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>(); // Smooth progress animation — continuously ticks generating tasks forward let smoothProgressTimer: ReturnType | null = null; function ensureSmoothProgress() { if (smoothProgressTimer) return; smoothProgressTimer = setInterval(() => { const state = useGenerationStore.getState(); const generating = state.tasks.filter((t) => t.status === 'generating'); if (generating.length === 0) { clearInterval(smoothProgressTimer!); smoothProgressTimer = null; return; } useGenerationStore.setState((s) => ({ tasks: s.tasks.map((t) => { if (t.status !== 'generating') return t; // Decelerate: fast at start, slow near end const increment = t.progress < 30 ? 2 : t.progress < 60 ? 1 : 0.5; return { ...t, progress: Math.min(t.progress + increment, 95) }; }), })); }, 2000); } // 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 : t.progress, 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); } } const PAGE_SIZE = 20; interface GenerationState { tasks: GenerationTask[]; isLoading: boolean; isLoadingMore: boolean; hasMore: boolean; savedScrollTop: number | null; addTask: () => Promise; removeTask: (id: string) => void; reEdit: (id: string) => void; regenerate: (id: string) => void; loadTasks: () => Promise; loadMore: () => Promise; saveScrollPosition: (top: number) => void; } export const useGenerationStore = create((set, get) => ({ tasks: [], isLoading: false, isLoadingMore: false, hasMore: false, savedScrollTop: null, loadTasks: async () => { set({ isLoading: true }); let tasks: GenerationTask[] = []; let hasMore = false; try { const { data } = await videoApi.getTasks({ page_size: PAGE_SIZE, offset: 0 }); tasks = data.results.map(backendToFrontend).reverse(); hasMore = data.has_more; } catch { // API unavailable — tasks stays empty } set({ tasks, hasMore, isLoading: false }); // Start polling and smooth progress for any active tasks let hasGenerating = false; for (const task of tasks) { if (task.status === 'generating' && task.taskId) { startPolling(task.taskId, task.id); hasGenerating = true; } } if (hasGenerating) ensureSmoothProgress(); }, loadMore: async () => { const { tasks, isLoadingMore, hasMore } = get(); if (isLoadingMore || !hasMore) return; set({ isLoadingMore: true }); try { // Backend returns newest-first; we display oldest-first (reversed). // tasks[0] is the oldest currently loaded. We need older ones = higher offset. // offset = total loaded from backend perspective const currentBackendCount = tasks.filter((t) => !t.id.startsWith('temp_')).length; const { data } = await videoApi.getTasks({ page_size: PAGE_SIZE, offset: currentBackendCount }); const olderTasks = data.results.map(backendToFrontend).reverse(); set((s) => ({ tasks: [...olderTasks, ...s.tasks], hasMore: data.has_more, isLoadingMore: false, })); } catch { set({ isLoadingMore: false }); } }, 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: 0, createdAt: Date.now(), }; set((s) => ({ tasks: [...s.tasks, placeholderTask] })); // Start smooth progress animation ensureSmoothProgress(); // 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) { 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 }); } } // 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 : taskStatus === 'failed' ? 0 : t.progress, errorMessage: mapErrorMessage(genResult.error_message) || t.errorMessage, } : 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 } } }; const msg = error.response?.data?.message; showToast(msg || '生成失败,请重试'); // 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(); }, saveScrollPosition: (top: number) => { set({ savedScrollTop: top }); }, }));