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; 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>; 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, 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, ) { 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; 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) { 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 (
选择比例
{aspectOptions.map((item) => ( ))}
选择分辨率
{resolutionItems.map((item) => ( ))}
尺寸
PX
生成数量
{supportsGroup ? ( <>
{countOptions.map((item) => ( ))}
{props.settings.countPreset === 'custom' && props.settings.outputMode === 'group' && ( )} ) : (

当前模型不支持组图输出。

)}

{count} / {unit}

); } export function MediaTaskBoard(props: { composer: ReactNode; message?: string; onEditRun?: (run: MediaGenerationRun) => void; onRerun?: (run: MediaGenerationRun) => void; runs: MediaGenerationRun[]; }) { const timelineRef = useRef(null); useEffect(() => { const timeline = timelineRef.current; if (!timeline) return; timeline.scrollTo({ behavior: 'smooth', top: timeline.scrollHeight }); }, [props.runs.length]); return (

今天

{props.message &&

{props.message}

} {props.runs.map((run) => ( ))}
{props.composer}
); } 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 (

{props.run.prompt} {props.run.mode === 'video' ? '视频' : '图片'} {props.run.modelLabel} {props.run.settings.aspectRatio} {props.run.settings.resolution}

{status}
{backdropItem && }
{Array.from({ length: expectedCount }).map((_, index) => ( ))}
{props.run.error &&

{props.run.error}

}
{items[0] ? ( ) : ( )} {expectedCount} / {unit}
); } function MediaTile(props: { expectedCount: number; index: number; item?: MediaResultItem; mode: Exclude; status: MediaGenerationRun['status']; }) { const isLoading = props.status === 'submitting' || props.status === 'queued' || props.status === 'running'; const isFailed = props.status === 'failed' || props.status === 'cancelled'; return (
{props.item?.type === 'video' && ( )} {props.item?.type === 'image' && {`生成结果} {isLoading && !props.item && (
{props.mode === 'video' ? '视频生成中' : '图片生成中'}
)} {isFailed && !props.item && (
生成失败
)} {!isLoading && !isFailed && !props.item && (
暂无结果
)}
); } 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): 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) { 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, 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): 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) { 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) { 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, mode: Exclude, 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, 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, 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) { 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) { 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 | undefined>) { return records.reduce>((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[] { 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 | undefined { if (!value || typeof value !== 'object' || Array.isArray(value)) return undefined; return value as Record; } 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'; }