video-shuoshan/web/src/store/generation.ts
seaislee1209 34e56ddf86 feat: v0.16.0 即时上传 + 音频视频前端校验 + 资产页修复 + Toast UI
- 即时上传:拖入文件后立刻上传 TOS,spinner/红色重试/禁用提交
- 音频校验:格式(MP3/WAV) + 时长[2,15.4]s + 总时长≤15.4s
- 视频校验:格式(MP4/MOV) + 时长[2,15.4]s + 总时长≤15.4s
- 后端 blob: URL 兜底拦截 + 音频错误文案优化
- 资产页:nginx 403 修复 + 倒序排列 + 加载更多按钮
- Toast:glass-card 毛玻璃风格 + 橙色感叹号图标

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 11:12:06 +08:00

650 lines
23 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;
}
/** 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, excluding asset refs. */
function buildReferenceSnapshots(
refs: Array<Record<string, string>>,
taskId: string,
): ReferenceSnapshot[] {
return refs
.filter((ref) => {
const url = ref.url || '';
return !isAssetUrl(url) && url.trim() !== '';
})
.map((ref, i) => ({
id: `ref_${taskId}_${i}`,
type: (ref.type || 'image') as 'image' | 'video' | 'audio',
previewUrl: ref.url || '',
label: ref.label || `素材${i + 1}`,
role: ref.role,
}));
}
/** Extract asset mention metadata from raw reference_urls. */
function buildAssetMentions(refs: Array<Record<string, string>>) {
return refs
.filter((ref) => isAssetUrl(ref.url || ''))
.map((ref) => {
const url = ref.url || '';
const groupId = url.startsWith('asset://group-') ? url.replace('asset://group-', '') : '';
return { groupId, label: ref.label || '', thumbUrl: ref.thumb_url || '' };
});
}
// 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,
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<string, ReturnType<typeof setTimeout>>();
// Smooth progress animation — continuously ticks generating tasks forward
let smoothProgressTimer: ReturnType<typeof setInterval> | 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<string | null>;
removeTask: (id: string) => void;
toggleFavorite: (id: string) => Promise<void>;
reEdit: (id: string) => void;
regenerate: (id: string) => void;
loadTasks: () => Promise<void>;
loadMore: () => Promise<void>;
saveScrollPosition: (top: number) => void;
}
export const useGenerationStore = create<GenerationState>((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: { groupId: string; label: string; thumbUrl: string }[] = [];
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<string>();
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 }[] = [];
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 groupId
const seenGroupIds = new Set<string>();
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 groupId = el.dataset.assetGroupId;
const groupName = el.dataset.groupName || el.textContent?.replace('@', '') || '';
if (groupId && !seenGroupIds.has(groupId)) {
seenGroupIds.add(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)
// If user edited the HTML and removed some asset tags, respect that — don't re-add from store
const htmlHadAssetSpans = input.editorHtml?.includes('data-ref-type="asset"');
if (!htmlHadAssetSpans) {
const inputAssetMentions = input.assetMentions || [];
for (const am of inputAssetMentions) {
if (am.groupId && !seenGroupIds.has(am.groupId)) {
seenGroupIds.add(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 });
},
}));