fix(video-canvas): persist story analysis and resume accepted enhance tasks
This commit is contained in:
parent
86b66172b1
commit
69dcf9b745
@ -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" } },
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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 项全部完成或像素级一致。
|
||||
|
||||
39
lib/canvas/enhance-task-poll.ts
Normal file
39
lib/canvas/enhance-task-poll.ts
Normal 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("轮询超时");
|
||||
}
|
||||
@ -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;
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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
|
||||
? {
|
||||
|
||||
42
lib/video-canvas/task-recovery.ts
Normal file
42
lib/video-canvas/task-recovery.ts
Normal 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;
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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);
|
||||
|
||||
78
lib/video-canvas/video-story.ts
Normal file
78
lib/video-canvas/video-story.ts
Normal 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);
|
||||
}
|
||||
@ -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)
|
||||
|
||||
@ -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.",
|
||||
);
|
||||
|
||||
@ -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: "",
|
||||
});
|
||||
});
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user