All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 4m56s
- 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>
699 lines
25 KiB
TypeScript
699 lines
25 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;
|
||
}
|
||
|
||
/** 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 });
|
||
},
|
||
}));
|