video-shuoshan/web/src/store/generation.ts
seaislee1209 39667ff19c feat: v0.19.0 1080P 分辨率支持 — 完整前后端 + 严格计费准确性 + 47 测试
火山 Seedance 2.0 于 2026-04-16 上线 1080P 支持。本次实现前端 UI、
后端校验/计费、数据库迁移,并严格遵守三原则:
1. 禁止兜底/静默降级 — Fast+1080P 组合在 UI/store/serializer/view/计价
   五层防御,任一层穿透都 fail loud,不悄悄按 720P 扣费
2. 钱的计算绝对准确 — 前端预估公式与后端 estimate_tokens 完全一致
   `(输入时长+输出时长) × 宽 × 高 × fps / 1024`;实际扣费按火山返回
   total_tokens × 官方单价;预估端不维护最低 token 修正表
3. 不隐藏 bug — 无 `or '720p'` / `|| '720p'` 兜底;类型严格;异常暴露

## 后端(7 处 + 1 次迁移)

- models.py: QuotaConfig 加 base_token_price_1080p(51)/base_token_price_1080p_video(31);
  GenerationRecord.resolution 加 RESOLUTION_CHOICES 约束 + default='720p'
- migrations/0020: 含 RunPython data migration 回填历史 resolution='' → '720p'
- utils/billing.py:
  * RESOLUTION_MAP 加 1080P 六种宽高比(21:9 是 2206×946,不是 seedance 1.0 值)
  * get_resolution 去掉 tier 默认值,非法组合 raise KeyError 不静默降级
  * estimate_tokens 纯官方公式,加 input_video_duration 参数(公式完整)
- utils/airdrama_client.py: create_task 加 resolution 必填参数(无默认值)
- apps/generation/serializers.py:
  * VideoGenerateSerializer 加 resolution ChoiceField
  * aspect_ratio 改 ChoiceField 显式拒绝 adaptive
  * SystemSettingsSerializer 加 2 个 1080P 单价
- apps/generation/views.py:
  * _get_token_price 加 resolution 必填参数,Fast+1080P raise ValueError
  * _sum_video_duration 累加视频参考时长
  * video_generate_view 读 resolution、400 拒绝 Fast+1080P 组合、
    传给 get_resolution/estimate_tokens/_get_token_price/create_task/
    GenerationRecord.resolution(移除 L450 硬编码 '720p')
  * _settle_payment 按 record.resolution 取单价(1080P 结算按 1080P 价)
  * _serialize_task + 5 处手工序列化加 resolution 字段(无 `or '720p'`)
- apps/accounts/views.py: team 接口返回 token_price_1080p/_video

## 前端(10 处)

- types/index.ts: Resolution 类型;GenerationTask/BackendTask/Team/
  QuotaConfig/AssetVideo 加字段(全部必填,无 optional)
- store/inputBar.ts: resolution state;setModel/setResolution 双向拦截
  Fast+1080P 组合,toast 提示引导,不静默降级
- store/generation.ts: addTask/backendToFrontend/reEdit/regenerate 全链路
  携带 resolution;mapErrorMessage 改 '今日生成次数或团队余额不足'
- components/Toolbar.tsx:
  * 加分辨率选择器 Dropdown(位置:比例和时长之间)
  * modelItems/resolutionItems 双向 disabled(Fast 下 1080P 灰 / 1080P 下 Fast 灰)
  * estimatedTokens 对齐后端公式(含输入视频时长 + assetMentions 视频时长)
  * estimatedCost 按 resolution 选单价(Fast→fast_*、1080p→1080p_*、其他→基础)
  * tooltip 明示"实际费用以火山 API 返回的 token 数为准"
- components/Dropdown.tsx: 加 disabled 属性支持
- components/VideoDetailModal.tsx: 重新编辑恢复 resolution
- components/GenerationCard.tsx: 动态显示 task.resolution.toUpperCase()
- pages/SettingsPage.tsx: 加 2 个 1080P 单价输入框(独立分组)
- pages/AdminAssetsPage.tsx / TeamAssetsPage.tsx: 去 || '720p' 兜底
- lib/api.ts: videoApi.generate 参数 resolution 必填

## 测试(47 个用例)

### 后端(28 个)
- tests/test_1080p_billing.py(23): RESOLUTION_MAP 像素、estimate_tokens
  公式(含/不含输入视频、不做最低 token 修正)、_get_token_price 六种
  组合、Fast+1080P 抛异常、calculate_cost 对齐官方示例 4.97 / 12.39 元
- tests/test_1080p_api.py(5): video_generate_view 拒绝 Fast+1080P (400)
  + 拒绝 adaptive + 拒绝非法 resolution + 默认值兼容 + 合法组合通过

### 前端(19 个)
- test/unit/resolution1080p.test.ts(14): store 状态、双向拦截
  (1080P 下切 Fast 被阻止 model 不变、反向同样)、官方像素契约测试、
  价格示例对齐(720P 4.97 / 1080P 12.39)
- test/e2e/resolution-1080p.spec.ts(5): 真实浏览器验证默认 720P、
  Dropdown 双向置灰、tooltip 明示以火山为准

## 与官方文档对齐

- 参数:resolution (480p/720p/1080p 小写)、ratio、duration、generate_audio
- 像素:来自 docs/API文档/创建视频生成任务API.md Seedance 2.0 & 2.0 fast 列
- 单价:来自 docs/API文档/seedance模型价格.md (46/28/51/31/37/22)
- Fast 不支持 1080P:来自 docs/API文档/Seedance 2.0 1080P.md

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-17 19:06:45 +08:00

709 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'],
resolution: bt.resolution,
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,
resolution: input.resolution,
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,
resolution: input.resolution,
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') {
// Only include direct file refs — asset library refs are tracked via assetMentions
const references: UploadedFile[] = task.references
.filter((r) => !r.isAssetRef)
.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,
resolution: task.resolution,
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,
resolution: task.resolution,
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
// Only include direct file refs — asset library refs go via assetMentions fallback
const references: UploadedFile[] = task.references
.filter((r) => !r.isAssetRef)
.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,
resolution: task.resolution,
references: task.mode === 'universal' ? references : [],
assetMentions: task.assetMentions || [],
});
// Trigger generation
get().addTask();
},
saveScrollPosition: (top: number) => {
set({ savedScrollTop: top });
},
}));