- 新增音频引用传递给 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>
558 lines
19 KiB
TypeScript
558 lines
19 KiB
TypeScript
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();
|
||
},
|
||
}));
|