diff --git a/apps/web/src/api.ts b/apps/web/src/api.ts index e52b1e9..062a8cd 100644 --- a/apps/web/src/api.ts +++ b/apps/web/src/api.ts @@ -511,7 +511,20 @@ export async function* streamChatCompletionText( export async function createImageGenerationTask( token: string, - input: { model: string; prompt: string; size?: string; quality?: string; runMode?: string; simulation?: boolean }, + input: { + model: string; + prompt: string; + aspect_ratio?: string; + count?: number; + height?: number; + n?: number; + quality?: string; + resolution?: string; + runMode?: string; + simulation?: boolean; + size?: string; + width?: number; + }, ): Promise<{ task: GatewayTask; next: Record }> { return request<{ task: GatewayTask; next: Record }>('/api/v1/images/generations', { body: input, @@ -531,6 +544,29 @@ export async function createImageEditTask( }); } +export async function createVideoGenerationTask( + token: string, + input: { + model: string; + prompt: string; + aspect_ratio?: string; + count?: number; + height?: number; + n?: number; + resolution?: string; + runMode?: string; + simulation?: boolean; + size?: string; + width?: number; + }, +): Promise<{ task: GatewayTask; next: Record }> { + return request<{ task: GatewayTask; next: Record }>('/api/v1/videos/generations', { + body: input, + method: 'POST', + token, + }); +} + export async function estimatePricing( token: string, input: Record, @@ -546,6 +582,11 @@ export async function getTask(token: string, taskId: string): Promise(`/api/v1/tasks/${taskId}`, { token }); } +export function resolveApiAssetUrl(src: string) { + if (/^(https?:|data:|blob:)/i.test(src)) return src; + return `${API_BASE}${src.startsWith('/') ? src : `/${src}`}`; +} + export async function listRateLimitWindows(token: string): Promise> { return request>('/api/admin/runtime/rate-limit-windows', { token }); } diff --git a/apps/web/src/pages/PlaygroundPage.tsx b/apps/web/src/pages/PlaygroundPage.tsx index 1681d6e..d4639f7 100644 --- a/apps/web/src/pages/PlaygroundPage.tsx +++ b/apps/web/src/pages/PlaygroundPage.tsx @@ -1,4 +1,4 @@ -import { useEffect, useMemo, useState, type ReactNode } from 'react'; +import { useEffect, useMemo, useRef, useState, type ReactNode } from 'react'; import { AssistantRuntimeProvider, ComposerPrimitive, @@ -6,25 +6,51 @@ import { ThreadPrimitive, useMessagePartText, useLocalRuntime, + useThread, type ChatModelAdapter, type ThreadMessage, + type ThreadMessageLike, } from '@assistant-ui/react'; import { StreamdownTextPrimitive } from '@assistant-ui/react-streamdown'; import { cjk } from '@streamdown/cjk'; import { code } from '@streamdown/code'; import { math } from '@streamdown/math'; import { mermaid } from '@streamdown/mermaid'; -import type { GatewayApiKey, PlatformModel } from '@easyai-ai-gateway/contracts'; +import type { GatewayApiKey, GatewayTask, PlatformModel } from '@easyai-ai-gateway/contracts'; import { Bot, ChevronDown, Image as ImageIcon, MessageSquarePlus, Paperclip, Send, Sparkles, Video } from 'lucide-react'; import { Badge, Button, Select, Textarea } from '../components/ui'; -import { streamChatCompletionText } from '../api'; +import { createImageGenerationTask, createVideoGenerationTask, getTask, streamChatCompletionText } from '../api'; import type { PlaygroundMode } from '../types'; +import { + defaultMediaGenerationSettings, + deriveMediaModelCapabilities, + mediaRequestPayload, + MediaSettingsPopover, + MediaTaskBoard, + normalizeMediaSettingsForCapabilities, + type MediaGenerationRun, + type MediaGenerationSettings, + type MediaModelCapabilities, +} from './playground-media'; type VideoCreateMode = 'text_to_video' | 'first_last_frame' | 'omni_reference'; +const MEDIA_RUNS_STORAGE_KEY = 'easyai:playground:media-runs:v1'; +const MEDIA_RUNS_STORAGE_LIMIT = 50; +const CHAT_MESSAGES_STORAGE_KEY = 'easyai:playground:chat-messages:v1'; +const CHAT_MESSAGES_STORAGE_LIMIT = 100; + +interface StoredChatMessage { + content: string; + createdAt: string; + id: string; + role: 'assistant' | 'user'; +} + interface ModelOption { count: number; label: string; + models: PlatformModel[]; provider: string; value: string; } @@ -87,16 +113,195 @@ export function PlaygroundPage(props: { const [imageHasReference, setImageHasReference] = useState(false); const [videoMode, setVideoMode] = useState('text_to_video'); const [threadKey, setThreadKey] = useState(0); + const [mediaSettings, setMediaSettings] = useState(defaultMediaGenerationSettings); + const [mediaRuns, setMediaRuns] = useState(readStoredMediaRuns); + const [mediaSubmitting, setMediaSubmitting] = useState(false); + const [mediaMessage, setMediaMessage] = useState(''); + const isMountedRef = useRef(false); + const resumedTaskIdsRef = useRef(new Set()); const activeMode = useMemo(() => modeOptions.find((item) => item.value === props.mode) ?? modeOptions[0], [props.mode]); const modelOptions = useMemo( () => buildModelOptions(filterModelsForMode(props.models, props.mode, imageHasReference, videoMode)), [imageHasReference, props.mode, props.models, videoMode], ); + const activeApiKeyId = resolveSelectedApiKeyId(props.apiKeys, props.apiKeySecretsById, props.selectedApiKeyId); + const activeApiKeySecret = activeApiKeyId ? props.apiKeySecretsById[activeApiKeyId] ?? '' : ''; + const activeModelOption = useMemo(() => modelOptions.find((item) => item.value === selectedModel), [modelOptions, selectedModel]); + const mediaCapabilities = useMemo( + () => props.mode === 'chat' + ? undefined + : deriveMediaModelCapabilities(activeModelOption?.models, props.mode, videoMode), + [activeModelOption, props.mode, videoMode], + ); useEffect(() => { setSelectedModel((current) => modelOptions.some((item) => item.value === current) ? current : modelOptions[0]?.value ?? ''); }, [modelOptions]); + useEffect(() => { + isMountedRef.current = true; + return () => { + isMountedRef.current = false; + }; + }, []); + + useEffect(() => { + if (props.mode === 'chat' || !mediaCapabilities) return; + const mediaMode = props.mode; + setMediaSettings((current) => normalizeMediaSettingsForCapabilities(current, mediaCapabilities, mediaMode)); + }, [mediaCapabilities, props.mode]); + + useEffect(() => { + writeStoredMediaRuns(mediaRuns); + }, [mediaRuns]); + + useEffect(() => { + const credential = activeApiKeySecret || props.token; + if (!credential) return; + const resumableRuns = mediaRuns.filter((run) => ( + run.task?.id + && taskIsPending(run.status) + && !resumedTaskIdsRef.current.has(run.task.id) + )); + resumableRuns.forEach((run) => { + if (!run.task?.id) return; + resumedTaskIdsRef.current.add(run.task.id); + void pollTaskUntilSettled(credential, run.task) + .then((detail) => { + if (!isMountedRef.current) return; + setMediaRuns((current) => updateMediaRun(current, run.localId, { + error: detail.error, + status: detail.status, + task: detail, + })); + }) + .catch((err) => { + if (!isMountedRef.current) return; + const errorMessage = err instanceof Error ? err.message : '任务状态同步失败'; + setMediaRuns((current) => updateMediaRun(current, run.localId, { error: errorMessage, status: 'failed' })); + }); + }); + }, [activeApiKeySecret, mediaRuns, props.token]); + + async function submitMediaTask(overrides?: { + mode?: Exclude; + prompt?: string; + settings?: MediaGenerationSettings; + }) { + const runMode = overrides?.mode ?? props.mode; + if (runMode === 'chat') return; + const sourceSettings = overrides?.settings ?? mediaSettings; + const runSettings = mediaCapabilities ? normalizeMediaSettingsForCapabilities(sourceSettings, mediaCapabilities, runMode) : sourceSettings; + const trimmedPrompt = (overrides?.prompt ?? prompt).trim(); + const credential = activeApiKeySecret || props.token; + if (!props.token) { + props.onLogin(); + return; + } + if (!credential) { + setMediaMessage('请选择可用于测试的 API Key;如果列表为空,请先创建一个 Key。'); + return; + } + if (!selectedModel) { + setMediaMessage('当前没有可用模型,请确认用户组权限或平台模型配置。'); + return; + } + if (!trimmedPrompt) { + setMediaMessage(runMode === 'video' ? '请输入视频提示词。' : '请输入图片提示词。'); + return; + } + + const localId = newLocalId(); + const modelLabel = modelOptions.find((item) => item.value === selectedModel)?.label ?? selectedModel; + const run: MediaGenerationRun = { + createdAt: new Date().toISOString(), + localId, + mode: runMode, + modelLabel, + prompt: trimmedPrompt, + settings: runSettings, + status: 'submitting', + }; + + setMediaRuns((current) => [...current, run]); + setMediaMessage(''); + setMediaSubmitting(true); + try { + const requestPayload = { + model: selectedModel, + prompt: trimmedPrompt, + ...mediaRequestPayload(runSettings), + }; + const response = runMode === 'video' + ? await createVideoGenerationTask(credential, requestPayload) + : await createImageGenerationTask(credential, requestPayload); + setMediaRuns((current) => updateMediaRun(current, localId, { status: response.task.status, task: response.task })); + const detail = await pollTaskUntilSettled(credential, response.task); + setMediaRuns((current) => updateMediaRun(current, localId, { + error: detail.error, + status: detail.status, + task: detail, + })); + } catch (err) { + const errorMessage = err instanceof Error ? err.message : '生成任务提交失败'; + setMediaMessage(errorMessage); + setMediaRuns((current) => updateMediaRun(current, localId, { error: errorMessage, status: 'failed' })); + } finally { + setMediaSubmitting(false); + } + } + + function editMediaRun(run: MediaGenerationRun) { + setPrompt(run.prompt); + setMediaSettings(run.settings); + if (props.mode !== run.mode) { + props.onModeChange(run.mode); + } + setMediaMessage('已带入这条任务的提示词和参数,可调整后再次生成。'); + } + + function rerunMediaRun(run: MediaGenerationRun) { + setPrompt(run.prompt); + setMediaSettings(run.settings); + if (props.mode !== run.mode) { + props.onModeChange(run.mode); + setMediaMessage('已切换到对应模式并带入参数,请确认模型后再次生成。'); + return; + } + void submitMediaTask({ mode: run.mode, prompt: run.prompt, settings: run.settings }); + } + + const mediaComposer = props.mode === 'chat' ? null : ( + void submitMediaTask()} + onVideoModeChange={setVideoMode} + /> + ); + + function startNewThread() { + clearStoredChatMessages(); + setThreadKey((value) => value + 1); + } + return (
- @@ -115,7 +320,7 @@ export function PlaygroundPage(props: {
-
+
0}> {props.mode === 'chat' ? ( + ) : mediaRuns.length > 0 && mediaComposer ? ( + ) : ( <> - undefined} - onVideoModeChange={setVideoMode} - /> + {mediaMessage &&

{mediaMessage}

} + {mediaComposer} )}
@@ -250,6 +446,7 @@ function AssistantChatPlayground(props: { const activeApiKeySecret = activeApiKeyId ? props.apiKeySecretsById[activeApiKeyId] ?? '' : ''; const canRun = Boolean(props.token && props.selectedModel && activeApiKeySecret); const apiKeyNotice = apiKeyNoticeText(props.apiKeys, props.apiKeySecretsById); + const initialMessages = useMemo(() => readStoredChatMessages(), []); const adapter = useMemo(() => ({ async *run({ abortSignal, messages }) { if (!props.token) { @@ -282,10 +479,11 @@ function AssistantChatPlayground(props: { }; }, }), [activeApiKeySecret, props]); - const runtime = useLocalRuntime(adapter); + const runtime = useLocalRuntime(adapter, { initialMessages }); return ( +
@@ -345,6 +543,21 @@ function AssistantChatPlayground(props: { ); } +function AssistantChatPersistenceBridge() { + const messages = useThread((state) => state.messages); + const skipInitialEmptyWriteRef = useRef(true); + + useEffect(() => { + if (skipInitialEmptyWriteRef.current) { + skipInitialEmptyWriteRef.current = false; + if (!messages.length && hasStoredChatMessages()) return; + } + writeStoredChatMessages(messages); + }, [messages]); + + return null; +} + function ModeSwitch(props: { activeMode: PlaygroundMode; onModeChange: (mode: PlaygroundMode) => void; @@ -535,6 +748,90 @@ function threadMessageText(message: ThreadMessage) { .trim(); } +function readStoredChatMessages(): ThreadMessageLike[] { + if (typeof window === 'undefined') return []; + try { + const raw = window.localStorage.getItem(CHAT_MESSAGES_STORAGE_KEY); + if (!raw) return []; + const parsed = JSON.parse(raw) as unknown; + const record = recordFromUnknown(parsed); + const source = Array.isArray(parsed) ? parsed : record?.messages; + if (!Array.isArray(source)) return []; + return source + .map(chatMessageLikeFromStorage) + .filter((item): item is ThreadMessageLike => Boolean(item)) + .slice(-CHAT_MESSAGES_STORAGE_LIMIT); + } catch { + return []; + } +} + +function hasStoredChatMessages() { + if (typeof window === 'undefined') return false; + try { + return Boolean(window.localStorage.getItem(CHAT_MESSAGES_STORAGE_KEY)); + } catch { + return false; + } +} + +function writeStoredChatMessages(messages: readonly ThreadMessage[]) { + if (typeof window === 'undefined') return; + try { + const storedMessages = messages + .map(storedChatMessageFromThread) + .filter((item): item is StoredChatMessage => Boolean(item)) + .slice(-CHAT_MESSAGES_STORAGE_LIMIT); + if (!storedMessages.length) { + window.localStorage.removeItem(CHAT_MESSAGES_STORAGE_KEY); + return; + } + window.localStorage.setItem(CHAT_MESSAGES_STORAGE_KEY, JSON.stringify({ + messages: storedMessages, + version: 1, + })); + } catch { + // Best effort only: local chat history should not block sending messages. + } +} + +function clearStoredChatMessages() { + if (typeof window === 'undefined') return; + try { + window.localStorage.removeItem(CHAT_MESSAGES_STORAGE_KEY); + } catch { + // Ignore storage errors. + } +} + +function chatMessageLikeFromStorage(value: unknown): ThreadMessageLike | undefined { + const record = recordFromUnknown(value); + if (!record) return undefined; + const role = record.role === 'assistant' || record.role === 'user' ? record.role : undefined; + const content = stringFromUnknown(record.content); + if (!role || !content) return undefined; + const createdAt = dateStringFromUnknown(record.createdAt); + return { + content, + createdAt: createdAt ? new Date(createdAt) : undefined, + id: stringFromUnknown(record.id) || undefined, + role, + status: role === 'assistant' ? { type: 'complete', reason: 'stop' } : undefined, + }; +} + +function storedChatMessageFromThread(message: ThreadMessage): StoredChatMessage | undefined { + if (message.role !== 'assistant' && message.role !== 'user') return undefined; + const content = threadMessageText(message); + if (!content) return undefined; + return { + content, + createdAt: message.createdAt.toISOString(), + id: message.id, + role: message.role, + }; +} + function resolveSelectedApiKeyId(apiKeys: GatewayApiKey[], secretsById: Record, selectedApiKeyId: string) { if (selectedApiKeyId && secretsById[selectedApiKeyId]) return selectedApiKeyId; const firstUsable = apiKeys.find((item) => Boolean(secretsById[item.id])); @@ -561,6 +858,9 @@ function Composer(props: { apiKeys?: GatewayApiKey[]; compact?: boolean; imageHasReference?: boolean; + isSubmitting?: boolean; + mediaCapabilities?: MediaModelCapabilities; + mediaSettings?: MediaGenerationSettings; mode: PlaygroundMode; modelOptions: ModelOption[]; prompt: string; @@ -570,6 +870,7 @@ function Composer(props: { onApiKeyChange?: (apiKeyId: string) => void; onCreateApiKey?: () => void; onImageReferenceChange?: (value: boolean) => void; + onMediaSettingsChange?: (settings: MediaGenerationSettings) => void; onModeChange: (mode: PlaygroundMode) => void; onModelChange: (value: string) => void; onPromptChange: (value: string) => void; @@ -611,6 +912,14 @@ function Composer(props: { )) : } + {props.mode !== 'chat' && props.mediaSettings && props.onMediaSettingsChange && ( + + )} {props.apiKeys && props.apiKeySecretsById && props.onApiKeyChange && ( {quickItems.map((item) => )}
- @@ -691,6 +1000,7 @@ function buildModelOptions(models: PlatformModel[]): ModelOption[] { const current = grouped.get(value); if (current) { current.count += 1; + current.models.push(model); if (!current.provider.includes(model.provider || '')) { current.provider = [current.provider, model.provider].filter(Boolean).join(' / '); } @@ -699,6 +1009,7 @@ function buildModelOptions(models: PlatformModel[]): ModelOption[] { grouped.set(value, { count: 1, label: model.modelAlias || model.displayName || model.modelName, + models: [model], provider: model.provider || model.platformName || '', value, }); @@ -711,3 +1022,149 @@ function modelOptionLabel(option: ModelOption) { const provider = option.provider ? ` · ${option.provider}` : ''; return `${option.label}${provider}${count}`; } + +function updateMediaRun(runs: MediaGenerationRun[], localId: string, patch: Partial) { + return runs.map((run) => run.localId === localId ? { ...run, ...patch } : run); +} + +async function pollTaskUntilSettled(token: string, task: GatewayTask) { + let detail = task; + for (let attempt = 0; attempt < 20; attempt += 1) { + detail = await getTask(token, detail.id); + if (!taskIsPending(detail.status)) return detail; + await delay(1200); + } + return detail; +} + +function taskIsPending(status: string) { + return status === 'queued' || status === 'running' || status === 'submitting'; +} + +function readStoredMediaRuns(): MediaGenerationRun[] { + if (typeof window === 'undefined') return []; + try { + const raw = window.localStorage.getItem(MEDIA_RUNS_STORAGE_KEY); + if (!raw) return []; + const parsed = JSON.parse(raw) as unknown; + const record = recordFromUnknown(parsed); + const source = Array.isArray(parsed) ? parsed : record?.runs; + if (!Array.isArray(source)) return []; + const runs = source + .map((item, index) => mediaRunFromStorage(item, index)) + .filter((item): item is MediaGenerationRun => Boolean(item)); + return sortMediaRunsByCreatedAt(runs).slice(-MEDIA_RUNS_STORAGE_LIMIT); + } catch { + return []; + } +} + +function writeStoredMediaRuns(runs: MediaGenerationRun[]) { + if (typeof window === 'undefined') return; + try { + if (!runs.length) { + window.localStorage.removeItem(MEDIA_RUNS_STORAGE_KEY); + return; + } + const storedRuns = sortMediaRunsByCreatedAt(runs).slice(-MEDIA_RUNS_STORAGE_LIMIT); + window.localStorage.setItem(MEDIA_RUNS_STORAGE_KEY, JSON.stringify({ + runs: storedRuns, + version: 1, + })); + } catch { + // Best effort only: private mode or full storage should not break generation. + } +} + +function mediaRunFromStorage(value: unknown, index: number): MediaGenerationRun | undefined { + const record = recordFromUnknown(value); + if (!record) return undefined; + const mode = record.mode === 'image' || record.mode === 'video' ? record.mode : undefined; + const prompt = stringFromUnknown(record.prompt); + if (!mode || !prompt) return undefined; + const task = taskFromStorage(record.task); + const createdAt = dateStringFromUnknown(record.createdAt) ?? new Date().toISOString(); + const localId = stringFromUnknown(record.localId) || task?.id || `stored-${index}-${createdAt}`; + const modelLabel = stringFromUnknown(record.modelLabel) || task?.model || '未知模型'; + let status: MediaGenerationRun['status'] = stringFromUnknown(record.status) || task?.status || 'failed'; + let error = stringFromUnknown(record.error); + if (status === 'submitting' && !task?.id) { + status = 'failed'; + error = error || '任务提交已中断,请重新生成。'; + } + return { + createdAt, + error, + localId, + mode, + modelLabel, + prompt, + settings: mediaSettingsFromStorage(record.settings), + status, + task, + }; +} + +function sortMediaRunsByCreatedAt(runs: MediaGenerationRun[]) { + return [...runs].sort((left, right) => Date.parse(left.createdAt) - Date.parse(right.createdAt)); +} + +function mediaSettingsFromStorage(value: unknown): MediaGenerationSettings { + const fallback = defaultMediaGenerationSettings(); + const record = recordFromUnknown(value); + if (!record) return fallback; + return { + aspectRatio: stringFromUnknown(record.aspectRatio) || fallback.aspectRatio, + countPreset: countPresetFromStorage(record.countPreset, fallback.countPreset), + customCount: numberFromUnknown(record.customCount, fallback.customCount, 1, 20), + height: numberFromUnknown(record.height, fallback.height, 128, 8192), + outputMode: record.outputMode === 'group' ? 'group' : 'single', + resolution: stringFromUnknown(record.resolution) || fallback.resolution, + width: numberFromUnknown(record.width, fallback.width, 128, 8192), + }; +} + +function countPresetFromStorage(value: unknown, fallback: MediaGenerationSettings['countPreset']) { + if (value === 'custom') return value; + const numeric = Number(value); + if (numeric === 1 || numeric === 2 || numeric === 3 || numeric === 4) return numeric; + return fallback; +} + +function taskFromStorage(value: unknown): GatewayTask | undefined { + const record = recordFromUnknown(value); + if (!record || !stringFromUnknown(record.id) || !stringFromUnknown(record.status)) return undefined; + return record as unknown as GatewayTask; +} + +function recordFromUnknown(value: unknown): Record | undefined { + if (!value || typeof value !== 'object' || Array.isArray(value)) return undefined; + return value as Record; +} + +function stringFromUnknown(value: unknown) { + return typeof value === 'string' ? value.trim() : ''; +} + +function dateStringFromUnknown(value: unknown) { + const raw = stringFromUnknown(value); + if (!raw) return undefined; + const time = Date.parse(raw); + return Number.isFinite(time) ? raw : undefined; +} + +function numberFromUnknown(value: unknown, fallback: number, min: number, max: number) { + const number = typeof value === 'number' ? value : Number(value); + if (!Number.isFinite(number)) return fallback; + return Math.min(max, Math.max(min, Math.round(number))); +} + +function delay(ms: number) { + return new Promise((resolve) => window.setTimeout(resolve, ms)); +} + +function newLocalId() { + return typeof crypto !== 'undefined' && 'randomUUID' in crypto + ? crypto.randomUUID() + : `${Date.now()}-${Math.random().toString(36).slice(2)}`; +} diff --git a/apps/web/src/pages/playground-media.tsx b/apps/web/src/pages/playground-media.tsx new file mode 100644 index 0000000..5d2e95b --- /dev/null +++ b/apps/web/src/pages/playground-media.tsx @@ -0,0 +1,818 @@ +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'; +} diff --git a/apps/web/src/styles/playground.css b/apps/web/src/styles/playground.css index e8fd14a..b49f5e4 100644 --- a/apps/web/src/styles/playground.css +++ b/apps/web/src/styles/playground.css @@ -322,6 +322,17 @@ padding: 0; } +.playgroundHero[data-media-board="true"] { + width: 100%; + height: 100%; + min-height: 0; + align-content: stretch; + align-items: stretch; + justify-items: stretch; + overflow: hidden; + padding: 0; +} + .playgroundModeSwitch { display: inline-flex; align-self: center; @@ -868,6 +879,530 @@ color: var(--primary); } +.mediaSettingsTrigger { + display: inline-flex; + min-height: 34px; + max-width: 260px; + align-items: center; + gap: 7px; + padding: 0 10px; + border: 1px solid var(--input); + border-radius: var(--radius-md); + background: var(--surface); + color: var(--text-normal); + font-size: var(--font-size-sm); + white-space: nowrap; + box-shadow: 0 1px 2px rgba(16, 24, 40, 0.04); +} + +.mediaSettingsTrigger span { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; +} + +.mediaSettingsPanel { + display: grid; + width: min(560px, calc(100vw - 24px)); + gap: 12px; + padding: 12px; + border-radius: var(--radius-lg); +} + +.mediaSettingsSection { + display: grid; + gap: 8px; +} + +.mediaSettingsLabel { + color: var(--muted-foreground); + font-size: var(--font-size-xs); + font-weight: var(--font-weight-medium); +} + +.mediaAspectGrid { + display: grid; + grid-template-columns: repeat(auto-fill, 44px); + gap: 4px; + justify-content: start; +} + +.mediaAspectGrid button { + display: inline-flex; + min-width: 0; + min-height: 46px; + align-items: center; + flex-direction: column; + justify-content: center; + gap: 2px; + padding: 4px 2px; + border: 1px solid var(--border); + border-radius: var(--radius-md); + background: var(--surface); + color: var(--text-normal); + font-size: var(--font-size-xs); +} + +.mediaAspectGrid button[data-active="true"] { + border-color: var(--primary); + background: color-mix(in srgb, var(--primary) 8%, var(--surface)); + color: var(--primary); + box-shadow: 0 0 0 1px color-mix(in srgb, var(--primary) 20%, transparent); +} + +.mediaAspectIcon { + position: relative; + display: grid; + width: 13px; + height: 13px; + place-items: center; + color: var(--text-normal); +} + +.mediaAspectIcon::before { + content: ""; + display: block; + border: 1.5px solid currentColor; + border-radius: 3px; +} + +.mediaAspectIcon[data-visual="smart"]::before { + width: 12px; + height: 12px; + border-radius: 4px; +} + +.mediaAspectIcon[data-visual="smart"] svg { + position: absolute; + width: 8px; + height: 8px; +} + +.mediaAspectIcon[data-visual="wide"]::before { + width: 13px; + height: 4px; +} + +.mediaAspectIcon[data-visual="landscape"]::before { + width: 12px; + height: 7px; +} + +.mediaAspectIcon[data-visual="square"]::before { + width: 10px; + height: 10px; +} + +.mediaAspectIcon[data-visual="portrait"]::before { + width: 7px; + height: 12px; +} + +.mediaAspectIcon[data-visual="tall"]::before { + width: 5px; + height: 13px; +} + +.mediaAspectGrid strong { + overflow: hidden; + max-width: 100%; + font-size: var(--font-size-xs); + font-weight: var(--font-weight-medium); + line-height: 1; + text-overflow: ellipsis; + white-space: nowrap; +} + +.mediaResolutionSegment, +.mediaOutputModeSegment { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(118px, 1fr)); + gap: 6px; +} + +.mediaResolutionSegment button, +.mediaOutputModeSegment button { + display: inline-flex; + min-height: 34px; + align-items: center; + justify-content: center; + gap: 6px; + border: 1px solid var(--border); + border-radius: var(--radius-md); + background: var(--surface); + color: var(--text-normal); + font-size: var(--font-size-sm); + white-space: nowrap; +} + +.mediaResolutionSegment button[data-active="true"], +.mediaOutputModeSegment button[data-active="true"] { + border-color: var(--primary); + background: color-mix(in srgb, var(--primary) 8%, var(--surface)); + color: var(--primary); + box-shadow: 0 0 0 1px color-mix(in srgb, var(--primary) 20%, transparent); +} + +.mediaResolutionSegment button:disabled, +.mediaOutputModeSegment button:disabled, +.mediaCountGrid button:disabled { + cursor: not-allowed; + opacity: 0.56; +} + +.mediaResolutionSegment svg, +.mediaSettingsHint svg { + color: #06a6bd; +} + +.mediaSizeRow { + display: grid; + grid-template-columns: minmax(0, 1fr) 24px minmax(0, 1fr) auto; + align-items: center; + gap: 8px; +} + +.mediaSizeRow label { + display: grid; + grid-template-columns: 24px minmax(0, 1fr); + align-items: center; + gap: 6px; + min-height: 36px; + padding: 0 8px; + border: 1px solid var(--border); + border-radius: var(--radius-md); + background: var(--surface-muted); +} + +.mediaSizeRow label span, +.mediaSizeRow strong { + color: #607080; + font-size: var(--font-size-sm); + font-weight: var(--font-weight-semibold); +} + +.mediaSizeRow .shInput { + border: 0; + background: transparent; + box-shadow: none; + color: var(--text-strong); + font-size: var(--font-size-sm); + text-align: right; +} + +.mediaSizeRow > svg { + justify-self: center; + color: #607080; +} + +.mediaCountGrid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(64px, 1fr)); + gap: 6px; +} + +.mediaCountGrid button { + min-height: 32px; + border: 1px solid var(--border); + border-radius: var(--radius-md); + background: var(--surface); + color: var(--text-normal); + font-size: var(--font-size-sm); +} + +.mediaCountGrid button[data-active="true"] { + border-color: var(--primary); + background: var(--primary); + color: var(--primary-foreground); +} + +.mediaCustomCount { + display: grid; + grid-template-columns: auto 110px; + align-items: center; + justify-content: start; + gap: 10px; + color: var(--text-soft); + font-size: var(--font-size-sm); +} + +.mediaUnsupportedNote { + margin: 0; + padding: 8px 10px; + border: 1px solid var(--border); + border-radius: var(--radius-md); + background: var(--surface-muted); + color: var(--text-soft); + font-size: var(--font-size-sm); +} + +.mediaSettingsHint { + display: inline-flex; + width: fit-content; + align-items: center; + gap: 6px; + color: var(--text-soft); + font-size: var(--font-size-sm); +} + +.mediaTaskPage { + display: grid; + grid-template-rows: minmax(0, 1fr) auto; + width: 100%; + height: 100%; + min-height: 0; + overflow: hidden; +} + +.mediaTaskTimeline { + display: grid; + align-content: start; + gap: 22px; + justify-items: center; + min-height: 0; + overflow-y: auto; + padding: 38px 40px 22px; +} + +.mediaTaskTimeline h1 { + width: min(1240px, 100%); + font-size: 30px; +} + +.mediaTaskTimeline > .playgroundError { + width: min(1240px, 100%); +} + +.mediaTaskItem { + display: grid; + gap: 14px; + width: min(1240px, 100%); +} + +.mediaTaskHeader { + display: flex; + align-items: flex-end; + justify-content: space-between; + gap: 14px; +} + +.mediaTaskHeader > div { + display: grid; + gap: 4px; + min-width: 0; +} + +.mediaTaskHeader p { + display: flex; + min-width: 0; + align-items: baseline; + gap: 9px; + flex-wrap: wrap; +} + +.mediaTaskHeader p span { + color: var(--text-strong); + font-size: var(--font-size-lg); + font-weight: var(--font-weight-medium); +} + +.mediaTaskHeader small, +.mediaTaskHeader time { + color: #7a8794; + font-size: var(--font-size-sm); +} + +.mediaTaskStatus { + flex: 0 0 auto; + min-height: 28px; + padding: 5px 9px; + border-radius: 999px; + background: var(--surface-muted); + color: var(--text-soft); + font-size: var(--font-size-xs); +} + +.mediaTaskStatus[data-status="succeeded"] { + background: #eefdf4; + color: #15803d; +} + +.mediaTaskStatus[data-status="failed"], +.mediaTaskStatus[data-status="cancelled"] { + background: #fef2f2; + color: #b91c1c; +} + +.mediaPreviewStage { + display: grid; + position: relative; + width: 100%; + justify-items: center; +} + +.mediaPreviewStage[data-count="1"] { + min-height: min(62vh, 600px); + max-height: 600px; + align-items: center; + overflow: hidden; + border: 1px solid rgba(15, 23, 42, 0.05); + border-radius: 18px; + background: + linear-gradient(135deg, rgba(248, 250, 252, 0.96), rgba(234, 238, 243, 0.74)), + repeating-linear-gradient(90deg, rgba(15, 23, 42, 0.035) 0 1px, transparent 1px 28px), + repeating-linear-gradient(0deg, rgba(15, 23, 42, 0.03) 0 1px, transparent 1px 28px); + box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.86); +} + +.mediaPreviewStage[data-count="1"]::after { + position: absolute; + inset: 0; + background: linear-gradient(180deg, rgba(255, 255, 255, 0.72), rgba(255, 255, 255, 0.18) 44%, rgba(15, 23, 42, 0.035)); + content: ""; + pointer-events: none; +} + +.mediaPreviewBackdrop { + position: absolute; + inset: -28px; + width: calc(100% + 56px); + height: calc(100% + 56px); + opacity: 0.2; + filter: blur(34px) saturate(1.08); + object-fit: cover; + pointer-events: none; + transform: scale(1.04); +} + +.mediaGrid { + --media-grid-columns: 1; + display: grid; + position: relative; + z-index: 1; + grid-template-columns: repeat(var(--media-grid-columns), minmax(0, 1fr)); + gap: 2px; + width: 100%; + max-width: var(--media-grid-max-width, 100%); + max-height: 600px; + justify-self: center; + overflow-y: auto; + overscroll-behavior: contain; +} + +.mediaTile { + position: relative; + display: grid; + min-width: 0; + min-height: 210px; + max-height: 600px; + aspect-ratio: var(--media-result-aspect, 1 / 1); + place-items: center; + overflow: hidden; + border-radius: 2px; + background: #eef0f2; +} + +.mediaTile[data-count="1"] { + min-height: min(62vh, 600px); + box-shadow: 0 18px 44px rgba(15, 23, 42, 0.14); +} + +.mediaTile[data-count="2"] { + min-height: min(48vh, 520px); +} + +.mediaTile img, +.mediaTile video { + width: 100%; + height: 100%; + object-fit: cover; +} + +.mediaLoading, +.mediaEmptyTile { + position: absolute; + inset: 0; + display: grid; + place-items: center; + gap: 10px; + overflow: hidden; + background: linear-gradient(110deg, #edf0f2 0%, #f7f8f9 42%, #e6eaee 78%); + background-size: 220% 100%; + color: #6b7280; + font-size: var(--font-size-sm); + animation: mediaLoadingShimmer 1.35s ease-in-out infinite; +} + +.mediaLoading svg { + animation: mediaLoadingSpin 0.9s linear infinite; +} + +.mediaEmptyTile { + animation: none; + background: #eef0f2; +} + +.mediaTaskError { + width: fit-content; + max-width: 100%; + padding: 8px 10px; + border: 1px solid #fecaca; + border-radius: var(--radius-md); + background: #fef2f2; + color: #991b1b; + font-size: var(--font-size-sm); +} + +.mediaTaskActions { + display: flex; + align-items: center; + gap: 8px; + flex-wrap: wrap; +} + +.mediaTaskActions > span { + display: inline-flex; + align-items: center; + gap: 5px; + margin-left: auto; + color: var(--text-soft); + font-size: var(--font-size-sm); +} + +.mediaTaskActions > span svg { + width: 14px; + height: 14px; + color: #607080; +} + +.mediaComposerDock { + padding: 16px 40px 28px; + background: linear-gradient(180deg, rgba(250, 250, 250, 0), var(--surface-subtle) 26%); +} + +.mediaComposerDock .playgroundComposer { + width: min(1240px, 100%); + min-height: 190px; + margin: 0 auto; + border-radius: 26px; +} + +@keyframes mediaLoadingSpin { + to { + transform: rotate(360deg); + } +} + +@keyframes mediaLoadingShimmer { + 0% { + background-position: 140% 0; + } + + 100% { + background-position: -80% 0; + } +} + @media (max-width: 1180px) { .publicWorksMasonry { column-count: 3; @@ -954,6 +1489,61 @@ flex-direction: column; } + .mediaSettingsTrigger, + .composerFooter .playgroundModelSelect, + .composerFooter .playgroundApiKeySelect, + .playgroundModelSelect, + .playgroundApiKeySelect { + width: 100%; + max-width: 100%; + } + + .mediaSettingsPanel { + max-height: calc(100vh - 32px); + overflow-y: auto; + padding: 12px; + border-radius: var(--radius-lg); + } + + .mediaAspectGrid { + grid-template-columns: repeat(3, minmax(0, 1fr)); + } + + .mediaResolutionSegment button { + min-height: 34px; + font-size: var(--font-size-sm); + } + + .mediaSizeRow { + grid-template-columns: 1fr; + } + + .mediaSizeRow > svg, + .mediaSizeRow > strong { + display: none; + } + + .mediaTaskTimeline { + padding: 30px 18px 18px; + } + + .mediaGrid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .mediaGrid[data-count="1"] { + grid-template-columns: 1fr; + } + + .mediaTile[data-count="1"], + .mediaTile[data-count="2"] { + min-height: auto; + } + + .mediaComposerDock { + padding: 12px 18px 18px; + } + .composerQuickPrompts { flex-wrap: wrap; } @@ -964,6 +1554,25 @@ } @media (max-width: 560px) { + .mediaAspectGrid, + .mediaCountGrid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .mediaGrid { + grid-template-columns: 1fr; + } + + .mediaTaskHeader { + align-items: flex-start; + flex-direction: column; + } + + .mediaTaskActions > span { + width: 100%; + margin-left: 0; + } + .publicWorksMasonry { column-count: 1; }