import { create } from 'zustand'; import type { GenerationTask, ReferenceSnapshot, UploadedFile, BackendTask } from '../types'; import { useInputBarStore } from './inputBar'; import { videoApi, mediaApi } from '../lib/api'; import { useAuthStore } from './auth'; import { showToast } from '../components/Toast'; // Map raw API error messages to user-friendly Chinese function mapErrorMessage(raw?: string): string | undefined { if (!raw) return undefined; const s = raw.toLowerCase(); // HTTP 4xx if (s.includes('400') || s.includes('bad request') || s.includes('invalid')) return '请求参数有误,请检查输入内容'; if (s.includes('401') || s.includes('403') || s.includes('unauthorized') || s.includes('forbidden')) return '服务认证失败,请联系管理员'; if (s.includes('429') || s.includes('too many') || s.includes('rate limit')) return '请求过于频繁,请稍后再试'; // HTTP 5xx / server errors if (s.includes('500') || s.includes('502') || s.includes('503') || s.includes('internal server') || s.includes('bad gateway') || s.includes('service unavailable')) return '服务器繁忙,请稍后重试'; // Timeout if (s.includes('timeout') || s.includes('timed out')) return '请求超时,请重试'; // Connection errors if (s.includes('connection') || s.includes('network') || s.includes('econnrefused')) return '网络连接失败,请检查网络后重试'; // Model / generation errors if (s.includes('quota') || s.includes('insufficient')) return '额度不足,请联系管理员'; // If already Chinese, return as-is if (/[\u4e00-\u9fa5]/.test(raw)) return raw; // Fallback return '生成失败,请重试'; } // Map backend status to frontend TaskStatus function mapStatus(backendStatus: string): 'generating' | 'completed' | 'failed' { if (backendStatus === 'completed' || backendStatus === 'succeeded') return 'completed'; if (backendStatus === 'failed') return 'failed'; return 'generating'; } function mapProgress(backendStatus: string): number { if (backendStatus === 'completed') return 100; if (backendStatus === 'failed') return 0; return 50; } // Convert a BackendTask to a frontend GenerationTask function backendToFrontend(bt: BackendTask): GenerationTask { const references: ReferenceSnapshot[] = (bt.reference_urls || []).map((ref, i) => ({ id: `ref_${bt.task_id}_${i}`, type: (ref.type || 'image') as 'image' | 'video', previewUrl: ref.url, label: ref.label || `素材${i + 1}`, role: ref.role, })); return { id: `backend_${bt.task_id}`, taskId: bt.task_id, prompt: bt.prompt, editorHtml: bt.prompt, mode: bt.mode, model: bt.model, aspectRatio: bt.aspect_ratio as GenerationTask['aspectRatio'], duration: bt.duration as GenerationTask['duration'], references, status: mapStatus(bt.status), progress: mapProgress(bt.status), resultUrl: bt.result_url || undefined, errorMessage: mapErrorMessage(bt.error_message), createdAt: new Date(bt.created_at).getTime(), }; } // Active polling timers const pollTimers = new Map>(); // Progressive polling: 10s for first 2min, 30s for 2-5min, 60s after 5min function getPollingInterval(startTime: number): number { const elapsed = Date.now() - startTime; if (elapsed < 2 * 60 * 1000) return 10 * 1000; // first 2 min: every 10s if (elapsed < 5 * 60 * 1000) return 30 * 1000; // 2-5 min: every 30s return 60 * 1000; // 5+ min: every 60s } function startPolling(taskId: string, frontendId: string) { if (pollTimers.has(frontendId)) return; const startTime = Date.now(); function schedulePoll() { const timer = setTimeout(async () => { try { const { data } = await videoApi.getTaskStatus(taskId); const newStatus = mapStatus(data.status); useGenerationStore.setState((s) => ({ tasks: s.tasks.map((t) => t.id === frontendId ? { ...t, status: newStatus, progress: newStatus === 'completed' ? 100 : newStatus === 'failed' ? 0 : Math.min(t.progress + 5, 90), resultUrl: data.result_url || t.resultUrl, errorMessage: mapErrorMessage(data.error_message) || t.errorMessage, } : t ), })); if (newStatus === 'completed' || newStatus === 'failed') { pollTimers.delete(frontendId); if (newStatus === 'completed') { useAuthStore.getState().fetchUserInfo(); } } else { schedulePoll(); // schedule next poll with progressive interval } } catch { schedulePoll(); // retry on error } }, getPollingInterval(startTime)); pollTimers.set(frontendId, timer); } schedulePoll(); } function stopPolling(frontendId: string) { const timer = pollTimers.get(frontendId); if (timer) { clearTimeout(timer); pollTimers.delete(frontendId); } } interface GenerationState { tasks: GenerationTask[]; isLoading: boolean; addTask: () => Promise; removeTask: (id: string) => void; reEdit: (id: string) => void; regenerate: (id: string) => void; loadTasks: () => Promise; } export const useGenerationStore = create((set, get) => ({ tasks: [], isLoading: false, loadTasks: async () => { set({ isLoading: true }); let tasks: GenerationTask[] = []; try { const { data } = await videoApi.getTasks(); tasks = data.results.map(backendToFrontend).reverse(); } catch { // API unavailable — tasks stays empty, mocks will fill in below } // Dev-only mock tasks for previewing all card states if (import.meta.env.DEV) { tasks.push( // ① 已完成 — 16:9 城市航拍 { id: 'mock_completed_169', taskId: 'demo-169', prompt: '航拍镜头从城市上空缓缓下降,金色夕阳照亮整个天际线,镜头缓慢推进穿过云层', editorHtml: '航拍镜头从城市上空缓缓下降,金色夕阳照亮整个天际线,镜头缓慢推进穿过云层', mode: 'universal' as const, model: 'seedance_2.0' as const, aspectRatio: '16:9' as const, duration: 10 as const, references: [], status: 'completed' as const, progress: 100, resultUrl: '/demo/demo-16-9.mp4', createdAt: Date.now() - 3600000, }, // ② 已完成 — 21:9 爆炸场景 { id: 'mock_completed_219', taskId: 'demo-219', prompt: '0-3s:手持近景镜头 + 轻微晃动 + 缓慢推近,爆炸后的烟尘缓缓落下,环境沉闷压抑', editorHtml: '0-3s:手持近景镜头 + 轻微晃动 + 缓慢推近,爆炸后的烟尘缓缓落下', mode: 'universal' as const, model: 'seedance_2.0' as const, aspectRatio: '21:9' as const, duration: 15 as const, references: [], status: 'completed' as const, progress: 100, resultUrl: '/demo/demo-21-9.mp4', createdAt: Date.now() - 7200000, }, // ③ 已完成 — 9:16 人物出场 { id: 'mock_completed_916', taskId: 'demo-916', prompt: '出场人物:张磊、队员1-8;紧张的救援场面,烟雾弥漫中队员们有序前进', editorHtml: '出场人物:张磊、队员1-8;紧张的救援场面,烟雾弥漫中队员们有序前进', mode: 'universal' as const, model: 'seedance_2.0_fast' as const, aspectRatio: '9:16' as const, duration: 4 as const, references: [], status: 'completed' as const, progress: 100, resultUrl: '/demo/demo-9-16.mp4', createdAt: Date.now() - 6000000, }, // ④ 生成中 — 刚开始 (5%) { id: 'mock_generating_low', taskId: 'demo-gen-low', prompt: '微距镜头拍摄雨滴落在花瓣上的慢动作,水珠在花瓣表面缓缓滑落', editorHtml: '微距镜头拍摄雨滴落在花瓣上的慢动作,水珠在花瓣表面缓缓滑落', mode: 'universal' as const, model: 'seedance_2.0' as const, aspectRatio: '16:9' as const, duration: 10 as const, references: [], status: 'generating' as const, progress: 5, createdAt: Date.now() - 60000, }, // ⑤ 生成中 — 进行中 (60%) { id: 'mock_generating_mid', taskId: 'demo-gen-mid', prompt: '水墨风格的山水画卷缓缓展开,远处群山叠嶂,近处溪流潺潺', editorHtml: '水墨风格的山水画卷缓缓展开,远处群山叠嶂,近处溪流潺潺', mode: 'universal' as const, model: 'seedance_2.0_fast' as const, aspectRatio: '1:1' as const, duration: 5 as const, references: [], status: 'generating' as const, progress: 60, createdAt: Date.now() - 120000, }, // ⑥ 失败 — 参数错误 { id: 'mock_failed_param', taskId: 'demo-fail-param', prompt: '深海探索镜头,潜水艇灯光照亮周围的珊瑚礁和鱼群', editorHtml: '深海探索镜头,潜水艇灯光照亮周围的珊瑚礁和鱼群', mode: 'universal' as const, model: 'seedance_2.0' as const, aspectRatio: '16:9' as const, duration: 10 as const, references: [], status: 'failed' as const, progress: 0, errorMessage: '请求参数有误,请检查输入内容', createdAt: Date.now() - 5000000, }, // ⑦ 失败 — 服务器错误 { id: 'mock_failed_server', taskId: 'demo-fail-server', prompt: '星空延时摄影,银河缓缓转动,前景是雪山湖泊的倒影', editorHtml: '星空延时摄影,银河缓缓转动,前景是雪山湖泊的倒影', mode: 'universal' as const, model: 'seedance_2.0' as const, aspectRatio: '4:3' as const, duration: 8 as const, references: [], status: 'failed' as const, progress: 0, errorMessage: '服务器繁忙,请稍后重试', createdAt: Date.now() - 4000000, }, ); } set({ tasks, isLoading: false }); // Start polling for any active tasks for (const task of tasks) { if (task.status === 'generating' && task.taskId) { startPolling(task.taskId, task.id); } } }, addTask: async () => { const input = useInputBarStore.getState(); if (!input.canSubmit()) return null; // Collect files to upload (or existing TOS URLs for regeneration) const filesToUpload: { file?: File; tosUrl?: string; type: 'image' | 'video' | 'audio'; role: string; label: string }[] = []; const getRoleForType = (type: 'image' | 'video' | 'audio') => type === 'video' ? 'reference_video' : type === 'audio' ? 'reference_audio' : 'reference_image'; if (input.mode === 'universal') { for (const ref of input.references) { if (ref.tosUrl) { filesToUpload.push({ tosUrl: ref.tosUrl, type: ref.type, role: getRoleForType(ref.type), label: ref.label }); } else if (ref.file) { filesToUpload.push({ file: ref.file, type: ref.type, role: getRoleForType(ref.type), label: ref.label }); } } } else { if (input.firstFrame?.tosUrl) { filesToUpload.push({ tosUrl: input.firstFrame.tosUrl, type: 'image', role: 'first_frame', label: '首帧' }); } else if (input.firstFrame?.file) { filesToUpload.push({ file: input.firstFrame.file, type: 'image', role: 'first_frame', label: '首帧' }); } if (input.lastFrame?.tosUrl) { filesToUpload.push({ tosUrl: input.lastFrame.tosUrl, type: 'image', role: 'last_frame', label: '尾帧' }); } else if (input.lastFrame?.file) { filesToUpload.push({ file: input.lastFrame.file, type: 'image', role: 'last_frame', label: '尾帧' }); } } // Snapshot references for local display before uploading const localRefs: ReferenceSnapshot[] = input.mode === 'universal' ? input.references.map((r) => ({ id: r.id, type: r.type, previewUrl: r.previewUrl, label: r.label, })) : [ input.firstFrame && { id: input.firstFrame.id, type: input.firstFrame.type, previewUrl: input.firstFrame.previewUrl, label: '首帧', }, input.lastFrame && { id: input.lastFrame.id, type: input.lastFrame.type, previewUrl: input.lastFrame.previewUrl, label: '尾帧', }, ].filter(Boolean) as ReferenceSnapshot[]; // Create a placeholder task immediately for UI feedback const tempId = `temp_${Date.now()}`; const placeholderTask: GenerationTask = { id: tempId, taskId: '', prompt: input.prompt, editorHtml: input.editorHtml, mode: input.mode, model: input.model, aspectRatio: input.aspectRatio, duration: input.duration, references: localRefs, status: 'generating', progress: 5, createdAt: Date.now(), }; set((s) => ({ tasks: [...s.tasks, placeholderTask] })); // Clear input useInputBarStore.setState({ prompt: '', editorHtml: '', references: [], firstFrame: null, lastFrame: null, }); try { // Upload files to TOS (or reuse existing TOS URLs) const uploadedRefs: { url: string; type: string; role: string; label: string }[] = []; for (const item of filesToUpload) { set((s) => ({ tasks: s.tasks.map((t) => t.id === tempId ? { ...t, progress: Math.min(t.progress + 10, 40) } : t ), })); if (item.tosUrl) { uploadedRefs.push({ url: item.tosUrl, type: item.type, role: item.role, label: item.label }); } else if (item.file) { const { data: uploadResult } = await mediaApi.upload(item.file); uploadedRefs.push({ url: uploadResult.url, type: item.type, role: item.role, label: item.label }); } } // Update progress: files uploaded set((s) => ({ tasks: s.tasks.map((t) => t.id === tempId ? { ...t, progress: 50 } : t ), })); // Call generate API const { data: genResult } = await videoApi.generate({ prompt: input.prompt, mode: input.mode, model: input.model, aspect_ratio: input.aspectRatio, duration: input.duration, references: uploadedRefs, }); // Update task with real backend IDs const frontendId = `backend_${genResult.task_id}`; const taskStatus = mapStatus(genResult.status); set((s) => ({ tasks: s.tasks.map((t) => t.id === tempId ? { ...t, id: frontendId, taskId: genResult.task_id, status: taskStatus as GenerationTask['status'], progress: taskStatus === 'completed' ? 100 : 60, } : t ), })); // Update reference previews with TOS URLs (persist on refresh) const updatedRefs = localRefs.map((ref, i) => ({ ...ref, previewUrl: uploadedRefs[i]?.url || ref.previewUrl, })); set((s) => ({ tasks: s.tasks.map((t) => t.id === frontendId ? { ...t, references: updatedRefs } : t ), })); // Start polling only if task is still generating if (taskStatus === 'generating') { startPolling(genResult.task_id, frontendId); } // Refresh quota useAuthStore.getState().fetchUserInfo(); return frontendId; } catch (err: unknown) { const error = err as { response?: { status?: number; data?: { message?: string } } }; if (error.response?.status === 429) { showToast(error.response.data?.message || '今日额度已用完'); } else { showToast('生成失败,请重试'); } // Mark task as failed set((s) => ({ tasks: s.tasks.map((t) => t.id === tempId ? { ...t, status: 'failed' as const, progress: 0 } : t ), })); return null; } }, removeTask: (id) => { stopPolling(id); const task = get().tasks.find((t) => t.id === id); set((s) => ({ tasks: s.tasks.filter((t) => t.id !== id) })); if (task?.taskId) { videoApi.deleteTask(task.taskId).catch(() => {}); } }, reEdit: (id) => { const task = get().tasks.find((t) => t.id === id); if (!task) return; const inputStore = useInputBarStore.getState(); if (inputStore.mode !== task.mode) { inputStore.switchMode(task.mode); } if (task.mode === 'universal') { const references: UploadedFile[] = task.references.map((r) => ({ id: r.id, type: r.type, previewUrl: r.previewUrl, label: r.label, tosUrl: r.previewUrl, })); useInputBarStore.setState({ prompt: task.prompt, editorHtml: task.editorHtml || task.prompt, aspectRatio: task.aspectRatio, duration: task.duration, references, }); } else { // Keyframe mode: restore firstFrame and lastFrame const firstRef = task.references.find((r) => r.label === '首帧'); const lastRef = task.references.find((r) => r.label === '尾帧'); useInputBarStore.setState({ prompt: task.prompt, editorHtml: task.editorHtml || task.prompt, aspectRatio: task.aspectRatio, duration: task.duration, firstFrame: firstRef ? { id: firstRef.id, type: firstRef.type, previewUrl: firstRef.previewUrl, label: '首帧', tosUrl: firstRef.previewUrl } : null, lastFrame: lastRef ? { id: lastRef.id, type: lastRef.type, previewUrl: lastRef.previewUrl, label: '尾帧', tosUrl: lastRef.previewUrl } : null, }); } }, regenerate: (id) => { const task = get().tasks.find((t) => t.id === id); if (!task) return; // Restore task data into input bar and trigger addTask const inputStore = useInputBarStore.getState(); if (inputStore.mode !== task.mode) { inputStore.switchMode(task.mode); } // For regeneration, we need to re-submit with the same TOS URLs // Set up the input bar state, then call addTask const references: UploadedFile[] = task.references.map((r) => ({ id: r.id, type: r.type, previewUrl: r.previewUrl, label: r.label, tosUrl: r.previewUrl, // TOS URL from previous upload })); useInputBarStore.setState({ prompt: task.prompt, editorHtml: task.editorHtml || task.prompt, model: task.model, aspectRatio: task.aspectRatio, duration: task.duration, references: task.mode === 'universal' ? references : [], }); // Trigger generation get().addTask(); }, }));