video-shuoshan/web/src/store/generation.ts
seaislee1209 ae0e2d4365
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 4m56s
fix: 素材库引用缩略图烂图 + 火山跨项目素材同步脚本
- MentionTag/createMentionSpan/VideoDetailModal: img onError fallback,缩略图加载失败显示占位图标
- buildReferenceSnapshots: 素材库引用用 thumb_url 做 previewUrl,不再过滤
- isAssetRef 标记防止视频缩略图被 <video> 标签渲染、重新编辑时防重复
- sync_volcano_assets management command: 从火山 default 项目同步素材到本地 DB

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 20:31:25 +08:00

699 lines
25 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 (including asset refs with thumb_url). */
function buildReferenceSnapshots(
refs: Array<Record<string, string>>,
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<Record<string, string>>) {
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<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: Record<string, unknown>[] = [];
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; 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<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 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 });
},
}));