From 69dcf9b7455dc1720497c6a1bdffafff4778e84b Mon Sep 17 00:00:00 2001 From: 134 Date: Thu, 17 Sep 2026 13:51:15 +0800 Subject: [PATCH] fix(video-canvas): persist story analysis and resume accepted enhance tasks --- app/api/canvas/generation-tasks/route.ts | 4 +- .../canvas/LiblibVideoCanvasReplica.tsx | 454 +++++------------- docs/video-canvas-task-repairs-2026-09-17.md | 28 ++ lib/canvas/enhance-task-poll.ts | 39 ++ lib/canvas/operations/enhance.ts | 83 +--- lib/video-canvas/image-operation.ts | 52 +- lib/video-canvas/task-contract.ts | 35 +- lib/video-canvas/task-recovery.ts | 42 ++ lib/video-canvas/task-store.ts | 80 ++- lib/video-canvas/task-worker.ts | 44 +- lib/video-canvas/video-story.ts | 78 +++ .../workflow-analysis-response.ts | 105 +--- scripts/qa/video-canvas-task-db.cjs | 167 +++++++ tests/routes/video-canvas-tasks.test.ts | 198 ++++++++ 14 files changed, 856 insertions(+), 553 deletions(-) create mode 100644 lib/canvas/enhance-task-poll.ts create mode 100644 lib/video-canvas/task-recovery.ts create mode 100644 lib/video-canvas/video-story.ts diff --git a/app/api/canvas/generation-tasks/route.ts b/app/api/canvas/generation-tasks/route.ts index 064461475..e78f299a4 100644 --- a/app/api/canvas/generation-tasks/route.ts +++ b/app/api/canvas/generation-tasks/route.ts @@ -1,3 +1,4 @@ +import { canResumeCanvasTask } from "@/lib/video-canvas/task-recovery"; import { imageOperationSchema } from "@/lib/video-canvas/image-operation"; import { after, NextResponse } from "next/server"; import { auth } from "@/app/(auth)/auth"; @@ -64,7 +65,8 @@ export async function GET(request: Request) { const projectId = new URL(request.url).searchParams.get("projectId") || ""; const userId = await owner(projectId); const tasks = await listCanvasGenerationTasks(userId, projectId); - if (tasks.some((task) => task.status === "pending")) kick(userId, projectId); + if (tasks.some((task) => task.status === "pending" || canResumeCanvasTask(task))) + kick(userId, projectId); return NextResponse.json( { success: true, tasks }, { headers: { "Cache-Control": "no-store" } }, diff --git a/components/canvas/LiblibVideoCanvasReplica.tsx b/components/canvas/LiblibVideoCanvasReplica.tsx index 22489777e..50c4c0b84 100644 --- a/components/canvas/LiblibVideoCanvasReplica.tsx +++ b/components/canvas/LiblibVideoCanvasReplica.tsx @@ -1,4 +1,5 @@ "use client"; +import { normalizeVideoAnalysisRows } from "@/lib/video-canvas/video-story"; import dynamic from "next/dynamic"; import { signOut } from "next-auth/react"; import { PromptTranslateButton } from "@/components/canvas/PromptTranslateButton"; @@ -101,7 +102,6 @@ import { canvasService, imageGenerationService, videoGenerationService, - workflowAnalysisService, } from "@/services/canvas"; import { canvasGenerationCount, canvasImageRequest, canvasImageResultUrls } from "@/lib/video-canvas/generation-contract"; import { anglePresets, angleRequestParameters } from "@/lib/video-canvas/angle-settings"; @@ -694,38 +694,6 @@ type StoryScriptRow = { keyframeUrl: string; }; -type VideoAnalysisResponsePayload = { - analysisRows?: Array<{ - backgroundMusic?: string; - dialogue?: string; - cameraAngle?: string; - cameraMovement?: string; - duration?: string | number; - endTime?: string | number; - focusDepth?: string; - imagePrompt?: string; - keyframeUrl?: string; - lighting?: string; - motionPrompt?: string; - narrative?: string; - sceneDescription?: string; - scene?: string; - shot?: string | number; - shotSize?: string; - startTime?: string | number; - story?: string; - voiceAndSfx?: string; - }>; - framePreviews?: string[]; - highlights?: string[]; - raw?: string; - recommendedNextSteps?: string[]; - shotIdeas?: Array<{ title: string; description: string }>; - summary?: string; -}; - -type VideoAnalysisResponseRow = NonNullable[number]; - type VideoAnalysisState = { status: "idle" | "loading" | "ready" | "error"; summary: string; @@ -2312,118 +2280,6 @@ function normalizeVideoClipRange(value: unknown): VideoClipRange { }; } -function formatVideoStoryNumber(value: unknown, fallback: number) { - const text = String(value ?? "").trim(); - if (text) return text.replace(/\s*秒$/i, "").replace(/\s*s$/i, ""); - const numericValue = Number.isFinite(fallback) ? fallback : 0; - return Number.isInteger(numericValue) - ? String(numericValue) - : String(Number(numericValue.toFixed(1))).replace(/\.0$/, ""); -} - -function formatVideoStoryShot(value: unknown, index: number) { - const text = String(value ?? "").trim(); - return text || String(index + 1); -} - -function cleanVideoStoryText(value: unknown, fallback: string) { - const text = String(value ?? "").trim(); - return text || fallback; -} - -function buildVideoAnalysisRows(node: CloneNode, responseData?: VideoAnalysisResponsePayload): VideoAnalysisRow[] { - const previewUrl = getVideoNodePreviewUrl(node) || getNodeMediaUrl(node) || ""; - const modelRows = Array.isArray(responseData?.analysisRows) ? responseData.analysisRows : []; - const framePreviews = Array.isArray(responseData?.framePreviews) - ? responseData.framePreviews.map((item) => String(item || "").trim()).filter(Boolean) - : []; - const videoDuration = Math.max(1, Math.round(getVideoNodeDurationSeconds(node))); - const defaultEndTime = formatVideoStoryNumber(undefined, videoDuration); - const summary = responseData?.summary?.trim() || ""; - const fallbackPrompt = summary || responseData?.raw?.trim() || `${node.title} 的连续镜头推演`; - const createRow = ( - row: Partial & Record, - index: number, - ): VideoAnalysisRow => { - const sceneDescription = cleanVideoStoryText( - row.sceneDescription || row.description || row.pictureDescription, - index === 0 ? `${node.title} 开场镜头` : `镜头 ${index + 1}` - ); - const narrative = cleanVideoStoryText( - row.narrative || row.story || row.storyContent || row.summary, - sceneDescription - ); - const motionPrompt = cleanVideoStoryText( - row.motionPrompt || row.videoPrompt, - "The camera slowly pushes toward the main subject while the action continues naturally and smoothly." - ); - - return { - id: `${node.id}-analysis-${index}`, - shot: formatVideoStoryShot(row.shot || row.shotNumber || row.index, index), - startTime: formatVideoStoryNumber(row.startTime, index === 0 ? 0 : index * videoDuration), - endTime: formatVideoStoryNumber(row.endTime, (index + 1) * videoDuration), - duration: formatVideoStoryNumber(row.duration, videoDuration), - sceneDescription, - narrative, - shotSize: cleanVideoStoryText(row.shotSize || row.frameSize, "中景至近景"), - cameraAngle: cleanVideoStoryText(row.cameraAngle || row.angle, "平视"), - cameraMovement: cleanVideoStoryText(row.cameraMovement || row.cameraMove, "缓慢推镜头 (Slow Zoom In)"), - focusDepth: cleanVideoStoryText(row.focusDepth || row.depthOfField, "浅景深"), - lighting: cleanVideoStoryText(row.lighting || row.light, "冷调低沉照明,主体光线柔和。"), - backgroundMusic: cleanVideoStoryText(row.backgroundMusic || row.music, "无"), - voiceAndSfx: cleanVideoStoryText(row.voiceAndSfx || row.audio || row.sfx, "无"), - imagePrompt: cleanVideoStoryText(row.imagePrompt || row.sceneDescription, fallbackPrompt), - motionPrompt, - keyframeUrl: - String(row.keyframeUrl || row.previewUrl || row.imageUrl || "").trim() || - framePreviews[index % Math.max(framePreviews.length, 1)] || - previewUrl, - }; - }; - - if (modelRows.length > 0) { - return modelRows.slice(0, 8).map((row, index) => createRow(row, index)); - } - const highlights = responseData?.highlights || []; - const shotIdeas = responseData?.shotIdeas?.slice(0, 3) || []; - const rows = - shotIdeas.length > 0 - ? shotIdeas.map((idea, index) => - createRow( - { - backgroundMusic: highlights[index] || highlights[0] || "氛围音乐待接入", - imagePrompt: idea.description || fallbackPrompt, - motionPrompt: - responseData?.recommendedNextSteps?.[index] || - "The camera moves smoothly around the subject and keeps the action continuous.", - narrative: idea.description || fallbackPrompt, - sceneDescription: idea.title || `镜头 ${index + 1}`, - voiceAndSfx: highlights[index + 1] || "无", - }, - index - ) - ) - : [ - createRow( - { - backgroundMusic: highlights[0] || "低沉的氛围电子乐,带有缓慢的机械节拍感。", - endTime: defaultEndTime, - imagePrompt: fallbackPrompt, - motionPrompt: - "The subject moves subtly while the camera slowly dollies in, keeping the scene cinematic and continuous.", - narrative: summary || "通过主要角色、环境和镜头运动呈现当前视频的叙事情绪。", - sceneDescription: `${node.title} 开场镜头`, - startTime: "0", - voiceAndSfx: highlights[1] || "环境声与轻微动作声。", - }, - 0 - ), - ]; - - return rows; -} - function createVideoStoryAnalysisNode({ existingNode, framePreviews = [], @@ -2439,7 +2295,7 @@ function createVideoStoryAnalysisNode({ rows: VideoAnalysisRow[]; summary: string; }): CloneNode { - const previewUrl = getVideoNodePreviewUrl(sourceNode) || getNodeMediaUrl(sourceNode) || ""; + const previewUrl = getVideoNodePreviewUrl(sourceNode) || ""; return { id: existingNode?.id || makeId("video-story"), kind: "video-story", @@ -3215,60 +3071,9 @@ function StoryScriptWorkflowDialog({ ); } -function getPersistedVideoAnalysisState(node: CloneNode | null | undefined): VideoAnalysisState | null { - if (!node) return null; - const raw = - node?.draftValues?.videoAnalysis && - typeof node.draftValues.videoAnalysis === "object" && - !Array.isArray(node.draftValues.videoAnalysis) - ? (node.draftValues.videoAnalysis as Record) - : null; - if (!raw) return null; - const summary = String(raw.summary || "").trim() || "已保存视频解析结果"; - const responseData: VideoAnalysisResponsePayload = { - analysisRows: Array.isArray(raw.rows) ? (raw.rows as VideoAnalysisResponsePayload["analysisRows"]) : [], - framePreviews: Array.isArray(raw.framePreviews) ? raw.framePreviews.map((item) => String(item || "").trim()) : [], - summary, - }; - const rows = buildVideoAnalysisRows(node, responseData); - if (rows.length === 0) return null; - return { - rows, - status: "ready", - summary, - }; -} - -function normalizeVideoStoryRow(row: unknown, index: number, nodeId: string): VideoAnalysisRow | null { - if (!row || typeof row !== "object") return null; - const record = row as Partial & Record; - const sceneDescription = cleanVideoStoryText(record.sceneDescription || record.description, `镜头 ${index + 1}`); - return { - id: String(record.id || `${nodeId}-video-story-row-${index}`), - shot: formatVideoStoryShot(record.shot, index), - startTime: cleanVideoStoryText(record.startTime, index === 0 ? "0" : ""), - endTime: cleanVideoStoryText(record.endTime, record.duration || ""), - duration: cleanVideoStoryText(record.duration, ""), - sceneDescription, - narrative: cleanVideoStoryText(record.narrative || record.story, sceneDescription), - shotSize: cleanVideoStoryText(record.shotSize, "中景至近景"), - cameraAngle: cleanVideoStoryText(record.cameraAngle, "平视"), - cameraMovement: cleanVideoStoryText(record.cameraMovement, "缓慢推镜头 (Slow Zoom In)"), - focusDepth: cleanVideoStoryText(record.focusDepth, "浅景深"), - lighting: cleanVideoStoryText(record.lighting, "冷调低沉照明,主体光线柔和。"), - backgroundMusic: cleanVideoStoryText(record.backgroundMusic, "无"), - voiceAndSfx: cleanVideoStoryText(record.voiceAndSfx, "无"), - imagePrompt: cleanVideoStoryText(record.imagePrompt, sceneDescription), - motionPrompt: cleanVideoStoryText(record.motionPrompt, "The camera moves smoothly and keeps the motion continuous."), - keyframeUrl: cleanVideoStoryText(record.keyframeUrl || record.previewUrl || record.imageUrl, ""), - }; -} - function getVideoStoryRows(node: CloneNode | null | undefined): VideoAnalysisRow[] { const rawRows = Array.isArray(node?.draftValues?.rows) ? node.draftValues.rows : []; - return rawRows - .map((row, index) => normalizeVideoStoryRow(row, index, node?.id || "video-story")) - .filter((row): row is VideoAnalysisRow => Boolean(row)); + return normalizeVideoAnalysisRows(rawRows, [], node?.id || "video-story"); } function getGridSplitCellLabel(index: number, split: Pick) { @@ -4843,7 +4648,6 @@ export function LiblibVideoCanvasReplica({ const clipRequestRunning = useRef(false); const [clipProcessingNodeId, setClipProcessingNodeId] = useState(null); const [videoClipRanges, setVideoClipRanges] = useState>({}); - const [videoAnalysisStates, setVideoAnalysisStates] = useState>({}); const [videoCropGenerateStates, setVideoCropGenerateStates] = useState>({}); const [videoAudioSplitStates, setVideoAudioSplitStates] = useState>({}); @@ -8527,175 +8331,117 @@ export function LiblibVideoCanvasReplica({ [addNodes, nodes, videoAudioSplitStates] ); - const upsertVideoStoryAnalysisNode = useCallback( - ( - sourceNode: CloneNode, - rows: VideoAnalysisRow[], - summary: string, - options?: { error?: string; framePreviews?: string[] } - ) => { - const existingStoryNode = nodes.find( - (item) => - getDraftString(item, "action") === "VIDEO_STORY_ANALYSIS" && - getDraftString(item, "sourceVideoNodeId") === sourceNode.id - ); - const storyNode = createVideoStoryAnalysisNode({ - error: options?.error, - existingNode: existingStoryNode, - framePreviews: options?.framePreviews, - rows, - sourceNode, - summary, - }); - - setNodes((current) => { - const currentExisting = current.find((item) => item.id === storyNode.id); - if (currentExisting) { - return current.map((item) => (item.id === storyNode.id ? { ...storyNode, x: item.x, y: item.y } : item)); - } - return [...current, storyNode]; - }); - setEdges((current) => - current.some((edge) => edge.source === sourceNode.id && edge.target === storyNode.id) - ? current - : [...current, { id: makeId("edge"), source: sourceNode.id, target: storyNode.id }] - ); - setSelectedNodeId(storyNode.id); - setSelectedEdgeId(null); - setActiveVideoToolbarMode(null); - return storyNode.id; - }, - [nodes] - ); - const requestVideoAnalysis = useCallback( - async (nodeId: string) => { + async (nodeId: string, force = false) => { const node = nodes.find((item) => item.id === nodeId); - if (!node) return; + if (!node) { + toast.error("源视频节点已移除,请重新添加视频"); + return; + } const videoUrl = getNodeReferenceMediaUrl(node); const previewImageUrl = getVideoNodePreviewUrl(node); if (!videoUrl) { toast.error("当前视频暂时没有可解析的素材地址"); return; } - if (videoAnalysisStates[nodeId]?.status === "loading") { + const existing = nodes.find( + (item) => + getDraftString(item, "action") === "VIDEO_STORY_ANALYSIS" && + getDraftString(item, "sourceVideoNodeId") === nodeId, + ); + if ( + existing && + (getDraftString(existing, "generationStatus") === "pending" || + (!force && getDraftString(existing, "status") === "ready")) + ) { + setSelectedNodeId(existing.id); + setSelectedEdgeId(null); return; } - if (videoAnalysisStates[nodeId]?.status === "ready") { - const existingStoryNode = nodes.find( - (item) => - getDraftString(item, "action") === "VIDEO_STORY_ANALYSIS" && - getDraftString(item, "sourceVideoNodeId") === node.id - ); - if (existingStoryNode) { - setSelectedNodeId(existingStoryNode.id); - setSelectedEdgeId(null); - } - return; - } - + const lockKey = `video-analysis:${nodeId}`; + if (activeGenerationSubmissions.current.has(lockKey)) return; const definition = getConfiguredFunction(videoCanvasFunctionConfig, "video-analysis"); + if (!videoCanvasFunctionConfig.enabled || !definition.enabled) { + toast.error("视频解析功能尚未启用"); + return; + } const prompt = composeFunctionPrompt( definition, [ "请解析当前视频,输出结构化视频故事表。", - "每个镜头必须包含:镜号、开始时间、结束时间、时长、画面描述、叙事内容、景别、摄影机角度、摄影机运动、焦距与景深、光线、背景音乐、人声/音效、图像生成提示词、视频运动提示词、关键帧建议。", - "图像生成提示词和视频运动提示词优先使用英文,其他说明使用中文。", + "每个镜头包含镜号、开始时间、结束时间、时长、画面描述、叙事内容、景别、摄影机角度与运动、焦距与景深、光线、背景音乐、人声/音效、图像提示词、视频运动提示词、关键帧建议。无法确定的信息留空,禁止编造。", + "图像与视频运动提示词优先使用英文,其他说明使用中文。", `视频标题:${node.title}`, - ].join("\n") + ].join("\n"), ); - + const storyNode = createVideoStoryAnalysisNode({ + existingNode: existing, + sourceNode: node, + rows: existing ? getVideoStoryRows(existing) : [], + summary: getDraftString(existing, "summary"), + }); + storyNode.draftValues = { + ...storyNode.draftValues, + generated: false, + status: "loading", + error: undefined, + generationStatus: "pending", + generationError: undefined, + }; + const edge = { id: makeId("edge"), source: nodeId, target: storyNode.id }; + activeGenerationSubmissions.current.add(lockKey); + setNodes((current) => + current.some((item) => item.id === storyNode.id) + ? current.map((item) => (item.id === storyNode.id ? storyNode : item)) + : [...current, storyNode], + ); + setEdges((current) => + current.some((item) => item.source === nodeId && item.target === storyNode.id) + ? current + : [...current, edge], + ); + setSelectedNodeId(storyNode.id); + setSelectedEdgeId(null); setActiveVideoToolbarMode(null); - setVideoAnalysisStates((current) => ({ - ...current, - [nodeId]: { - error: undefined, - rows: [], - status: "loading", - summary: "正在解析视频故事...", - }, - })); - try { - const response = await workflowAnalysisService.analyze({ - imageUrls: - previewImageUrl && previewImageUrl !== videoUrl ? [previewImageUrl] : undefined, - mode: "video-analysis", - nodeTitle: node.title, - preferredModelRef: getFunctionModel(videoCanvasFunctionConfig, definition), - prompt, - videoUrls: [videoUrl], - }); - if (!response.success) { - throw new Error(response.error || "视频解析失败"); - } - const rows = buildVideoAnalysisRows(node, response.data); - const summary = - response.data?.summary?.trim() || - response.data?.highlights?.join(" · ") || - "已整理出当前视频的镜头节奏与生成建议。"; - const framePreviews = response.data?.framePreviews || []; - setNodes((current) => - current.map((item) => - item.id === nodeId - ? { - ...item, - draftValues: { - ...(item.draftValues || {}), - videoAnalysis: { - framePreviews, - rows, - summary, - }, - }, - } - : item - ) - ); - upsertVideoStoryAnalysisNode(node, rows, summary, { framePreviews }); - setVideoAnalysisStates((current) => ({ - ...current, - [nodeId]: { - rows, - status: "ready", - summary, + await generationTasks.submit( + storyNode, + "analysis", + { + imageUrls: + previewImageUrl && previewImageUrl !== videoUrl ? [previewImageUrl] : undefined, + mode: "video-analysis", + nodeTitle: node.title, + preferredModelRef: getFunctionModel(videoCanvasFunctionConfig, definition), + prompt, + videoUrls: [videoUrl], }, - })); - toast.success("解析完成,已创建视频故事节点"); + [edge], + ); } catch (error) { - const fallbackRows = buildVideoAnalysisRows(node); - const errorMessage = error instanceof Error ? error.message : "视频解析失败"; - const summary = "解析失败,先展示本地占位结构,方便继续编排。"; + const message = error instanceof Error ? error.message : "视频解析提交失败"; setNodes((current) => current.map((item) => - item.id === nodeId + item.id === storyNode.id ? { ...item, draftValues: { - ...(item.draftValues || {}), - videoAnalysis: { - rows: fallbackRows, - summary, - }, + ...item.draftValues, + status: "error", + error: message, + generationStatus: "failed", + generationError: message, }, } - : item - ) + : item, + ), ); - upsertVideoStoryAnalysisNode(node, fallbackRows, summary, { error: errorMessage }); - setVideoAnalysisStates((current) => ({ - ...current, - [nodeId]: { - error: errorMessage, - rows: fallbackRows, - status: "error", - summary, - }, - })); - toast.error(errorMessage); + toast.error(message); + } finally { + activeGenerationSubmissions.current.delete(lockKey); } }, - [nodes, upsertVideoStoryAnalysisNode, videoAnalysisStates, videoCanvasFunctionConfig] + [nodes, videoCanvasFunctionConfig, generationTasks.submit], ); const runPresetComposerSubmit = useCallback( @@ -8958,6 +8704,11 @@ export function LiblibVideoCanvasReplica({ setNodeContextMenu(null); setConnectionMenu(null); + if (getDraftString(node, "action") === "VIDEO_STORY_ANALYSIS") { + await requestVideoAnalysis(getDraftString(node, "sourceVideoNodeId"), true); + return; + } + const incomingNodes = edges .filter((edge) => edge.target === nodeId) .map((edge) => nodes.find((item) => item.id === edge.source)) @@ -9187,7 +8938,14 @@ export function LiblibVideoCanvasReplica({ if (!delegatesVideo) activeGenerationSubmissions.current.delete(nodeId); } }, - [edges, nodes, submitVideoTargetComposer, videoCanvasFunctionConfig, generationTasks.submit], + [ + edges, + nodes, + submitVideoTargetComposer, + videoCanvasFunctionConfig, + generationTasks.submit, + requestVideoAnalysis, + ], ); const deleteNode = useCallback(async (nodeId: string, options?: { silent?: boolean }) => { @@ -10465,7 +10223,7 @@ export function LiblibVideoCanvasReplica({ } } : node))} />; })()} activeMode={activeVideoToolbarMode} - analysisLoading={videoAnalysisStates[selectedVideoMediaNode.id]?.status === "loading"} + analysisLoading={nodes.some(item => getDraftString(item, "sourceVideoNodeId") === selectedVideoMediaNode.id && getDraftString(item, "generationStatus") === "pending")} anchor={selectedVideoAnchor} audioSplitLoading={videoAudioSplitStates[selectedVideoMediaNode.id]?.status === "loading"} onAnalyze={() => { @@ -13077,7 +12835,8 @@ function VideoStoryCanvasNode({ node, selected }: { node: CloneNode; selected: b const rows = getVideoStoryRows(node); const previewUrl = getDraftString(node, "keyframeUrl"); const summary = getDraftString(node, "summary"); - const error = getDraftString(node, "error"); + const error = getDraftString(node, "generationError") || getDraftString(node, "error"); + const pending = getDraftString(node, "generationStatus") === "pending"; const headers = [ "镜号", "开始时间", @@ -13101,7 +12860,7 @@ function VideoStoryCanvasNode({ node, selected }: { node: CloneNode; selected: b
@@ -13111,12 +12870,12 @@ function VideoStoryCanvasNode({ node, selected }: { node: CloneNode; selected: b 视频故事
- {rows.length || 1} 镜 + {rows.length} 镜 - {summary || error ? ( + {summary || error || pending ? (
- {error || summary} + {error || (pending ? "正在解析视频故事,可离开页面,返回后会恢复任务状态。" : summary)}
) : null}
@@ -13153,7 +12912,10 @@ function VideoStoryCanvasNode({ node, selected }: { node: CloneNode; selected: b key={row.id} > {cells.map((cell, index) => ( -
+
{cell || "-"}
))} @@ -13174,7 +12936,7 @@ function VideoStoryCanvasNode({ node, selected }: { node: CloneNode; selected: b }) ) : (
- 暂无视频故事 + {pending ? "正在解析…" : "暂无视频故事"}
)}
diff --git a/docs/video-canvas-task-repairs-2026-09-17.md b/docs/video-canvas-task-repairs-2026-09-17.md index 3bdc73477..abd2dcf0d 100644 --- a/docs/video-canvas-task-repairs-2026-09-17.md +++ b/docs/video-canvas-task-repairs-2026-09-17.md @@ -68,3 +68,31 @@ node scripts/qa/video-canvas-task-db.cjs - TypeScript 与 diff 空白检查通过;没有新增付费模型调用,没有部署 48 服务器。 仍需继续的项目:视频解析故事入口尚有同步请求,后台进程中断后的上游续查、多标签同时编辑、生产部署回归,以及原矩阵中的高级功能缺口。本轮不据此宣称 231 项全部完成。 + +## 后续修复:视频故事与上游任务续查(承接 86b66172) + +本节更新前面仍标为未完成的“视频解析故事”和“AOS 高清已有任务编号续查”。这两项已完成分支修复与下述本地验证,其他范围不因此自动视为已修复。 + +### 视频解析故事 + +- 工具栏“解析故事”在提交时创建故事节点,进入实际持久任务队列;刷新、重新打开画布后通过任务状态恢复结果。故事节点重新运行也走视频解析,修正了原先落入通用脚本生成分支的问题。 +- 解析失败或模型返回空镜头时显示错误并保留旧稿,新节点显示 0 镜。移除前后端自动填入的开场剧情、运镜、灯光、音乐等占位内容;缺失字段显示空值。 +- 前后端共用镜头规范化逻辑,保留时间 0,不再截断成最多 8 镜;目前上限 120 镜。双击提交由本地提交锁和服务端同节点任务去重保护。 + +### 高清续查 + +- AOS 受理后先持久化 taskId、操作类型与源图,再查询结果。执行进程中断后,只恢复服务器已保存且与原请求匹配的高清编号;恢复分支只 GET 原任务,不重新 POST。 +- 下一次画布任务查询发现心跳过期后标记 interrupted,再按 30 秒起、最高 5 分钟的间隔接管续查;限任务创建后 24 小时、总领取次数少于 20 次。不依赖后台永久调度器,服务重启后需要画布查询触发恢复。 +- 增加领取次数校验:旧执行者的心跳、结果、检查点与失败不能覆盖已被新执行者接管的任务。恢复窗口内对同一节点重复点击运行会复用原任务。 +- AOS 明确负数失败状态立即终止查询,修正被吞掉后长期显示 processing 的问题;每次网络查询有超时,整个短轮询按实际经过时间计时。 +- 仍未给没有持久化上游编号的其他供应商任务添加自动续查;视频模型及 VIP98K 同步调用在进程中断后的恢复不能据此视为完成。没有任务编号的请求不会自动重投。 + +### 本轮验证 + +- helpers/route **454 项通过,无跳过**,其中任务专项 30 项;包含既有真实 FFmpeg 验证。新增检查编号匹配、恢复时限/间隔、GET-only 恢复、执行权失效、终态失败、AOS 短轮询上限、完整镜头及空结果拒绝。 +- 真实隔离 PostgreSQL:10 个并发调用争抢续查只有 1 个成功;旧领取次数的结果/错误/检查点不能覆盖新领取;重复提交恢复中的节点不会新增任务。 +- **独立进程中断与重启**:实际 worker/store + PostgreSQL,供应商使用本地桩。首次进程在受理、保存编号并开始 GET 后强制退出,第二个独立进程从库恢复并完成。调用记录严格为 `POST → GET → GET`,上游编号一致。这不是生产 Next.js 部署重启测试。 +- **浏览器刷新**:实际故事节点组件、Hook、route、worker、store + PostgreSQL;鉴权/供应商/Next after 使用本地适配。故事任务处理中刷新后恢复为 1 个真实桩镜头;另一个空镜头响应变为失败,刷新后仍显示 0 镜与明确错误。画布快照保存仍为本地验收接口。 +- TypeScript 无错误、diff 空白检查通过。没有新增付费模型调用,没有部署 48 服务器。 + +后续仍需完成:其他供应商执行中断恢复、多标签编辑冲突、生产部署回归,以及原矩阵列出的高级功能缺口。本次未声称参考站 231 项全部完成或像素级一致。 diff --git a/lib/canvas/enhance-task-poll.ts b/lib/canvas/enhance-task-poll.ts new file mode 100644 index 000000000..b545e7a96 --- /dev/null +++ b/lib/canvas/enhance-task-poll.ts @@ -0,0 +1,39 @@ +// Polling is bounded by elapsed time, including network waits. Terminal provider failures +// must escape immediately instead of being converted into a perpetual pending response. +export class EnhanceTerminalError extends Error {} +export async function pollTaskResult( + apiUrl: string, + apiKey: string, + taskId: string, + delay = 1000, + timeout = 120000, + fetcher: typeof fetch = fetch, +): Promise { + const deadline = Date.now() + timeout; + while (Date.now() < deadline) { + try { + const response = await fetcher( + `${apiUrl}/api/tasks/visual/scale/${encodeURIComponent(taskId)}`, + { + method: "GET", + headers: { "X-API-KEY": apiKey }, + signal: AbortSignal.timeout(Math.max(1, Math.min(10000, deadline - Date.now()))), + }, + ); + if (!response.ok) throw Error(`轮询请求失败: ${response.status}`); + const result = await response.json(); + if (!result.data) throw Error("高清查询未返回任务状态"); + if (result.data.state < 0) + throw new EnhanceTerminalError( + `AOS任务失败: ${String(result.data.err_message || result.data.err_info || result.data.state_detail || result.data.state)}`, + ); + if (result.data.progress >= 100) return result; + } catch (error) { + if (error instanceof EnhanceTerminalError) throw error; + } + const remaining = deadline - Date.now(); + if (remaining > 0) + await new Promise((resolve) => setTimeout(resolve, Math.min(delay, remaining))); + } + throw Error("轮询超时"); +} diff --git a/lib/canvas/operations/enhance.ts b/lib/canvas/operations/enhance.ts index 4157ec30e..9ca92e5ba 100644 --- a/lib/canvas/operations/enhance.ts +++ b/lib/canvas/operations/enhance.ts @@ -1,3 +1,4 @@ +import { pollTaskResult, EnhanceTerminalError } from "@/lib/canvas/enhance-task-poll"; import "server-only"; import { NextRequest, NextResponse } from "next/server"; import { @@ -469,76 +470,6 @@ async function processWithVip98kEnhance(params: { }; } -/** - * 轮询获取任务结果(根据官方示例) - */ -async function pollTaskResult( - apiUrl: string, - apiKey: string, - taskId: string, - delay: number = 1000, - timeout: number = 120000 // 增加到120秒(2分钟) -): Promise { - const pollUrl = `${apiUrl}/api/tasks/visual/scale/${taskId}`; - - const getTaskResult = async (): Promise => { - const response = await fetch(pollUrl, { - method: "GET", - headers: { - "X-API-KEY": apiKey, - }, - }); - - if (!response.ok) { - throw new Error(`轮询请求失败: ${response.status} ${response.statusText}`); - } - - const result = await response.json(); - - if (!result.data) { - throw result; - } - - const { progress, state } = result.data; - console.log(`[Enhance] 轮询进度: progress=${progress}%, state=${state}, task_id=${taskId}`); - - // state < 0 表示失败 - if (state < 0) { - const errMessage = String( - result.data?.err_message || result.data?.err_info || result.data?.state_detail || `state=${state}` - ); - throw new Error(`AOS任务失败: ${errMessage}`); - } - - // progress >= 100 表示完成 - if (progress >= 100) { - console.log(`[Enhance] 任务完成,task_id=${taskId}`); - return result; - } - - // 否则继续轮询 - throw null; - }; - - try { - const result = await getTaskResult(); - return result; - } catch (error) { - if (error && typeof error === "object" && "data" in error) { - throw new Error(JSON.stringify(error, null, 2)); - } - if (timeout <= 0) { - throw new Error("轮询超时"); - } - await new Promise((resolve) => { - setTimeout(() => { - resolve(undefined); - }, delay); - }); - return pollTaskResult(apiUrl, apiKey, taskId, delay, timeout - delay); - } -} - function isEnhanceTaskStillProcessing(error: unknown): boolean { if (!(error instanceof Error)) return false; return /轮询超时/i.test(error.message || ""); @@ -787,7 +718,7 @@ export async function executeEnhanceGET(request: Request, trustedUserId: string) } const detail = pollError instanceof Error ? pollError.message : "轮询失败"; return NextResponse.json( - { error: "变清晰处理失败", detail }, + { error: "变清晰处理失败", detail, terminal: pollError instanceof EnhanceTerminalError }, { status: 500 } ); } @@ -816,7 +747,7 @@ export async function executeEnhanceGET(request: Request, trustedUserId: string) * 从数据库读取变清晰API配置,调用外部API进行图片增强处理 * 将处理后的图片上传到S3并保存到数据库 */ -export async function executeEnhancePOST(request: Request, trustedUserId: string) { +export async function executeEnhancePOST(request: Request, trustedUserId: string, onTaskCreated?: (taskId: string) => Promise) { console.log("[Enhance API] ===== 收到请求 ====="); try { // 0. 获取用户会话 @@ -1351,6 +1282,14 @@ export async function executeEnhancePOST(request: Request, trustedUserId: string ); } + if (onTaskCreated) { + // Durable callers save the upstream ID before querying; no retry or fallback POST + // may run after the provider has accepted this task. + try { await onTaskCreated(String(taskId)); } + catch (error) { console.error("[Enhance] 保存已受理任务编号失败", error); } + return buildEnhanceTaskPendingResponse(String(taskId)); + } + // 4. 轮询获取任务结果(短等待,超时后让前端继续轮询) console.log(`[Enhance] 开始短轮询任务结果,task_id: ${taskId}`); let taskResult: any; diff --git a/lib/video-canvas/image-operation.ts b/lib/video-canvas/image-operation.ts index fbdacabed..e2e65ef85 100644 --- a/lib/video-canvas/image-operation.ts +++ b/lib/video-canvas/image-operation.ts @@ -24,7 +24,12 @@ export const imageOperationSchema = z }); export type ImageOperation = z.infer; export class ImageOperationUncertainError extends Error {} -type Handler = (request: Request, userId: string) => Promise; +export class CanvasTaskLeaseLostError extends Error {} +type Handler = ( + request: Request, + userId: string, + onTaskCreated?: (taskId: string) => Promise, +) => Promise; export async function executeImageOperation( params: unknown, userId: string, @@ -39,6 +44,8 @@ export async function executeImageOperation( checkpoint?: (data: Record) => Promise; wait?: (ms: number) => Promise; maxPolls?: number; + resume?: { operation: "enhance"; taskId: string; sourceImageUrl: string } | null; + assertLease?: () => Promise; } = {}, ) { const input = imageOperationSchema.parse(params); @@ -65,29 +72,47 @@ export async function executeImageOperation( "remove-bg": handlers.removeBackground, "angle-edit": handlers.angleEdit, }[input.operation]; - let response = await handler( - new Request("http://canvas.internal/operation", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(payload), - }), - userId, - ); + const checkpoint = async (taskId: string) => { + await options.assertLease?.(); + await options.checkpoint?.({ + upstreamTask: { operation: "enhance", taskId, sourceImageUrl: input.imageUrl }, + }); + }; + await options.assertLease?.(); + if ( + options.resume && + (input.operation !== "enhance" || + options.resume.operation !== "enhance" || + !options.resume.taskId?.trim() || + options.resume.sourceImageUrl !== input.imageUrl) + ) + throw Error("已保存的高清任务与原请求不匹配,已停止恢复"); + let response = options.resume + ? Response.json({ processing: true, taskId: options.resume.taskId }, { status: 202 }) + : await handler( + new Request("http://canvas.internal/operation", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + }), + userId, + checkpoint, + ); let body = await response.json(); if (response.status === 202 || body.processing) { if (input.operation !== "enhance" || !body.taskId) throw new ImageOperationUncertainError("图片任务已受理但缺少可查询的任务标识,请勿重复提交"); const taskId = String(body.taskId); try { - await options.checkpoint?.({ - upstreamTask: { operation: "enhance", taskId, sourceImageUrl: input.imageUrl }, - }); - } catch { + await checkpoint(taskId); + } catch (error) { + if (error instanceof CanvasTaskLeaseLostError) throw error; throw new ImageOperationUncertainError("高清任务已受理,但任务标识保存失败,请勿重复提交"); } const query = new URLSearchParams({ taskId, sourceImageUrl: input.imageUrl }); for (let i = 0; i < (options.maxPolls ?? 30); i++) { await (options.wait || ((ms) => new Promise((resolve) => setTimeout(resolve, ms))))(1200); + await options.assertLease?.(); try { response = await handlers.pollEnhance( new Request(`http://canvas.internal/operation?${query}`), @@ -99,6 +124,7 @@ export async function executeImageOperation( "高清任务查询连接中断,已保留任务标识,请勿重复提交", ); } + if (!response.ok && body.terminal) throw Error(body.detail || body.error || "高清任务失败"); if (!response.ok) throw new ImageOperationUncertainError(body.error || "高清任务暂时无法查询,请勿重复提交"); if (response.status !== 202 && !body.processing) break; diff --git a/lib/video-canvas/task-contract.ts b/lib/video-canvas/task-contract.ts index 4daaa491d..601a537df 100644 --- a/lib/video-canvas/task-contract.ts +++ b/lib/video-canvas/task-contract.ts @@ -1,3 +1,5 @@ +import { hasRecoverableCanvasTask } from "./task-recovery"; +import { normalizeVideoAnalysisRows } from "./video-story"; import { storyTaskAttachmentSchema, mergeStoryTaskResult } from "./story-task-attachment"; import { z } from "zod"; @@ -32,6 +34,8 @@ export type CanvasGenerationTask = { requestId: string; status: string; createdAt: string; + updatedAt?: string; + attempts?: number; request: CanvasTaskRequest; result?: Record | null; error?: string | null; @@ -45,6 +49,8 @@ export function canCancelCanvasTask(status: string) { export function requireAnalysisResult(data: Record | undefined, mode: string) { if (!data) throw Error("分析没有返回结果,原内容已保留"); + if (mode === "video-analysis" && !normalizeVideoAnalysisRows(data.analysisRows).length) + throw Error("视频解析没有返回有效镜头,原内容已保留"); const rows = data.analysisRows || data.shotIdeas; if ( mode === "story-script" && @@ -60,6 +66,7 @@ export function requireAnalysisResult(data: Record | undefined, mod } if ( mode !== "story-script" && + mode !== "video-analysis" && ![data.raw, data.summary, ...(data.agentPlan || [])].some( (value) => typeof value === "string" && value.trim(), ) @@ -165,13 +172,39 @@ export function reconcileCanvasTasks< task.error || (task.status === "cancelled" ? "已取消排队,未提交供应商" : "任务失败,请查看任务状态"), }); + if (task.status === "interrupted" && hasRecoverableCanvasTask(task)) + nextDraft.generationError = "高清任务查询中断,稍后会自动查询原任务,请勿重复生成。"; subtitle = task.status === "cancelled" ? "已取消" : task.status === "interrupted" - ? "待核实" + ? hasRecoverableCanvasTask(task) + ? "等待续查" + : "待核实" : "生成失败"; } + if (task.request.kind === "analysis" && task.request.params.mode === "video-analysis") { + Object.assign(nextDraft, { + status: + task.status === "succeeded" + ? "ready" + : isCanvasTaskActive(task.status) + ? "loading" + : "error", + error: nextDraft.generationError, + }); + if (task.status === "succeeded") { + Object.assign(nextDraft, { + rows: normalizeVideoAnalysisRows( + task.result?.analysisRows, + task.result?.framePreviews || [], + source.id, + ), + framePreviews: task.result?.framePreviews || [], + summary: task.result?.summary || "", + }); + } + } result = result.map((item, i) => i === index ? { diff --git a/lib/video-canvas/task-recovery.ts b/lib/video-canvas/task-recovery.ts new file mode 100644 index 000000000..99b74a8c8 --- /dev/null +++ b/lib/video-canvas/task-recovery.ts @@ -0,0 +1,42 @@ +// Resume only server-recorded enhancement jobs. A lost request without an upstream ID is never resubmitted. +export type RecoveryTask = { + status: string; + createdAt: string; + updatedAt?: string; + attempts?: number; + request: { kind: string; params: Record }; + result?: Record | null; +}; +export function getEnhanceCheckpoint(task: Pick) { + const upstream = task.result?.upstreamTask; + if ( + task.request.kind !== "image-operation" || + task.request.params.operation !== "enhance" || + upstream?.operation !== "enhance" || + typeof upstream.taskId !== "string" || + !upstream.taskId.trim() || + upstream.sourceImageUrl !== task.request.params.imageUrl + ) + return null; + return { + operation: "enhance" as const, + taskId: upstream.taskId, + sourceImageUrl: String(upstream.sourceImageUrl), + }; +} +export function hasRecoverableCanvasTask(task: RecoveryTask, now = Date.now()) { + const created = Date.parse(task.createdAt); + return ( + Boolean(getEnhanceCheckpoint(task)) && + Number.isFinite(created) && + now >= created && + now - created <= 86400000 && + (task.attempts || 0) < 20 + ); +} +export function canResumeCanvasTask(task: RecoveryTask, now = Date.now()) { + if (task.status !== "interrupted" || !hasRecoverableCanvasTask(task, now)) return false; + const updated = Date.parse(task.updatedAt || ""); + const delay = Math.min(300000, 30000 * 2 ** Math.min(4, Math.max(0, (task.attempts || 1) - 1))); + return Number.isFinite(updated) && now - updated >= delay; +} diff --git a/lib/video-canvas/task-store.ts b/lib/video-canvas/task-store.ts index ed13412dc..ad32a18ca 100644 --- a/lib/video-canvas/task-store.ts +++ b/lib/video-canvas/task-store.ts @@ -1,3 +1,4 @@ +import { canResumeCanvasTask, hasRecoverableCanvasTask } from "./task-recovery"; import "server-only"; import { and, asc, desc, eq, lt, sql } from "drizzle-orm"; import { db } from "@/lib/db"; @@ -16,6 +17,8 @@ const serialize = (row: typeof table.$inferSelect): CanvasGenerationTask => ({ requestId: row.requestId, status: row.status, createdAt: row.createdAt.toISOString(), + updatedAt: row.updatedAt.toISOString(), + attempts: row.attempts, request: row.request as CanvasTaskRequest, result: row.result as Record | null, error: row.error, @@ -68,6 +71,17 @@ export async function createCanvasGenerationTask(userId: string, request: Canvas (row) => (row.request as CanvasTaskRequest).node.id === request.node.id, ); if (duplicate) return serialize(duplicate); + const interrupted = await tx + .select() + .from(table) + .where(and(scope(userId, request.projectId), eq(table.status, "interrupted"))); + const recovering = interrupted.find( + (row) => + (row.request as CanvasTaskRequest).node.id === request.node.id && + hasRecoverableCanvasTask(serialize(row)), + ); + if (recovering) return serialize(recovering); + if (active.length >= 40) throw Object.assign(Error("当前画布排队任务已达 40 个,请等待完成后继续"), { status: 429 }); const [created] = await tx @@ -137,36 +151,53 @@ export async function claimCanvasGenerationTask(userId: string, projectId: strin ).length ) return null; - const [next] = await tx + let [next] = await tx .select() .from(table) .where(and(scope(userId, projectId), eq(table.status, "pending"))) .orderBy(asc(table.createdAt)) .limit(1); + if (!next) { + const interrupted = await tx + .select() + .from(table) + .where(and(scope(userId, projectId), eq(table.status, "interrupted"))) + .orderBy(asc(table.updatedAt)) + .limit(240); + next = interrupted.find((row) => canResumeCanvasTask(serialize(row)))!; + } if (!next) return null; const [claimed] = await tx .update(table) .set({ status: "running", + error: null, startedAt: new Date(), updatedAt: new Date(), attempts: sql`${table.attempts}+1`, }) - .where(and(eq(table.id, next.id), eq(table.status, "pending"))) + .where(and(eq(table.id, next.id), eq(table.status, next.status))) .returning(); return claimed ? serialize(claimed) : null; }); } -export async function heartbeatCanvasGenerationTask(id: string) { +export async function heartbeatCanvasGenerationTask(id: string, attempt?: number) { await db .update(table) .set({ updatedAt: new Date() }) - .where(and(eq(table.id, id), eq(table.status, "running"))); + .where( + and( + eq(table.id, id), + eq(table.status, "running"), + attempt === undefined ? undefined : eq(table.attempts, attempt), + ), + ); } export async function finishCanvasGenerationTask( id: string, result?: Record, error?: string, + attempt?: number, ) { // A late real result can recover an interrupted task, but cannot replace a newer request ID. await db @@ -178,7 +209,13 @@ export async function finishCanvasGenerationTask( updatedAt: new Date(), completedAt: new Date(), }) - .where(and(eq(table.id, id), sql`${table.status} in ('running','interrupted')`)); + .where( + and( + eq(table.id, id), + sql`${table.status} in ('running','interrupted')`, + attempt === undefined ? undefined : eq(table.attempts, attempt), + ), + ); } export async function cancelCanvasGenerationTask(userId: string, projectId: string, id: string) { const [cancelled] = await db @@ -228,15 +265,40 @@ export async function hideCanvasGenerationNode( }); } -export async function checkpointCanvasGenerationTask(id: string, result: Record) { +export async function checkpointCanvasGenerationTask( + id: string, + result: Record, + attempt?: number, +) { await db .update(table) .set({ result, updatedAt: new Date() }) - .where(and(eq(table.id, id), eq(table.status, "running"))); + .where( + and( + eq(table.id, id), + eq(table.status, "running"), + attempt === undefined ? undefined : eq(table.attempts, attempt), + ), + ); } -export async function interruptCanvasGenerationTask(id: string, error: string) { +export async function interruptCanvasGenerationTask(id: string, error: string, attempt?: number) { await db .update(table) .set({ status: "interrupted", error, updatedAt: new Date() }) - .where(and(eq(table.id, id), eq(table.status, "running"))); + .where( + and( + eq(table.id, id), + eq(table.status, "running"), + attempt === undefined ? undefined : eq(table.attempts, attempt), + ), + ); +} + +export async function ownsCanvasTaskLease(id: string, attempt: number) { + const [row] = await db + .select({ id: table.id }) + .from(table) + .where(and(eq(table.id, id), eq(table.attempts, attempt), eq(table.status, "running"))) + .limit(1); + return Boolean(row); } diff --git a/lib/video-canvas/task-worker.ts b/lib/video-canvas/task-worker.ts index a131c255e..f837d2ac3 100644 --- a/lib/video-canvas/task-worker.ts +++ b/lib/video-canvas/task-worker.ts @@ -1,15 +1,24 @@ +import { getEnhanceCheckpoint } from "./task-recovery"; import { requireStoryPromptResult } from "./story-task-attachment"; -import { executeImageOperation, ImageOperationUncertainError } from "./image-operation"; +import { + executeImageOperation, + ImageOperationUncertainError, + CanvasTaskLeaseLostError, +} from "./image-operation"; import { executeEnhancePOST, executeEnhanceGET } from "@/lib/canvas/operations/enhance"; import { executeExtendPOST } from "@/lib/canvas/operations/extend"; import { executeRemoveBackgroundPOST } from "@/lib/canvas/operations/rmbg"; import { executeAngleEditPOST } from "@/lib/canvas/operations/angle-edit"; -import { checkpointCanvasGenerationTask, interruptCanvasGenerationTask } from "./task-store"; +import { + checkpointCanvasGenerationTask, + interruptCanvasGenerationTask, + ownsCanvasTaskLease, +} from "./task-store"; import "server-only"; import { executeDirectVideoGeneration } from "@/lib/ai/tools/generate-video"; import { executeCanvasImageResponse } from "@/lib/canvas/generate-image-response"; import { executeWorkflowAnalysisResponse } from "./workflow-analysis-response"; -import { requireAnalysisResult, type CanvasTaskRequest } from "./task-contract"; +import { requireAnalysisResult, type CanvasGenerationTask } from "./task-contract"; import { validateVideoReferences } from "./reference-input"; import { probeRemoteUrl } from "@/lib/net/url-access"; import { @@ -18,7 +27,15 @@ import { heartbeatCanvasGenerationTask, } from "./task-store"; -async function execute(request: CanvasTaskRequest, userId: string, taskId: string) { +async function execute(task: CanvasGenerationTask, userId: string) { + const request = task.request; + const assertLease = async () => { + if (!(await ownsCanvasTaskLease(task.id, task.attempts!))) + throw new CanvasTaskLeaseLostError("任务已由新的执行进程接管"); + }; + await assertLease(); + const resume = getEnhanceCheckpoint(task); + if ((task.attempts || 1) > 1 && !resume) throw Error("缺少上游任务编号,禁止重新提交"); const params = request.params; if (request.kind === "image-operation") return executeImageOperation( @@ -31,7 +48,11 @@ async function execute(request: CanvasTaskRequest, userId: string, taskId: strin angleEdit: executeAngleEditPOST, pollEnhance: executeEnhanceGET, }, - { checkpoint: (result) => checkpointCanvasGenerationTask(taskId, result) }, + { + resume, + assertLease, + checkpoint: (result) => checkpointCanvasGenerationTask(task.id, result, task.attempts), + }, ); if (request.kind === "video") { const checked = await validateVideoReferences(params.images, (url) => @@ -81,20 +102,27 @@ export async function runCanvasGenerationQueue(userId: string, projectId: string const task = await claimCanvasGenerationTask(userId, projectId); if (!task) return; const timer = setInterval( - () => void heartbeatCanvasGenerationTask(task.id).catch(() => {}), + () => void heartbeatCanvasGenerationTask(task.id, task.attempts).catch(() => {}), 20000, ); try { - await finishCanvasGenerationTask(task.id, await execute(task.request, userId, task.id)); + await finishCanvasGenerationTask( + task.id, + await execute(task, userId), + undefined, + task.attempts, + ); } catch (error) { + if (error instanceof CanvasTaskLeaseLostError) return; if (error instanceof ImageOperationUncertainError) { - await interruptCanvasGenerationTask(task.id, error.message); + await interruptCanvasGenerationTask(task.id, error.message, task.attempts); continue; } await finishCanvasGenerationTask( task.id, undefined, error instanceof Error ? error.message : "任务执行失败", + task.attempts, ); } finally { clearInterval(timer); diff --git a/lib/video-canvas/video-story.ts b/lib/video-canvas/video-story.ts new file mode 100644 index 000000000..41b3c59a4 --- /dev/null +++ b/lib/video-canvas/video-story.ts @@ -0,0 +1,78 @@ +const normalizeText = (value: unknown) => + typeof value === "string" || typeof value === "number" ? String(value).trim() : ""; + +export function normalizeVideoAnalysisRows( + value: unknown, + framePreviews: string[] = [], + nodeId = "video-story", +) { + if (!Array.isArray(value)) return []; + return value + .map((item, index) => { + const raw = + item && typeof item === "object" && !Array.isArray(item) + ? (item as Record) + : {}; + const sceneDescription = normalizeText( + raw.sceneDescription || raw.scene || raw.title || raw.description, + ); + const narrative = normalizeText( + raw.narrative || raw.story || raw.storyContent || raw.narration || raw.summary, + ); + const shot = normalizeText(raw.shot || raw.shotNumber || raw.index || index + 1); + const startTime = normalizeText(raw.startTime ?? raw.start ?? raw.start_time); + const endTime = normalizeText(raw.endTime ?? raw.end ?? raw.end_time); + const duration = normalizeText(raw.duration ?? raw.durationSeconds ?? raw.duration_seconds); + const shotSize = normalizeText( + raw.shotSize || raw.frameSize || raw.shot_type || raw.sceneScale, + ); + const cameraAngle = normalizeText(raw.cameraAngle || raw.angle || raw.camera_angle); + const cameraMovement = normalizeText( + raw.cameraMovement || raw.cameraMove || raw.camera_motion || raw.motionType, + ); + const focusDepth = normalizeText( + raw.focusDepth || raw.depthOfField || raw.focalDepth || raw.focus_depth, + ); + const lighting = normalizeText(raw.lighting || raw.light || raw.lightDesign); + const backgroundMusic = normalizeText(raw.backgroundMusic || raw.music || raw.bgm); + const voiceAndSfx = normalizeText(raw.voiceAndSfx || raw.sound || raw.audio || raw.sfx); + const imagePrompt = normalizeText( + raw.imagePrompt || raw.image_generation_prompt || raw.image, + ); + const motionPrompt = normalizeText( + raw.motionPrompt || raw.motion || raw.videoPrompt || raw.video_motion_prompt, + ); + const keyframeIndex = Number( + raw.keyframeIndex || raw.frameIndex || raw.key_frame_index || index + 1, + ); + if (!sceneDescription && !narrative && !imagePrompt) return null; + const normalizedFrameIndex = + Number.isFinite(keyframeIndex) && keyframeIndex > 0 + ? Math.max(0, Math.min(framePreviews.length - 1, Math.round(keyframeIndex) - 1)) + : index % Math.max(framePreviews.length, 1); + return { + id: normalizeText(raw.id) || `${nodeId}-analysis-${index}`, + shot: shot || String(index + 1), + startTime, + endTime, + duration, + sceneDescription, + narrative, + shotSize, + cameraAngle, + cameraMovement, + focusDepth, + lighting, + backgroundMusic, + voiceAndSfx, + imagePrompt, + motionPrompt, + keyframeUrl: + normalizeText(raw.keyframeUrl || raw.previewUrl || raw.imageUrl) || + framePreviews[normalizedFrameIndex] || + "", + }; + }) + .filter((item): item is NonNullable => Boolean(item)) + .slice(0, 120); +} diff --git a/lib/video-canvas/workflow-analysis-response.ts b/lib/video-canvas/workflow-analysis-response.ts index 89760c46e..0153556bb 100644 --- a/lib/video-canvas/workflow-analysis-response.ts +++ b/lib/video-canvas/workflow-analysis-response.ts @@ -1,3 +1,4 @@ +import { normalizeVideoAnalysisRows } from "./video-story"; import "server-only"; import { normalizeStoryScriptRows } from "@/lib/video-canvas/story-script"; import { isCanvasLanguageModel, isCanvasVisionModel } from "@/lib/video-canvas/model-matching"; @@ -98,109 +99,6 @@ function normalizeShotIdeas(value: unknown) { .slice(0, 6); } -function normalizeVideoAnalysisRows(value: unknown, framePreviews: string[]) { - if (!Array.isArray(value)) return []; - return value - .map((item, index) => { - const raw = - item && typeof item === "object" && !Array.isArray(item) - ? (item as Record) - : {}; - const sceneDescription = normalizeText( - raw.sceneDescription || raw.scene || raw.title || raw.description, - ); - const narrative = normalizeText( - raw.narrative || raw.story || raw.storyContent || raw.narration || raw.summary, - ); - const shot = normalizeText(raw.shot || raw.shotNumber || raw.index || index + 1); - const startTime = normalizeText(raw.startTime || raw.start || raw.start_time); - const endTime = normalizeText(raw.endTime || raw.end || raw.end_time); - const duration = normalizeText(raw.duration || raw.durationSeconds || raw.duration_seconds); - const shotSize = normalizeText( - raw.shotSize || raw.frameSize || raw.shot_type || raw.sceneScale, - ); - const cameraAngle = normalizeText(raw.cameraAngle || raw.angle || raw.camera_angle); - const cameraMovement = normalizeText( - raw.cameraMovement || raw.cameraMove || raw.camera_motion || raw.motionType, - ); - const focusDepth = normalizeText( - raw.focusDepth || raw.depthOfField || raw.focalDepth || raw.focus_depth, - ); - const lighting = normalizeText(raw.lighting || raw.light || raw.lightDesign); - const backgroundMusic = normalizeText(raw.backgroundMusic || raw.music || raw.bgm); - const voiceAndSfx = normalizeText(raw.voiceAndSfx || raw.sound || raw.audio || raw.sfx); - const imagePrompt = normalizeText( - raw.imagePrompt || raw.image_generation_prompt || raw.image, - ); - const motionPrompt = normalizeText( - raw.motionPrompt || raw.motion || raw.videoPrompt || raw.video_motion_prompt, - ); - const keyframeIndex = Number( - raw.keyframeIndex || raw.frameIndex || raw.key_frame_index || index + 1, - ); - if ( - !sceneDescription && - !narrative && - !backgroundMusic && - !voiceAndSfx && - !imagePrompt && - !motionPrompt && - !shotSize && - !cameraAngle && - !cameraMovement && - !focusDepth && - !lighting - ) { - return null; - } - const normalizedFrameIndex = - Number.isFinite(keyframeIndex) && keyframeIndex > 0 - ? Math.max(0, Math.min(framePreviews.length - 1, Math.round(keyframeIndex) - 1)) - : index % Math.max(framePreviews.length, 1); - return { - shot: shot || String(index + 1), - startTime: startTime || (index === 0 ? "0" : ""), - endTime, - duration, - sceneDescription: sceneDescription || `镜头 ${index + 1}`, - narrative: narrative || sceneDescription || `镜头 ${index + 1}`, - shotSize: shotSize || "中景至近景", - cameraAngle: cameraAngle || "平视", - cameraMovement: cameraMovement || "缓慢推镜头 (Slow Zoom In)", - focusDepth: focusDepth || "浅景深", - lighting: lighting || "冷调低沉照明,主体光线柔和。", - backgroundMusic: backgroundMusic || "无", - voiceAndSfx: voiceAndSfx || "无", - imagePrompt: imagePrompt || sceneDescription || "延续当前视频气质生成关键帧", - motionPrompt: motionPrompt || "保持镜头运动自然、连贯、符合前后节奏。", - keyframeUrl: framePreviews[normalizedFrameIndex] || "", - }; - }) - .filter( - ( - item, - ): item is { - shot: string; - startTime: string; - endTime: string; - duration: string; - sceneDescription: string; - narrative: string; - shotSize: string; - cameraAngle: string; - cameraMovement: string; - focusDepth: string; - lighting: string; - backgroundMusic: string; - voiceAndSfx: string; - imagePrompt: string; - motionPrompt: string; - keyframeUrl: string; - } => Boolean(item), - ) - .slice(0, 8); -} - function normalizeCharacters(value: unknown) { if (!Array.isArray(value)) return []; return value @@ -700,6 +598,7 @@ export async function executeWorkflowAnalysisResponse(body: unknown, signal?: Ab parsedJson.analysisRows, extractedVideoFrames, ); + if (!analysisRows.length) return NextResponse.json({ success: false, error: "视频解析没有返回有效镜头,请重新解析" }, { status: 502 }); const shotIdeas = normalizeShotIdeas(parsedJson.shotIdeas).length > 0 ? normalizeShotIdeas(parsedJson.shotIdeas) diff --git a/scripts/qa/video-canvas-task-db.cjs b/scripts/qa/video-canvas-task-db.cjs index b91d10553..e9c7d01c0 100644 --- a/scripts/qa/video-canvas-task-db.cjs +++ b/scripts/qa/video-canvas-task-db.cjs @@ -162,6 +162,173 @@ const fs = require("fs"), assert.equal(recovered.status, "interrupted"); assert.equal(recovered.result.upstreamTask.taskId, "provider-123"); assert.equal(await store.claimCanvasGenerationTask(user, project), null); + // Reclaim one checkpoint under concurrency and reject all stale worker writes. + const enhanceRequest = { + ...request, + requestId: crypto.randomUUID(), + kind: "image-operation", + node: { ...request.node, id: "node-recoverable" }, + params: { operation: "enhance", imageUrl: "https://example.com/source.png" }, + }; + const resumable = await store.createCanvasGenerationTask(user, enhanceRequest); + const firstLease = await store.claimCanvasGenerationTask(user, project); + await store.checkpointCanvasGenerationTask( + resumable.id, + { + upstreamTask: { + operation: "enhance", + taskId: "provider-resume", + sourceImageUrl: enhanceRequest.params.imageUrl, + }, + }, + firstLease.attempts, + ); + await store.interruptCanvasGenerationTask(resumable.id, "query lost", firstLease.attempts); + assert.equal( + ( + await store.createCanvasGenerationTask(user, { + ...enhanceRequest, + requestId: crypto.randomUUID(), + }) + ).id, + resumable.id, + ); + assert.equal(await store.claimCanvasGenerationTask(user, project), null); + await client.query( + "UPDATE \"CanvasCapabilityTask\" SET \"updatedAt\"=(now() at time zone 'UTC')-interval '6 minutes' WHERE id=$1", + [resumable.id], + ); + const resumed = ( + await Promise.all( + Array.from({ length: 10 }, () => store.claimCanvasGenerationTask(user, project)), + ) + ).filter(Boolean); + assert.equal(resumed.length, 1); + assert.equal(resumed[0].attempts, 2); + assert.equal(await store.ownsCanvasTaskLease(resumable.id, 1), false); + assert.equal(await store.ownsCanvasTaskLease(resumable.id, 2), true); + await store.finishCanvasGenerationTask( + resumable.id, + { images: [{ originalUrl: "stale" }] }, + undefined, + 1, + ); + await store.interruptCanvasGenerationTask(resumable.id, "stale failure", 1); + await store.checkpointCanvasGenerationTask( + resumable.id, + { upstreamTask: { taskId: "wrong" } }, + 1, + ); + const fenced = (await store.listCanvasGenerationTasks(user, project)).find( + (t) => t.id === resumable.id, + ); + assert.equal(fenced.status, "running"); + assert.equal(fenced.result.upstreamTask.taskId, "provider-resume"); + await store.finishCanvasGenerationTask( + resumable.id, + { images: [{ originalUrl: "https://example.com/recovered.png" }] }, + undefined, + 2, + ); + + // A separate worker process exits after its first GET. Restart must read the persisted ID. + const callsFile = path.resolve(".qa/video-task-db/provider-calls.jsonl"); + fs.writeFileSync(callsFile, ""); + const unexpected = `throw Error("unexpected provider path")`; + const stubs = { + "@/lib/ai/tools/generate-video": `export const executeDirectVideoGeneration=async()=>{${unexpected}};`, + "@/lib/canvas/generate-image-response": `export const executeCanvasImageResponse=async()=>{${unexpected}};`, + "./workflow-analysis-response": `export const executeWorkflowAnalysisResponse=async()=>{${unexpected}};`, + "@/lib/canvas/operations/extend": `export const executeExtendPOST=async()=>{${unexpected}};`, + "@/lib/canvas/operations/rmbg": `export const executeRemoveBackgroundPOST=async()=>{${unexpected}};`, + "@/lib/canvas/operations/angle-edit": `export const executeAngleEditPOST=async()=>{${unexpected}};`, + "@/lib/canvas/operations/enhance": ` + import fs from 'node:fs'; + const log=(method,id)=>fs.appendFileSync(${JSON.stringify(callsFile)},JSON.stringify({method,id})+'\\n'); + export const executeEnhancePOST=async(req,user,onCreated)=>{log('POST','durable-upstream');await onCreated?.('durable-upstream');return Response.json({processing:true,taskId:'durable-upstream'},{status:202});}; + export const executeEnhanceGET=async req=>{const id=new URL(req.url).searchParams.get('taskId');log('GET',id);if(process.env.QA_CRASH==='1')process.exit(17);return Response.json({success:true,original_url:'https://example.com/process-recovered.png'});};`, + "server-only": "", + }; + await esbuild.build({ + entryPoints: ["lib/video-canvas/task-worker.ts"], + outfile: ".qa/video-task-db/worker.cjs", + bundle: true, + platform: "node", + format: "cjs", + packages: "external", + alias: { "@": process.cwd() }, + plugins: [ + { + name: "mock-provider-only", + setup(build) { + build.onResolve({ filter: /.*/ }, (args) => + stubs[args.path] !== undefined ? { path: args.path, namespace: "qa" } : undefined, + ); + build.onLoad({ filter: /.*/, namespace: "qa" }, (args) => ({ + contents: stubs[args.path], + loader: "js", + })); + }, + }, + ], + }); + const restartTask = await store.createCanvasGenerationTask(user, { + ...enhanceRequest, + requestId: crypto.randomUUID(), + node: { ...enhanceRequest.node, id: "node-process-restart" }, + }); + const childCode = `require(${JSON.stringify(path.resolve(".qa/video-task-db/worker.cjs"))}).runCanvasGenerationQueue(${JSON.stringify(user)},${JSON.stringify(project)}).then(()=>process.exit(0),e=>{console.error(e);process.exit(1)});`; + const runChild = (crash) => + new Promise((resolve, reject) => { + require("node:child_process").execFile( + process.execPath, + ["-e", childCode], + { env: { ...process.env, QA_CRASH: crash ? "1" : "0" }, timeout: 30000 }, + (error, stdout, stderr) => { + if (error && !(crash && error.code === 17)) reject(Error(stderr || String(error))); + else resolve(); + }, + ); + }); + await runChild(true); + const crashed = (await store.listCanvasGenerationTasks(user, project)).find( + (t) => t.id === restartTask.id, + ); + assert.equal(crashed.status, "running"); + assert.equal(crashed.result.upstreamTask.taskId, "durable-upstream"); + await client.query( + "UPDATE \"CanvasCapabilityTask\" SET \"updatedAt\"=(now() at time zone 'UTC')-interval '6 minutes' WHERE id=$1", + [restartTask.id], + ); + assert.equal( + (await store.listCanvasGenerationTasks(user, project)).find((t) => t.id === restartTask.id) + .status, + "interrupted", + ); + await client.query( + "UPDATE \"CanvasCapabilityTask\" SET \"updatedAt\"=(now() at time zone 'UTC')-interval '6 minutes' WHERE id=$1", + [restartTask.id], + ); + await runChild(false); + const final = (await store.listCanvasGenerationTasks(user, project)).find( + (t) => t.id === restartTask.id, + ); + assert.equal(final.status, "succeeded"); + assert.equal(final.attempts, 2); + assert.equal(final.result.images[0].originalUrl, "https://example.com/process-recovered.png"); + const providerCalls = fs + .readFileSync(callsFile, "utf8") + .trim() + .split("\n") + .map((line) => JSON.parse(line)); + assert.deepEqual( + providerCalls.map((call) => call.method), + ["POST", "GET", "GET"], + ); + assert(providerCalls.every((call) => call.id === "durable-upstream")); + console.log( + "PASS: real PostgreSQL checkpoint recovery, ten concurrent reclaimers, retry cooldown, stale-worker fencing, duplicate prevention, abrupt worker process restart with POST/GET/GET only.", + ); console.log( "PASS: real PostgreSQL ownership, idempotency (10 submits), exclusive claiming (10 workers), serial queue, cancellation CAS, durable results, hidden nodes, interrupted task without resubmission, late-result recovery.", ); diff --git a/tests/routes/video-canvas-tasks.test.ts b/tests/routes/video-canvas-tasks.test.ts index 839bb863e..3007031d5 100644 --- a/tests/routes/video-canvas-tasks.test.ts +++ b/tests/routes/video-canvas-tasks.test.ts @@ -423,3 +423,201 @@ test("recovered task results populate a script while retaining the independent o expect(result.some((n) => n.id === "n1")).toBe(true); expect(reconcileCanvasTasks(result, [attached], "2026-09-17T01:00:00Z")).toBe(result); }); + +// Recovery only queries an existing, server-recorded task; it never submits another job. +import { canResumeCanvasTask, getEnhanceCheckpoint } from "@/lib/video-canvas/task-recovery"; +import { CanvasTaskLeaseLostError } from "@/lib/video-canvas/image-operation"; +import { normalizeVideoAnalysisRows } from "@/lib/video-canvas/video-story"; +import { pollTaskResult, EnhanceTerminalError } from "@/lib/canvas/enhance-task-poll"; +const recoveryTime = Date.parse("2026-09-17T04:00:00Z"); +const checkpoint = { + operation: "enhance" as const, + taskId: "saved-upstream", + sourceImageUrl: "https://example.com/source.png", +}; +const recoverable = { + status: "interrupted", + createdAt: "2026-09-17T03:00:00Z", + updatedAt: "2026-09-17T03:59:00Z", + attempts: 1, + request: { + kind: "image-operation", + params: { operation: "enhance", imageUrl: checkpoint.sourceImageUrl }, + }, + result: { upstreamTask: checkpoint }, +}; +test("recovery enforces checkpoint identity, age, cooldown and bounded attempts", () => { + expect(canResumeCanvasTask(recoverable, recoveryTime)).toBe(true); + for (const changed of [ + { status: "failed" }, + { result: null }, + { attempts: 20 }, + { createdAt: "2026-09-15T00:00:00Z" }, + { updatedAt: "2026-09-17T03:59:50Z" }, + { attempts: 8 }, + { + request: { + kind: "image-operation", + params: { operation: "enhance", imageUrl: "https://example.com/other.png" }, + }, + }, + ]) + expect(canResumeCanvasTask({ ...recoverable, ...changed }, recoveryTime)).toBe(false); + expect( + getEnhanceCheckpoint({ ...recoverable, request: { kind: "video", params: {} } }), + ).toBeNull(); +}); +test("restarting enhancement from its checkpoint makes zero POST calls", async () => { + let posts = 0, + gets = 0; + const post = async () => { + posts++; + throw Error("must not POST"); + }; + const result = await executeImageOperation( + recoverable.request.params, + "owner", + { + enhance: post, + extend: post, + removeBackground: post, + angleEdit: post, + pollEnhance: async (req) => { + gets++; + expect(new URL(req.url).searchParams.get("taskId")).toBe(checkpoint.taskId); + return Response.json({ success: true, original_url: "https://example.com/recovered.png" }); + }, + }, + { resume: checkpoint, wait: async () => {} }, + ); + expect(result.images[0].originalUrl).toContain("recovered.png"); + expect(posts).toBe(0); + expect(gets).toBe(1); +}); +test("invalid checkpoints and a replaced worker lease cannot reach the provider", async () => { + let calls = 0; + const handler = async () => { + calls++; + throw Error("provider touched"); + }; + const handlers = { + enhance: handler, + extend: handler, + removeBackground: handler, + angleEdit: handler, + pollEnhance: handler, + }; + await expect( + executeImageOperation(recoverable.request.params, "owner", handlers, { + resume: { ...checkpoint, sourceImageUrl: "wrong" }, + }), + ).rejects.toThrow("不匹配"); + await expect( + executeImageOperation(recoverable.request.params, "owner", handlers, { + assertLease: async () => { + throw new CanvasTaskLeaseLostError(); + }, + }), + ).rejects.toBeInstanceOf(CanvasTaskLeaseLostError); + expect(calls).toBe(0); +}); +test("terminal provider failure stops query recovery instead of staying pending", async () => { + const handler = async () => + Response.json({ terminal: true, detail: "AOS任务失败: invalid image" }, { status: 500 }); + try { + await executeImageOperation( + recoverable.request.params, + "owner", + { + enhance: handler, + extend: handler, + removeBackground: handler, + angleEdit: handler, + pollEnhance: handler, + }, + { resume: checkpoint, wait: async () => {} }, + ); + throw Error("expected provider failure"); + } catch (error) { + expect(error).not.toBeInstanceOf(ImageOperationUncertainError); + expect(String(error)).toContain("AOS任务失败"); + } +}); +test("actual AOS polling propagates negative state immediately and bounds elapsed retries", async () => { + let calls = 0; + await expect( + pollTaskResult("https://example.com", "qa", "id", 1, 1000, async () => { + calls++; + return Response.json({ data: { state: -1, err_message: "bad image", progress: 0 } }); + }), + ).rejects.toBeInstanceOf(EnhanceTerminalError); + expect(calls).toBe(1); + const started = Date.now(); + await expect( + pollTaskResult("https://example.com", "qa", "id", 1, 25, async () => + Response.json({ data: { state: 0, progress: 10 } }), + ), + ).rejects.toThrow("轮询超时"); + expect(Date.now() - started).toBeLessThan(500); +}); +test("video analysis preserves zero timestamps and all shots without invented camera or audio facts", () => { + const rows = normalizeVideoAnalysisRows( + Array.from({ length: 12 }, (_, i) => ({ sceneDescription: `镜头${i}`, startTime: 0 })), + [], + "story", + ); + expect(rows).toHaveLength(12); + expect(rows[0]).toMatchObject({ + startTime: "0", + lighting: "", + backgroundMusic: "", + cameraMovement: "", + imagePrompt: "", + }); + expect(normalizeVideoAnalysisRows([{}, { startTime: 0, backgroundMusic: "无" }])).toEqual([]); + expect(() => + requireAnalysisResult({ summary: "没有镜头", analysisRows: [] }, "video-analysis"), + ).toThrow("有效镜头"); +}); +test("video story refresh restores real rows, pending state and errors without a placeholder shot", () => { + const story = { + ...node, + kind: "video-story", + inputType: "text", + draftValues: { action: "VIDEO_STORY_ANALYSIS", rows: [] }, + }; + const analysis = { + ...task, + request: { + ...task.request, + node: story as any, + kind: "analysis" as const, + params: { mode: "video-analysis" }, + }, + result: { + summary: "视频内容", + analysisRows: [{ sceneDescription: "杯子", startTime: 0 }], + framePreviews: ["https://example.com/frame.png"], + }, + }; + const pending = reconcileCanvasTasks( + [story], + [{ ...analysis, status: "running" }], + "2026-09-17T01:00:00Z", + ); + expect(pending[0].draftValues).toMatchObject({ status: "loading", rows: [] }); + const failed = reconcileCanvasTasks( + pending, + [{ ...analysis, status: "failed", error: "解析失败" }], + "2026-09-17T01:00:00Z", + ); + expect(failed[0].draftValues).toMatchObject({ status: "error", error: "解析失败", rows: [] }); + const restored = reconcileCanvasTasks([], [analysis], "2026-09-17T01:00:00Z")[0]; + expect(restored.kind).toBe("video-story"); + expect(restored.draftValues).toMatchObject({ status: "ready", summary: "视频内容" }); + expect(restored.draftValues?.rows[0]).toMatchObject({ + startTime: "0", + keyframeUrl: "https://example.com/frame.png", + lighting: "", + }); +});