fix(video-canvas): persist story analysis and resume accepted enhance tasks

This commit is contained in:
134 2026-09-17 13:51:15 +08:00
parent 86b66172b1
commit 69dcf9b745
14 changed files with 856 additions and 553 deletions

View File

@ -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" } },

View File

@ -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<VideoAnalysisResponsePayload["analysisRows"]>[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<VideoAnalysisResponseRow> & Record<string, unknown>,
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<string, unknown>)
: 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<VideoAnalysisRow> & Record<string, unknown>;
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<GridSplitState, "columns">) {
@ -4843,7 +4648,6 @@ export function LiblibVideoCanvasReplica({
const clipRequestRunning = useRef(false);
const [clipProcessingNodeId, setClipProcessingNodeId] = useState<string | null>(null);
const [videoClipRanges, setVideoClipRanges] = useState<Record<string, VideoClipRange>>({});
const [videoAnalysisStates, setVideoAnalysisStates] = useState<Record<string, VideoAnalysisState>>({});
const [videoCropGenerateStates, setVideoCropGenerateStates] = useState<Record<string, VideoCropGenerateState>>({});
const [videoAudioSplitStates, setVideoAudioSplitStates] = useState<Record<string, VideoAudioSplitState>>({});
@ -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
<div
className={cn(
"flex h-full w-full flex-col overflow-hidden rounded-[8px] border border-white/10 bg-[#202020] text-white shadow-[0_22px_64px_rgba(0,0,0,0.46)]",
selected && "border-[#8b96ff] ring-2 ring-[#8b96ff]/36"
selected && "border-[#8b96ff] ring-2 ring-[#8b96ff]/36",
)}
data-video-story-node="true"
>
@ -13111,12 +12870,12 @@ function VideoStoryCanvasNode({ node, selected }: { node: CloneNode; selected: b
<span className="truncate">视频故事</span>
</div>
<span className="shrink-0 rounded-[6px] border border-white/10 px-2 py-0.5 text-[11px] text-white/40">
{rows.length || 1} 镜
{rows.length} 镜
</span>
</div>
{summary || error ? (
{summary || error || pending ? (
<div className="border-b border-white/8 px-4 py-2 text-[12px] leading-5 text-white/48">
{error || summary}
{error || (pending ? "正在解析视频故事,可离开页面,返回后会恢复任务状态。" : summary)}
</div>
) : null}
<div className="min-h-0 flex-1 overflow-auto">
@ -13153,7 +12912,10 @@ function VideoStoryCanvasNode({ node, selected }: { node: CloneNode; selected: b
key={row.id}
>
{cells.map((cell, index) => (
<div className="border-r border-white/6 px-3 py-3 last:border-r-0" key={`${row.id}-${index}`}>
<div
className="border-r border-white/6 px-3 py-3 last:border-r-0"
key={`${row.id}-${index}`}
>
<span className="line-clamp-5 whitespace-pre-wrap">{cell || "-"}</span>
</div>
))}
@ -13174,7 +12936,7 @@ function VideoStoryCanvasNode({ node, selected }: { node: CloneNode; selected: b
})
) : (
<div className="flex h-[260px] items-center justify-center text-[13px] text-white/32">
暂无视频故事
{pending ? "正在解析…" : "暂无视频故事"}
</div>
)}
</div>

View File

@ -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 项全部完成或像素级一致。

View File

@ -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<any> {
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("轮询超时");
}

View File

@ -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<any> {
const pollUrl = `${apiUrl}/api/tasks/visual/scale/${taskId}`;
const getTaskResult = async (): Promise<any> => {
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<void>) {
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;

View File

@ -24,7 +24,12 @@ export const imageOperationSchema = z
});
export type ImageOperation = z.infer<typeof imageOperationSchema>;
export class ImageOperationUncertainError extends Error {}
type Handler = (request: Request, userId: string) => Promise<Response>;
export class CanvasTaskLeaseLostError extends Error {}
type Handler = (
request: Request,
userId: string,
onTaskCreated?: (taskId: string) => Promise<void>,
) => Promise<Response>;
export async function executeImageOperation(
params: unknown,
userId: string,
@ -39,6 +44,8 @@ export async function executeImageOperation(
checkpoint?: (data: Record<string, unknown>) => Promise<void>;
wait?: (ms: number) => Promise<void>;
maxPolls?: number;
resume?: { operation: "enhance"; taskId: string; sourceImageUrl: string } | null;
assertLease?: () => Promise<void>;
} = {},
) {
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;

View File

@ -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<string, any> | null;
error?: string | null;
@ -45,6 +49,8 @@ export function canCancelCanvasTask(status: string) {
export function requireAnalysisResult(data: Record<string, any> | 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<string, any> | 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
? {

View File

@ -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<string, unknown> };
result?: Record<string, any> | null;
};
export function getEnhanceCheckpoint(task: Pick<RecoveryTask, "request" | "result">) {
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;
}

View File

@ -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<string, any> | 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<string, any>,
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<string, unknown>) {
export async function checkpointCanvasGenerationTask(
id: string,
result: Record<string, unknown>,
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);
}

View File

@ -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);

View File

@ -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<string, unknown>)
: {};
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<typeof item> => Boolean(item))
.slice(0, 120);
}

View File

@ -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<string, unknown>)
: {};
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)

View File

@ -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.",
);

View File

@ -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<TaskNode>(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<TaskNode>([], [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: "",
});
});