819 lines
32 KiB
TypeScript
819 lines
32 KiB
TypeScript
import { useEffect, useRef, type CSSProperties, type ReactNode } from 'react';
|
||
import type { GatewayTask, PlatformModel } from '@easyai-ai-gateway/contracts';
|
||
import {
|
||
Download,
|
||
Edit3,
|
||
Image as ImageIcon,
|
||
Images,
|
||
Link2,
|
||
LoaderCircle,
|
||
Sparkles,
|
||
Square,
|
||
} from 'lucide-react';
|
||
import { resolveApiAssetUrl } from '../api';
|
||
import { Button, Input, Popover, PopoverContent, PopoverTrigger } from '../components/ui';
|
||
import type { PlaygroundMode } from '../types';
|
||
|
||
export type MediaOutputMode = 'single' | 'group';
|
||
export type MediaCountPreset = 1 | 2 | 3 | 4 | 'custom';
|
||
export type MediaResolution = string;
|
||
|
||
const mediaGridGap = 2;
|
||
const mediaPreviewMaxHeight = 600;
|
||
|
||
export interface MediaGenerationSettings {
|
||
aspectRatio: string;
|
||
countPreset: MediaCountPreset;
|
||
customCount: number;
|
||
height: number;
|
||
outputMode: MediaOutputMode;
|
||
resolution: MediaResolution;
|
||
width: number;
|
||
}
|
||
|
||
export interface MediaGenerationRun {
|
||
createdAt: string;
|
||
error?: string;
|
||
localId: string;
|
||
mode: Exclude<PlaygroundMode, 'chat'>;
|
||
modelLabel: string;
|
||
prompt: string;
|
||
settings: MediaGenerationSettings;
|
||
status: GatewayTask['status'] | 'submitting';
|
||
task?: GatewayTask;
|
||
}
|
||
|
||
interface AspectRatioOption {
|
||
label: string;
|
||
value: string;
|
||
visual: 'smart' | 'wide' | 'landscape' | 'square' | 'portrait' | 'tall';
|
||
}
|
||
|
||
interface ResolutionOption {
|
||
label: string;
|
||
modes: Array<Exclude<PlaygroundMode, 'chat'>>;
|
||
value: MediaResolution;
|
||
}
|
||
|
||
export interface MediaModelCapabilities {
|
||
aspectRatios: string[];
|
||
maxCount: number;
|
||
resolutions: MediaResolution[];
|
||
supportsGroup: boolean;
|
||
}
|
||
|
||
const aspectRatioOptions: AspectRatioOption[] = [
|
||
{ value: 'auto', label: '智能', visual: 'smart' },
|
||
{ value: '8:1', label: '8:1', visual: 'wide' },
|
||
{ value: '4:1', label: '4:1', visual: 'wide' },
|
||
{ value: '21:9', label: '21:9', visual: 'wide' },
|
||
{ value: '16:9', label: '16:9', visual: 'wide' },
|
||
{ value: '3:2', label: '3:2', visual: 'landscape' },
|
||
{ value: '5:4', label: '5:4', visual: 'landscape' },
|
||
{ value: '4:3', label: '4:3', visual: 'landscape' },
|
||
{ value: '1:1', label: '1:1', visual: 'square' },
|
||
{ value: '4:5', label: '4:5', visual: 'portrait' },
|
||
{ value: '3:4', label: '3:4', visual: 'portrait' },
|
||
{ value: '2:3', label: '2:3', visual: 'portrait' },
|
||
{ value: '9:16', label: '9:16', visual: 'tall' },
|
||
{ value: '1:4', label: '1:4', visual: 'tall' },
|
||
{ value: '1:8', label: '1:8', visual: 'tall' },
|
||
];
|
||
|
||
const resolutionOptions: ResolutionOption[] = [
|
||
{ value: '1K', label: '标准 1K', modes: ['image'] },
|
||
{ value: '2K', label: '高清 2K', modes: ['image'] },
|
||
{ value: '4K', label: '超清 4K', modes: ['image', 'video'] },
|
||
{ value: '480p', label: '标清 480p', modes: ['video'] },
|
||
{ value: '720p', label: '高清 720p', modes: ['video'] },
|
||
{ value: '1080p', label: '全高清 1080p', modes: ['video'] },
|
||
{ value: '2160p', label: '超清 2160p', modes: ['video'] },
|
||
];
|
||
|
||
const countPresetOptions: Array<{ label: string; value: MediaCountPreset }> = [
|
||
{ value: 1, label: '1' },
|
||
{ value: 2, label: '2' },
|
||
{ value: 3, label: '3' },
|
||
{ value: 4, label: '4' },
|
||
{ value: 'custom', label: '自定义' },
|
||
];
|
||
|
||
export function defaultMediaGenerationSettings(): MediaGenerationSettings {
|
||
return {
|
||
aspectRatio: '1:1',
|
||
countPreset: 1,
|
||
customCount: 6,
|
||
height: 2048,
|
||
outputMode: 'single',
|
||
resolution: '2K',
|
||
width: 2048,
|
||
};
|
||
}
|
||
|
||
export function mediaOutputCount(settings: MediaGenerationSettings) {
|
||
if (settings.outputMode === 'single') return 1;
|
||
const raw = settings.countPreset === 'custom' ? settings.customCount : settings.countPreset;
|
||
return clampNumber(raw, 1, 20);
|
||
}
|
||
|
||
export function mediaRequestPayload(settings: MediaGenerationSettings) {
|
||
const count = mediaOutputCount(settings);
|
||
const size = `${settings.width}x${settings.height}`;
|
||
const highQuality = settings.resolution === '4K' || settings.resolution === '2160p';
|
||
return {
|
||
aspect_ratio: settings.aspectRatio === 'auto' ? undefined : settings.aspectRatio,
|
||
count,
|
||
height: settings.height,
|
||
n: count,
|
||
quality: highQuality ? 'high' : 'medium',
|
||
resolution: settings.resolution,
|
||
size,
|
||
width: settings.width,
|
||
};
|
||
}
|
||
|
||
export function deriveMediaModelCapabilities(
|
||
models: PlatformModel[] | PlatformModel | undefined,
|
||
mode: Exclude<PlaygroundMode, 'chat'>,
|
||
contextKey?: string,
|
||
): MediaModelCapabilities {
|
||
const modelList = (Array.isArray(models) ? models : models ? [models] : []).filter(Boolean);
|
||
if (!modelList.length) return defaultMediaModelCapabilities(mode);
|
||
const derived = modelList.map((model) => deriveSingleMediaModelCapabilities(model, mode, contextKey));
|
||
return {
|
||
aspectRatios: intersectOptionValues(derived.map((item) => item.aspectRatios), aspectRatioOptions.map((item) => item.value)),
|
||
maxCount: Math.max(1, Math.min(...derived.map((item) => item.maxCount))),
|
||
resolutions: intersectOptionValues(derived.map((item) => item.resolutions), resolutionOptionsForMode(mode).map((item) => item.value)),
|
||
supportsGroup: derived.every((item) => item.supportsGroup),
|
||
};
|
||
}
|
||
|
||
export function normalizeMediaSettingsForCapabilities(
|
||
settings: MediaGenerationSettings,
|
||
capabilities: MediaModelCapabilities,
|
||
mode: Exclude<PlaygroundMode, 'chat'>,
|
||
) {
|
||
const aspectOptions = filterAspectRatioOptions(capabilities);
|
||
const resolutionItems = filterResolutionOptions(capabilities, mode);
|
||
const maxCount = Math.max(1, Math.min(capabilities.maxCount, 20));
|
||
const supportsGroup = capabilities.supportsGroup && maxCount > 1;
|
||
const next: MediaGenerationSettings = {
|
||
...settings,
|
||
aspectRatio: aspectOptions.some((item) => item.value === settings.aspectRatio) ? settings.aspectRatio : aspectOptions[0]?.value ?? 'auto',
|
||
resolution: resolutionItems.some((item) => item.value === settings.resolution) ? settings.resolution : resolutionItems[0]?.value ?? settings.resolution,
|
||
};
|
||
|
||
if (!supportsGroup) {
|
||
next.countPreset = 1;
|
||
next.customCount = 1;
|
||
next.outputMode = 'single';
|
||
} else if (next.outputMode === 'group') {
|
||
if (next.countPreset === 'custom') {
|
||
next.customCount = clampNumber(next.customCount, 2, maxCount);
|
||
} else if (next.countPreset > maxCount) {
|
||
next.countPreset = maxCount <= 4 ? (maxCount as MediaCountPreset) : 'custom';
|
||
next.customCount = maxCount;
|
||
} else if (next.countPreset === 1) {
|
||
next.outputMode = 'single';
|
||
}
|
||
} else {
|
||
next.countPreset = 1;
|
||
}
|
||
|
||
return mediaSettingsEqual(settings, next) ? settings : next;
|
||
}
|
||
|
||
export function MediaSettingsPopover(props: {
|
||
capabilities?: MediaModelCapabilities;
|
||
mode: Exclude<PlaygroundMode, 'chat'>;
|
||
settings: MediaGenerationSettings;
|
||
onChange: (settings: MediaGenerationSettings) => void;
|
||
}) {
|
||
const capabilities = props.capabilities ?? defaultMediaModelCapabilities(props.mode);
|
||
const aspectOptions = filterAspectRatioOptions(capabilities);
|
||
const resolutionItems = resolutionOptionsForMode(props.mode);
|
||
const enabledResolutions = new Set(filterResolutionOptions(capabilities, props.mode).map((item) => item.value));
|
||
const maxCount = Math.max(1, Math.min(capabilities.maxCount, 20));
|
||
const supportsGroup = capabilities.supportsGroup && maxCount > 1;
|
||
const countOptions = countPresetOptions.filter((item) => item.value === 'custom' ? maxCount > 4 : item.value <= Math.min(4, maxCount));
|
||
const count = mediaOutputCount(props.settings);
|
||
const unit = props.mode === 'video' ? '条' : '张';
|
||
|
||
function patch(next: Partial<MediaGenerationSettings>) {
|
||
props.onChange(normalizeMediaSettingsForCapabilities({ ...props.settings, ...next }, capabilities, props.mode));
|
||
}
|
||
|
||
function selectCountPreset(value: MediaCountPreset) {
|
||
if (value !== 1 && !supportsGroup) return;
|
||
patch({
|
||
countPreset: value,
|
||
outputMode: value === 1 ? 'single' : 'group',
|
||
});
|
||
}
|
||
|
||
return (
|
||
<Popover>
|
||
<PopoverTrigger asChild>
|
||
<button type="button" className="mediaSettingsTrigger">
|
||
<Square size={15} />
|
||
<span>{mediaSettingsSummary(props.settings, props.mode)}</span>
|
||
</button>
|
||
</PopoverTrigger>
|
||
<PopoverContent align="start" className="mediaSettingsPanel" side="top" sideOffset={10}>
|
||
<section className="mediaSettingsSection">
|
||
<span className="mediaSettingsLabel">选择比例</span>
|
||
<div className="mediaAspectGrid">
|
||
{aspectOptions.map((item) => (
|
||
<button
|
||
type="button"
|
||
key={item.value}
|
||
data-active={props.settings.aspectRatio === item.value}
|
||
onClick={() => patch({ aspectRatio: item.value })}
|
||
>
|
||
<span className="mediaAspectIcon" data-visual={item.visual}>
|
||
{item.visual === 'smart' && <Sparkles size={16} />}
|
||
</span>
|
||
<strong>{item.label}</strong>
|
||
</button>
|
||
))}
|
||
</div>
|
||
</section>
|
||
|
||
<section className="mediaSettingsSection">
|
||
<span className="mediaSettingsLabel">选择分辨率</span>
|
||
<div className="mediaResolutionSegment">
|
||
{resolutionItems.map((item) => (
|
||
<button
|
||
type="button"
|
||
key={item.value}
|
||
data-active={props.settings.resolution === item.value}
|
||
disabled={!enabledResolutions.has(item.value)}
|
||
onClick={() => enabledResolutions.has(item.value) && patch({ resolution: item.value })}
|
||
>
|
||
{item.label}
|
||
{item.value === '4K' && <Sparkles size={15} />}
|
||
</button>
|
||
))}
|
||
</div>
|
||
</section>
|
||
|
||
<section className="mediaSettingsSection">
|
||
<span className="mediaSettingsLabel">尺寸</span>
|
||
<div className="mediaSizeRow">
|
||
<label>
|
||
<span>W</span>
|
||
<Input
|
||
inputMode="numeric"
|
||
min={128}
|
||
size="sm"
|
||
type="number"
|
||
value={props.settings.width}
|
||
onChange={(event) => patch({ width: clampNumber(Number(event.target.value), 128, 8192) })}
|
||
/>
|
||
</label>
|
||
<Link2 size={18} />
|
||
<label>
|
||
<span>H</span>
|
||
<Input
|
||
inputMode="numeric"
|
||
min={128}
|
||
size="sm"
|
||
type="number"
|
||
value={props.settings.height}
|
||
onChange={(event) => patch({ height: clampNumber(Number(event.target.value), 128, 8192) })}
|
||
/>
|
||
</label>
|
||
<strong>PX</strong>
|
||
</div>
|
||
</section>
|
||
|
||
<section className="mediaSettingsSection">
|
||
<span className="mediaSettingsLabel">生成数量</span>
|
||
<div className="mediaOutputModeSegment">
|
||
<button type="button" data-active={props.settings.outputMode === 'single'} onClick={() => patch({ countPreset: 1, outputMode: 'single' })}>
|
||
<ImageIcon size={15} />
|
||
单图
|
||
</button>
|
||
<button type="button" disabled={!supportsGroup} data-active={props.settings.outputMode === 'group'} onClick={() => patch({ countPreset: 2, outputMode: 'group' })}>
|
||
<Images size={15} />
|
||
{supportsGroup ? '组图' : '不支持组图'}
|
||
</button>
|
||
</div>
|
||
{supportsGroup ? (
|
||
<>
|
||
<div className="mediaCountGrid">
|
||
{countOptions.map((item) => (
|
||
<button
|
||
type="button"
|
||
key={String(item.value)}
|
||
data-active={props.settings.countPreset === item.value}
|
||
onClick={() => selectCountPreset(item.value)}
|
||
>
|
||
{item.value === 'custom' ? item.label : `${item.label}${unit}`}
|
||
</button>
|
||
))}
|
||
</div>
|
||
{props.settings.countPreset === 'custom' && props.settings.outputMode === 'group' && (
|
||
<label className="mediaCustomCount">
|
||
<span>自定义数量</span>
|
||
<Input
|
||
inputMode="numeric"
|
||
min={2}
|
||
max={maxCount}
|
||
size="sm"
|
||
type="number"
|
||
value={props.settings.customCount}
|
||
onChange={(event) => patch({ customCount: clampNumber(Number(event.target.value), 2, maxCount) })}
|
||
/>
|
||
</label>
|
||
)}
|
||
</>
|
||
) : (
|
||
<p className="mediaUnsupportedNote">当前模型不支持组图输出。</p>
|
||
)}
|
||
<p className="mediaSettingsHint">
|
||
<Sparkles size={14} />
|
||
{count} / {unit}
|
||
</p>
|
||
</section>
|
||
</PopoverContent>
|
||
</Popover>
|
||
);
|
||
}
|
||
|
||
export function MediaTaskBoard(props: {
|
||
composer: ReactNode;
|
||
message?: string;
|
||
onEditRun?: (run: MediaGenerationRun) => void;
|
||
onRerun?: (run: MediaGenerationRun) => void;
|
||
runs: MediaGenerationRun[];
|
||
}) {
|
||
const timelineRef = useRef<HTMLElement>(null);
|
||
|
||
useEffect(() => {
|
||
const timeline = timelineRef.current;
|
||
if (!timeline) return;
|
||
timeline.scrollTo({ behavior: 'smooth', top: timeline.scrollHeight });
|
||
}, [props.runs.length]);
|
||
|
||
return (
|
||
<div className="mediaTaskPage">
|
||
<section ref={timelineRef} className="mediaTaskTimeline" aria-label="生成任务列表">
|
||
<h1>今天</h1>
|
||
{props.message && <p className="playgroundError">{props.message}</p>}
|
||
{props.runs.map((run) => (
|
||
<MediaTaskCard
|
||
key={run.localId}
|
||
run={run}
|
||
onEditRun={props.onEditRun}
|
||
onRerun={props.onRerun}
|
||
/>
|
||
))}
|
||
</section>
|
||
<div className="mediaComposerDock">
|
||
{props.composer}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function MediaTaskCard(props: {
|
||
run: MediaGenerationRun;
|
||
onEditRun?: (run: MediaGenerationRun) => void;
|
||
onRerun?: (run: MediaGenerationRun) => void;
|
||
}) {
|
||
const items = mediaResultItems(props.run);
|
||
const expectedCount = Math.max(mediaOutputCount(props.run.settings), items.length, 1);
|
||
const columns = expectedCount === 1 ? 1 : expectedCount === 2 ? 2 : Math.min(5, expectedCount);
|
||
const style = {
|
||
'--media-grid-columns': columns,
|
||
'--media-grid-max-width': mediaGridMaxWidth(props.run.settings, columns),
|
||
'--media-result-aspect': cssAspectRatio(props.run.settings),
|
||
} as CSSProperties;
|
||
const status = mediaStatusText(props.run);
|
||
const unit = props.run.mode === 'video' ? '条' : '张';
|
||
const isPending = props.run.status === 'submitting' || props.run.status === 'queued' || props.run.status === 'running';
|
||
const backdropItem = expectedCount === 1 && items[0]?.type === 'image' ? items[0] : undefined;
|
||
|
||
return (
|
||
<article className="mediaTaskItem" data-status={props.run.status}>
|
||
<header className="mediaTaskHeader">
|
||
<div>
|
||
<p>
|
||
<span>{props.run.prompt}</span>
|
||
<small>{props.run.mode === 'video' ? '视频' : '图片'} {props.run.modelLabel} {props.run.settings.aspectRatio} {props.run.settings.resolution}</small>
|
||
</p>
|
||
<time dateTime={props.run.createdAt}>{formatRunTime(props.run.createdAt)}</time>
|
||
</div>
|
||
<span className="mediaTaskStatus" data-status={props.run.status}>{status}</span>
|
||
</header>
|
||
|
||
<div className="mediaPreviewStage" data-count={expectedCount}>
|
||
{backdropItem && <img aria-hidden="true" className="mediaPreviewBackdrop" src={backdropItem.src} alt="" />}
|
||
<div className="mediaGrid" data-count={expectedCount} style={style}>
|
||
{Array.from({ length: expectedCount }).map((_, index) => (
|
||
<MediaTile
|
||
expectedCount={expectedCount}
|
||
index={index}
|
||
item={items[index]}
|
||
key={`${props.run.localId}-${index}`}
|
||
mode={props.run.mode}
|
||
status={props.run.status}
|
||
/>
|
||
))}
|
||
</div>
|
||
</div>
|
||
|
||
{props.run.error && <p className="mediaTaskError">{props.run.error}</p>}
|
||
<footer className="mediaTaskActions">
|
||
{items[0] ? (
|
||
<Button asChild size="sm" variant="secondary">
|
||
<a href={items[0].src} download target="_blank" rel="noreferrer">
|
||
<Download size={14} />
|
||
下载
|
||
</a>
|
||
</Button>
|
||
) : (
|
||
<Button type="button" size="sm" variant="secondary" disabled>
|
||
<Download size={14} />
|
||
下载
|
||
</Button>
|
||
)}
|
||
<Button type="button" size="sm" variant="secondary" onClick={() => props.onEditRun?.(props.run)}>
|
||
<Edit3 size={14} />
|
||
重新编辑
|
||
</Button>
|
||
<Button type="button" size="sm" variant="secondary" disabled={isPending} onClick={() => props.onRerun?.(props.run)}>
|
||
<Sparkles size={14} />
|
||
再次生成
|
||
</Button>
|
||
<span>
|
||
<Sparkles size={14} />
|
||
{expectedCount} / {unit}
|
||
</span>
|
||
</footer>
|
||
</article>
|
||
);
|
||
}
|
||
|
||
function MediaTile(props: {
|
||
expectedCount: number;
|
||
index: number;
|
||
item?: MediaResultItem;
|
||
mode: Exclude<PlaygroundMode, 'chat'>;
|
||
status: MediaGenerationRun['status'];
|
||
}) {
|
||
const isLoading = props.status === 'submitting' || props.status === 'queued' || props.status === 'running';
|
||
const isFailed = props.status === 'failed' || props.status === 'cancelled';
|
||
return (
|
||
<div className="mediaTile" data-count={props.expectedCount} data-empty={!props.item && !isLoading} data-kind={props.mode}>
|
||
{props.item?.type === 'video' && (
|
||
<video controls muted playsInline poster={props.item.poster}>
|
||
<source src={props.item.src} />
|
||
</video>
|
||
)}
|
||
{props.item?.type === 'image' && <img src={props.item.src} alt={`生成结果 ${props.index + 1}`} />}
|
||
{isLoading && !props.item && (
|
||
<div className="mediaLoading">
|
||
<LoaderCircle size={24} />
|
||
<span>{props.mode === 'video' ? '视频生成中' : '图片生成中'}</span>
|
||
</div>
|
||
)}
|
||
{isFailed && !props.item && (
|
||
<div className="mediaEmptyTile">
|
||
<span>生成失败</span>
|
||
</div>
|
||
)}
|
||
{!isLoading && !isFailed && !props.item && (
|
||
<div className="mediaEmptyTile">
|
||
<span>暂无结果</span>
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function mediaResultItems(run: MediaGenerationRun): MediaResultItem[] {
|
||
const data = arrayFromUnknown(run.task?.result?.data);
|
||
return data
|
||
.map((entry) => mediaResultItemFromEntry(entry, run.mode))
|
||
.filter((item): item is MediaResultItem => Boolean(item));
|
||
}
|
||
|
||
function mediaResultItemFromEntry(entry: unknown, mode: Exclude<PlaygroundMode, 'chat'>): MediaResultItem | undefined {
|
||
const record = recordFromUnknown(entry);
|
||
if (!record) return undefined;
|
||
const b64 = stringFromUnknown(record.b64_json);
|
||
if (b64) return { src: `data:image/png;base64,${b64}`, type: 'image' };
|
||
const videoUrl = firstString(record.video_url, nestedString(record.video_url, 'url'), record.videoUrl, record.output_video_url);
|
||
if (videoUrl) return { src: resolveApiAssetUrl(videoUrl), type: 'video' };
|
||
const imageUrl = firstString(record.url, record.image_url, nestedString(record.image_url, 'url'), record.output_url);
|
||
if (!imageUrl) return undefined;
|
||
const normalized = resolveApiAssetUrl(imageUrl);
|
||
const type = mode === 'video' || /\.(mp4|mov|webm|m4v)(\?|#|$)/i.test(normalized) ? 'video' : 'image';
|
||
return { src: normalized, type };
|
||
}
|
||
|
||
function mediaSettingsSummary(settings: MediaGenerationSettings, mode: Exclude<PlaygroundMode, 'chat'>) {
|
||
const count = mediaOutputCount(settings);
|
||
const unit = mode === 'video' ? '条' : '张';
|
||
const resolutionLabel = resolutionOptionsForMode(mode).find((item) => item.value === settings.resolution)?.label ?? settings.resolution;
|
||
const modeLabel = settings.outputMode === 'single' ? '单图' : '组图';
|
||
return `${settings.aspectRatio} | ${resolutionLabel} | ${settings.width}x${settings.height} | ${modeLabel} ${count}${unit}`;
|
||
}
|
||
|
||
function mediaStatusText(run: MediaGenerationRun) {
|
||
if (run.status === 'submitting') return '提交中';
|
||
if (run.status === 'queued') return '排队中';
|
||
if (run.status === 'running') return '生成中';
|
||
if (run.status === 'succeeded') return '已完成';
|
||
if (run.status === 'failed') return '失败';
|
||
if (run.status === 'cancelled') return '已取消';
|
||
return run.status;
|
||
}
|
||
|
||
function formatRunTime(value: string) {
|
||
return new Intl.DateTimeFormat('zh-CN', { hour: '2-digit', minute: '2-digit' }).format(new Date(value));
|
||
}
|
||
|
||
function cssAspectRatio(settings: MediaGenerationSettings) {
|
||
const [aspectWidth, aspectHeight] = settings.aspectRatio.split(':').map((item) => Number(item));
|
||
if (Number.isFinite(aspectWidth) && Number.isFinite(aspectHeight) && aspectWidth > 0 && aspectHeight > 0) {
|
||
return `${aspectWidth} / ${aspectHeight}`;
|
||
}
|
||
return `${settings.width || 1} / ${settings.height || 1}`;
|
||
}
|
||
|
||
function mediaGridMaxWidth(settings: MediaGenerationSettings, columns: number) {
|
||
const ratio = numericAspectRatio(settings);
|
||
const safeColumns = Math.max(1, columns);
|
||
const maxWidth = (mediaPreviewMaxHeight * ratio * safeColumns) + (mediaGridGap * (safeColumns - 1));
|
||
return `${Math.max(120, Math.ceil(maxWidth))}px`;
|
||
}
|
||
|
||
function numericAspectRatio(settings: MediaGenerationSettings) {
|
||
const [aspectWidth, aspectHeight] = settings.aspectRatio.split(':').map((item) => Number(item));
|
||
if (Number.isFinite(aspectWidth) && Number.isFinite(aspectHeight) && aspectWidth > 0 && aspectHeight > 0) {
|
||
return aspectWidth / aspectHeight;
|
||
}
|
||
const width = Number(settings.width);
|
||
const height = Number(settings.height);
|
||
if (Number.isFinite(width) && Number.isFinite(height) && width > 0 && height > 0) {
|
||
return width / height;
|
||
}
|
||
return 1;
|
||
}
|
||
|
||
function deriveSingleMediaModelCapabilities(
|
||
model: PlatformModel,
|
||
mode: Exclude<PlaygroundMode, 'chat'>,
|
||
contextKey?: string,
|
||
): MediaModelCapabilities {
|
||
const source = mergeCapabilityRecords(model.capabilities, model.capabilityOverride);
|
||
const typeKeys = capabilityTypeKeys(model, source, mode, contextKey);
|
||
const defaultCapabilities = defaultMediaModelCapabilities(mode);
|
||
const resolutionValues = normalizeResolutionValues(stringListFromCapability(firstCapabilityValue(source, typeKeys, ['output_resolutions']), [contextKey]));
|
||
const allowedAspectValues = normalizeAspectRatioValues(stringListFromCapability(firstCapabilityValue(source, typeKeys, ['aspect_ratio_allowed']), [contextKey]));
|
||
const ratioRange = ratioRangeFromValue(firstCapabilityValue(source, typeKeys, ['aspect_ratio_range']));
|
||
const rangedAspectValues = ratioRange ? aspectRatioOptions
|
||
.filter((item) => item.value === 'auto' || aspectRatioWithinRange(item.value, ratioRange))
|
||
.map((item) => item.value) : [];
|
||
const aspectRatios = allowedAspectValues.length && rangedAspectValues.length
|
||
? allowedAspectValues.filter((item) => rangedAspectValues.includes(item))
|
||
: allowedAspectValues.length ? allowedAspectValues : rangedAspectValues;
|
||
const maxCountValue = numberFromUnknown(firstCapabilityValue(source, typeKeys, countCapabilityKeys(mode)));
|
||
const explicitGroupSupport = boolFromUnknown(firstCapabilityValue(source, typeKeys, groupCapabilityKeys(mode)));
|
||
const maxCount = explicitGroupSupport === false ? 1 : clampNumber(maxCountValue ?? defaultCapabilities.maxCount, 1, 20);
|
||
const supportsGroup = explicitGroupSupport === false ? false : maxCount > 1;
|
||
|
||
return {
|
||
aspectRatios: aspectRatios.length ? aspectRatios : defaultCapabilities.aspectRatios,
|
||
maxCount,
|
||
resolutions: resolutionValues.length ? resolutionValues : defaultCapabilities.resolutions,
|
||
supportsGroup,
|
||
};
|
||
}
|
||
|
||
function defaultMediaModelCapabilities(mode: Exclude<PlaygroundMode, 'chat'>): MediaModelCapabilities {
|
||
return {
|
||
aspectRatios: aspectRatioOptions.map((item) => item.value),
|
||
maxCount: 20,
|
||
resolutions: resolutionOptionsForMode(mode).map((item) => item.value),
|
||
supportsGroup: true,
|
||
};
|
||
}
|
||
|
||
function filterAspectRatioOptions(capabilities: MediaModelCapabilities) {
|
||
const allowed = new Set(capabilities.aspectRatios);
|
||
const items = aspectRatioOptions.filter((item) => allowed.has(item.value));
|
||
return items.length ? items : aspectRatioOptions;
|
||
}
|
||
|
||
function filterResolutionOptions(capabilities: MediaModelCapabilities, mode: Exclude<PlaygroundMode, 'chat'>) {
|
||
const allowed = new Set(capabilities.resolutions);
|
||
const modeOptions = resolutionOptionsForMode(mode);
|
||
const items = modeOptions.filter((item) => allowed.has(item.value));
|
||
return items.length ? items : modeOptions;
|
||
}
|
||
|
||
function resolutionOptionsForMode(mode: Exclude<PlaygroundMode, 'chat'>) {
|
||
return resolutionOptions.filter((item) => item.modes.includes(mode));
|
||
}
|
||
|
||
function intersectOptionValues(values: string[][], fallback: string[]) {
|
||
const nonEmptyValues = values.filter((items) => items.length > 0);
|
||
if (!nonEmptyValues.length) return fallback;
|
||
const intersection = fallback.filter((item) => nonEmptyValues.every((items) => items.includes(item)));
|
||
return intersection.length ? intersection : nonEmptyValues[0];
|
||
}
|
||
|
||
function capabilityTypeKeys(
|
||
model: PlatformModel,
|
||
source: Record<string, unknown>,
|
||
mode: Exclude<PlaygroundMode, 'chat'>,
|
||
contextKey?: string,
|
||
) {
|
||
const modeTypeHints = mode === 'image'
|
||
? ['image_generate', 'image_edit', 'images.generations', 'images.edits', 'image']
|
||
: [contextKey, 'text_to_video', 'image_to_video', 'omni_video', 'video_generate', 'video'];
|
||
return uniqueStrings([
|
||
...stringListFromCapability(source.originalTypes),
|
||
model.modelType,
|
||
...modeTypeHints.filter((item): item is string => Boolean(item)),
|
||
]);
|
||
}
|
||
|
||
function firstCapabilityValue(source: Record<string, unknown>, typeKeys: string[], keys: string[]) {
|
||
for (const key of keys) {
|
||
const value = nestedCapabilityValue(source, typeKeys, key);
|
||
if (hasCapabilityValue(value)) return value;
|
||
}
|
||
return undefined;
|
||
}
|
||
|
||
function nestedCapabilityValue(source: Record<string, unknown>, typeKeys: string[], key: string) {
|
||
for (const type of typeKeys) {
|
||
const config = recordFromUnknown(source[type]);
|
||
if (config && key in config) return config[key];
|
||
}
|
||
if (key in source) return source[key];
|
||
for (const value of Object.values(source)) {
|
||
const config = recordFromUnknown(value);
|
||
if (config && key in config) return config[key];
|
||
}
|
||
return undefined;
|
||
}
|
||
|
||
function groupCapabilityKeys(mode: Exclude<PlaygroundMode, 'chat'>) {
|
||
return mode === 'image'
|
||
? ['output_multiple_images', 'multiple_images', 'support_multiple_images', 'supports_group']
|
||
: ['output_multiple_videos', 'multiple_videos', 'support_multiple_videos', 'supports_group'];
|
||
}
|
||
|
||
function countCapabilityKeys(mode: Exclude<PlaygroundMode, 'chat'>) {
|
||
return mode === 'image'
|
||
? ['output_max_images_count', 'max_images', 'max_outputs', 'output_max_count']
|
||
: ['output_max_videos_count', 'max_videos', 'max_outputs', 'output_max_count'];
|
||
}
|
||
|
||
function mergeCapabilityRecords(...records: Array<Record<string, unknown> | undefined>) {
|
||
return records.reduce<Record<string, unknown>>((acc, record) => {
|
||
if (!record) return acc;
|
||
Object.entries(record).forEach(([key, value]) => {
|
||
const current = recordFromUnknown(acc[key]);
|
||
const next = recordFromUnknown(value);
|
||
acc[key] = current && next ? { ...current, ...next } : value;
|
||
});
|
||
return acc;
|
||
}, {});
|
||
}
|
||
|
||
function stringListFromCapability(value: unknown, preferredKeys: Array<string | undefined> = []): string[] {
|
||
if (Array.isArray(value)) return value.map(String).map((item) => item.trim()).filter(Boolean);
|
||
if (typeof value === 'string') return value.split(/[,,\s]+/).map((item) => item.trim()).filter(Boolean);
|
||
const record = recordFromUnknown(value);
|
||
if (!record) return [];
|
||
const preferred = preferredKeys
|
||
.filter((key): key is string => Boolean(key))
|
||
.flatMap((key) => stringListFromCapability(record[key]));
|
||
if (preferred.length) return preferred;
|
||
return Object.values(record).flatMap((item) => stringListFromCapability(item));
|
||
}
|
||
|
||
function normalizeAspectRatioValues(values: string[]) {
|
||
const allowedValues = new Set(aspectRatioOptions.map((item) => item.value));
|
||
return uniqueStrings(values.map((value) => {
|
||
const normalized = value.toLowerCase().replace(/[×x]/g, ':').replace(/\s+/g, '');
|
||
if (['auto', 'smart', 'adaptive', '智能'].includes(normalized)) return 'auto';
|
||
return normalized;
|
||
})).filter((value) => allowedValues.has(value));
|
||
}
|
||
|
||
function normalizeResolutionValues(values: string[]) {
|
||
const allowedValues = new Set(resolutionOptions.map((item) => item.value));
|
||
return uniqueStrings(values.map((value) => {
|
||
const normalized = value.trim().toLowerCase();
|
||
if (normalized.includes('2160')) return '2160p';
|
||
if (normalized.includes('1080')) return '1080p';
|
||
if (normalized.includes('720')) return '720p';
|
||
if (normalized.includes('480')) return '480p';
|
||
if (normalized.includes('4k') || normalized.includes('4096')) return '4K';
|
||
if (normalized.includes('2k') || normalized.includes('2048')) return '2K';
|
||
if (normalized.includes('1k') || normalized.includes('1024')) return '1K';
|
||
return value.trim();
|
||
})).filter((value) => allowedValues.has(value));
|
||
}
|
||
|
||
function ratioRangeFromValue(value: unknown): [number, number] | undefined {
|
||
if (Array.isArray(value) && value.length >= 2) {
|
||
const min = Number(value[0]);
|
||
const max = Number(value[1]);
|
||
if (Number.isFinite(min) && Number.isFinite(max)) return [Math.min(min, max), Math.max(min, max)];
|
||
}
|
||
if (typeof value === 'string') {
|
||
const numbers = value.match(/-?\d+(?:\.\d+)?/g)?.map(Number) ?? [];
|
||
if (numbers.length >= 2 && numbers.every(Number.isFinite)) return [Math.min(numbers[0], numbers[1]), Math.max(numbers[0], numbers[1])];
|
||
}
|
||
const record = recordFromUnknown(value);
|
||
if (!record) return undefined;
|
||
for (const item of Object.values(record)) {
|
||
const nested = ratioRangeFromValue(item);
|
||
if (nested) return nested;
|
||
}
|
||
return undefined;
|
||
}
|
||
|
||
function aspectRatioWithinRange(value: string, range: [number, number]) {
|
||
const [width, height] = value.split(':').map(Number);
|
||
if (!Number.isFinite(width) || !Number.isFinite(height) || height <= 0) return false;
|
||
const ratio = width / height;
|
||
return ratio >= range[0] && ratio <= range[1];
|
||
}
|
||
|
||
function hasCapabilityValue(value: unknown) {
|
||
if (value === undefined || value === null || value === '') return false;
|
||
if (Array.isArray(value)) return value.length > 0;
|
||
return true;
|
||
}
|
||
|
||
function boolFromUnknown(value: unknown) {
|
||
if (value === true || value === 'true') return true;
|
||
if (value === false || value === 'false') return false;
|
||
return undefined;
|
||
}
|
||
|
||
function numberFromUnknown(value: unknown) {
|
||
if (typeof value === 'number' && Number.isFinite(value)) return value;
|
||
if (typeof value === 'string' && value.trim()) {
|
||
const parsed = Number(value);
|
||
if (Number.isFinite(parsed)) return parsed;
|
||
}
|
||
return undefined;
|
||
}
|
||
|
||
function uniqueStrings(values: string[]) {
|
||
return Array.from(new Set(values.map((item) => item.trim()).filter(Boolean)));
|
||
}
|
||
|
||
function mediaSettingsEqual(left: MediaGenerationSettings, right: MediaGenerationSettings) {
|
||
return left.aspectRatio === right.aspectRatio
|
||
&& left.countPreset === right.countPreset
|
||
&& left.customCount === right.customCount
|
||
&& left.height === right.height
|
||
&& left.outputMode === right.outputMode
|
||
&& left.resolution === right.resolution
|
||
&& left.width === right.width;
|
||
}
|
||
|
||
function firstString(...values: unknown[]) {
|
||
return values.map(stringFromUnknown).find(Boolean) ?? '';
|
||
}
|
||
|
||
function nestedString(value: unknown, key: string) {
|
||
return stringFromUnknown(recordFromUnknown(value)?.[key]);
|
||
}
|
||
|
||
function stringFromUnknown(value: unknown) {
|
||
return typeof value === 'string' ? value.trim() : '';
|
||
}
|
||
|
||
function arrayFromUnknown(value: unknown) {
|
||
return Array.isArray(value) ? value : [];
|
||
}
|
||
|
||
function recordFromUnknown(value: unknown): Record<string, unknown> | undefined {
|
||
if (!value || typeof value !== 'object' || Array.isArray(value)) return undefined;
|
||
return value as Record<string, unknown>;
|
||
}
|
||
|
||
function clampNumber(value: number, min: number, max: number) {
|
||
if (!Number.isFinite(value)) return min;
|
||
return Math.min(max, Math.max(min, Math.round(value)));
|
||
}
|
||
|
||
interface MediaResultItem {
|
||
poster?: string;
|
||
src: string;
|
||
type: 'image' | 'video';
|
||
}
|