video-shuoshan/web/src/store/generation.ts
seaislee1209 32f0ee58f4 feat: v0.8.0 — Seedance API 全流程修复 + TOS 视频持久化
- 新增音频引用传递给 Seedance API
- 视频生成完成后自动持久化到 TOS(永久 CDN URL)
- 移除 ARK_API_KEY 硬编码默认值
- 前端渐进式轮询(10s/30s/60s)替代固定 3 分钟
- TOS 桶切换到 airdrama-media (cn-beijing)
- K8s Secret 注入 TOS 密钥,CI/CD 同步更新

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-15 21:26:27 +08:00

558 lines
19 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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<string, ReturnType<typeof setTimeout>>();
// 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<string | null>;
removeTask: (id: string) => void;
reEdit: (id: string) => void;
regenerate: (id: string) => void;
loadTasks: () => Promise<void>;
}
export const useGenerationStore = create<GenerationState>((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();
},
}));