easyai-ai-gateway/apps/web/src/pages/playground-media.tsx

819 lines
32 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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';
}