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; } /** Check if a URL is an asset library reference (case-insensitive protocol). */ function isAssetUrl(url: string): boolean { return url.startsWith('asset://') || url.startsWith('Asset://'); } /** Build ReferenceSnapshot[] from raw reference_urls (including asset refs with thumb_url). */ function buildReferenceSnapshots( refs: Array>, taskId: string, ): ReferenceSnapshot[] { return refs .filter((ref) => { const url = ref.url || ''; // 素材库引用必须有 thumb_url 才能显示缩略图 if (isAssetUrl(url)) return !!(ref.thumb_url); return url.trim() !== ''; }) .map((ref, i) => { const url = ref.url || ''; const assetRef = isAssetUrl(url); return { id: `ref_${taskId}_${i}`, type: (ref.type || 'image') as 'image' | 'video' | 'audio', // 素材库引用用 thumb_url,直接上传用原始 url previewUrl: assetRef ? ref.thumb_url : url, label: ref.label || `素材${i + 1}`, role: ref.role, isAssetRef: assetRef || undefined, }; }); } /** Extract asset mention metadata from raw reference_urls. */ function buildAssetMentions(refs: Array>) { return refs .filter((ref) => isAssetUrl(ref.url || '')) .map((ref) => { const url = ref.url || ''; // New format: asset://local-{id} if (url.startsWith('asset://local-')) { const assetId = url.replace('asset://local-', ''); return { assetId, label: ref.label || '', thumbUrl: ref.thumb_url || '', assetType: ref.type || 'image', duration: parseFloat(ref.duration || '0'), }; } // Legacy format: asset://group-{id} const groupId = url.startsWith('asset://group-') ? url.replace('asset://group-', '') : ''; return { groupId, label: ref.label || '', thumbUrl: ref.thumb_url || '', assetType: 'image', duration: 0 }; }); } // Convert a BackendTask to a frontend GenerationTask function backendToFrontend(bt: BackendTask): GenerationTask { const allRefs = bt.reference_urls || []; const references = buildReferenceSnapshots(allRefs, bt.task_id); const assetMentions = buildAssetMentions(allRefs); 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, assetMentions, status: mapStatus(bt.status), progress: bt.status === 'processing' ? Number(sessionStorage.getItem(`progress_${bt.task_id}`) || mapProgress(bt.status)) : mapProgress(bt.status), resultUrl: bt.result_url || undefined, thumbnailUrl: bt.thumbnail_url || undefined, errorMessage: mapErrorMessage(bt.error_message), createdAt: new Date(bt.created_at).getTime(), tokensConsumed: bt.tokens_consumed || 0, costAmount: bt.cost_amount || 0, isFavorited: bt.is_favorited || false, seed: bt.seed ?? -1, }; } // 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; const newProgress = Math.min(t.progress + increment, 95); if (t.taskId) sessionStorage.setItem(`progress_${t.taskId}`, String(newProgress)); return { ...t, progress: newProgress }; }), })); }, 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); // Parse references from polling response const pollAllRefs = data.reference_urls || []; const pollRefs = buildReferenceSnapshots(pollAllRefs, taskId); const pollAssetMentions = buildAssetMentions(pollAllRefs); 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, tokensConsumed: data.tokens_consumed ?? t.tokensConsumed, costAmount: data.cost_amount ?? t.costAmount, seed: data.seed ?? t.seed, references: pollRefs.length > 0 ? pollRefs : t.references, assetMentions: pollAssetMentions.length > 0 ? pollAssetMentions : t.assetMentions, } : t ), })); if (newStatus === 'completed' || newStatus === 'failed') { pollTimers.delete(frontendId); sessionStorage.removeItem(`progress_${taskId}`); 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; toggleFavorite: (id: string) => Promise; 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[]; // Extract asset mentions for placeholder display const placeholderAssetMentions: Record[] = []; if (input.editorHtml) { const parser = new DOMParser(); const doc = parser.parseFromString(input.editorHtml, 'text/html'); const spans = doc.querySelectorAll('[data-ref-type="asset"]'); const seen = new Set(); spans.forEach((span) => { const el = span as HTMLElement; const gid = el.dataset.assetGroupId; if (gid && !seen.has(gid)) { seen.add(gid); placeholderAssetMentions.push({ groupId: gid, label: el.dataset.groupName || el.textContent?.replace('@', '') || '', thumbUrl: el.dataset.thumbUrl || '', }); } }); } // Fallback: from inputBar store (regenerate 场景 editorHtml 是纯文本) if (placeholderAssetMentions.length === 0 && input.assetMentions?.length) { placeholderAssetMentions.push(...input.assetMentions); } // 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, assetMentions: placeholderAssetMentions, status: 'generating', progress: 0, createdAt: Date.now(), isFavorited: false, seed: input.seed ?? -1, }; set((s) => ({ tasks: [...s.tasks, placeholderTask] })); // Start smooth progress animation ensureSmoothProgress(); // Clear input useInputBarStore.setState({ prompt: '', editorHtml: '', references: [], assetMentions: [], firstFrame: null, lastFrame: null, }); try { // Use pre-uploaded TOS URLs (immediate upload), fallback to upload here if needed const uploadedRefs: { url: string; type: string; role: string; label: string; thumb_url?: string; duration?: string }[] = []; for (const item of filesToUpload) { if (item.tosUrl && !item.tosUrl.startsWith('blob:')) { uploadedRefs.push({ url: item.tosUrl, type: item.type, role: item.role, label: item.label }); } else if (item.file) { // Fallback: file wasn't pre-uploaded (shouldn't normally happen with immediate upload) const { data: uploadResult } = await mediaApi.upload(item.file); uploadedRefs.push({ url: uploadResult.url, type: item.type, role: item.role, label: item.label }); } } // Extract asset mentions from editor HTML — deduplicate by assetId const seenAssetIds = new Set(); if (input.editorHtml) { const parser = new DOMParser(); const doc = parser.parseFromString(input.editorHtml, 'text/html'); const assetSpans = doc.querySelectorAll('[data-ref-type="asset"]'); assetSpans.forEach((span) => { const el = span as HTMLElement; const assetId = el.dataset.assetId; const assetType = (el.dataset.assetType || 'Image').toLowerCase(); const assetName = el.dataset.assetName || el.textContent?.replace('@', '') || ''; const duration = el.dataset.duration || '0'; if (assetId && !seenAssetIds.has(assetId)) { seenAssetIds.add(assetId); uploadedRefs.push({ url: `asset://local-${assetId}`, type: assetType, role: `reference_${assetType}`, label: assetName, thumb_url: el.dataset.thumbUrl || '', duration, }); } // Legacy: data-asset-group-id (old format) if (!assetId && el.dataset.assetGroupId) { const groupId = el.dataset.assetGroupId; const groupName = el.dataset.groupName || el.textContent?.replace('@', '') || ''; if (!seenAssetIds.has(`group-${groupId}`)) { seenAssetIds.add(`group-${groupId}`); uploadedRefs.push({ url: `asset://group-${groupId}`, type: 'image', role: 'reference_image', label: groupName, thumb_url: el.dataset.thumbUrl || '', }); } } }); } // Fallback: only use inputBar assetMentions when editorHtml has NO asset spans // (regenerate scenario where editorHtml is plain text) const htmlHadAssetSpans = input.editorHtml?.includes('data-ref-type="asset"'); if (!htmlHadAssetSpans) { const inputAssetMentions = input.assetMentions || []; for (const am of inputAssetMentions) { // New format if (am.assetId && !seenAssetIds.has(am.assetId)) { seenAssetIds.add(am.assetId); const t = (am.assetType || 'Image').toLowerCase(); uploadedRefs.push({ url: `asset://local-${am.assetId}`, type: t, role: `reference_${t}`, label: am.label, thumb_url: am.thumbUrl || '', duration: String(am.duration || 0), }); } // Legacy format if (!am.assetId && am.groupId && !seenAssetIds.has(`group-${am.groupId}`)) { seenAssetIds.add(`group-${am.groupId}`); uploadedRefs.push({ url: `asset://group-${am.groupId}`, type: 'image', role: 'reference_image', label: am.label, thumb_url: am.thumbUrl || '', }); } } } // 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, search_mode: input.searchMode || 'off', seed: input.seed ?? -1, }); // 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; error_message?: string } } }; const msg = error.response?.data?.error_message || error.response?.data?.message || '生成失败,请重试'; const displayMsg = mapErrorMessage(msg) || msg; showToast(displayMsg); // Mark task as failed with error message set((s) => ({ tasks: s.tasks.map((t) => t.id === tempId ? { ...t, status: 'failed' as const, progress: 0, errorMessage: displayMsg } : 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(() => {}); } }, toggleFavorite: async (id) => { const task = get().tasks.find((t) => t.id === id); if (!task?.taskId) return; // Optimistic update set((s) => ({ tasks: s.tasks.map((t) => t.id === id ? { ...t, isFavorited: !t.isFavorited } : t), })); try { await videoApi.toggleFavorite(task.taskId); } catch { // Revert on failure set((s) => ({ tasks: s.tasks.map((t) => t.id === id ? { ...t, isFavorited: !t.isFavorited } : t), })); } }, 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') { // task.references only contains file refs (assets filtered in backendToFrontend) const references: UploadedFile[] = task.references.map((r) => ({ id: r.id, type: r.type, previewUrl: r.previewUrl, label: r.label, tosUrl: r.previewUrl, })); // Always use plain text prompt for reEdit — let PromptInput's rebuildMentionSpans // reconstruct tags from references + assetMentions (avoids dead blob: URLs) const taskSeed = task.seed ?? -1; const currentSeedEnabled = useInputBarStore.getState().seedEnabled; useInputBarStore.setState({ prompt: task.prompt, editorHtml: task.prompt, aspectRatio: task.aspectRatio, duration: task.duration, references, assetMentions: task.assetMentions || [], // 如果 seed 开关打开且 task 有有效 seed,填入;否则不动 ...(currentSeedEnabled && taskSeed > 0 ? { seed: taskSeed } : {}), }); } 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, assetMentions: [], 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, })); useInputBarStore.setState({ prompt: task.prompt, editorHtml: task.editorHtml || task.prompt, model: task.model, aspectRatio: task.aspectRatio, duration: task.duration, references: task.mode === 'universal' ? references : [], assetMentions: task.assetMentions || [], }); // Trigger generation get().addTask(); }, saveScrollPosition: (top: number) => { set({ savedScrollTop: top }); }, }));