398 lines
13 KiB
TypeScript
398 lines
13 KiB
TypeScript
import { hasRecoverableCanvasTask } from "./task-recovery";
|
|
import { normalizeVideoAnalysisRows } from "./video-story";
|
|
import {
|
|
storyTaskAttachmentSchema,
|
|
mergeStoryTaskResult,
|
|
} from "./story-task-attachment";
|
|
import { z } from "zod";
|
|
|
|
export const canvasTaskRequestSchema = z.object({
|
|
requestId: z.string().uuid(),
|
|
projectId: z.string().uuid(),
|
|
kind: z.enum([
|
|
"video",
|
|
"image",
|
|
"analysis",
|
|
"image-operation",
|
|
"audio",
|
|
"smart-edit",
|
|
"video-upscale",
|
|
]),
|
|
node: z
|
|
.object({
|
|
id: z.string().min(1).max(180),
|
|
title: z.string().max(300),
|
|
x: z.number().finite(),
|
|
y: z.number().finite(),
|
|
width: z.number().positive().max(10000),
|
|
height: z.number().positive().max(10000),
|
|
kind: z.string().max(30),
|
|
inputType: z.string().max(30).optional(),
|
|
draftValues: z.record(z.unknown()).optional(),
|
|
})
|
|
.passthrough(),
|
|
params: z.record(z.unknown()),
|
|
workflow: z
|
|
.object({
|
|
runId: z.string().uuid(),
|
|
groupId: z.string().min(1).max(180),
|
|
inputs: z
|
|
.array(
|
|
z.object({
|
|
output: z.literal("edit-plan").optional(),
|
|
requestId: z.string().uuid().optional(),
|
|
node: z.object({
|
|
id: z.string().min(1).max(180),
|
|
title: z.string().optional(),
|
|
kind: z.string().optional(),
|
|
inputType: z.string().optional(),
|
|
draftValues: z.record(z.any()).optional(),
|
|
}),
|
|
})
|
|
)
|
|
.max(30),
|
|
})
|
|
.optional(),
|
|
attachment: storyTaskAttachmentSchema.optional(),
|
|
hiddenNodeIds: z.array(z.string()).max(20).optional(),
|
|
edges: z
|
|
.array(z.object({ id: z.string(), source: z.string(), target: z.string() }))
|
|
.max(30)
|
|
.default([]),
|
|
});
|
|
export type CanvasTaskRequest = z.infer<typeof canvasTaskRequestSchema>;
|
|
export type CanvasGenerationTask = {
|
|
id: string;
|
|
requestId: string;
|
|
status: string;
|
|
createdAt: string;
|
|
updatedAt?: string;
|
|
attempts?: number;
|
|
request: CanvasTaskRequest;
|
|
result?: Record<string, any> | null;
|
|
error?: string | null;
|
|
};
|
|
export function isCanvasTaskActive(status: string) {
|
|
return status === "pending" || status === "running";
|
|
}
|
|
export function canCancelCanvasTask(status: string) {
|
|
return status === "pending";
|
|
}
|
|
|
|
export function requireAnalysisResult(
|
|
data: Record<string, any> | undefined,
|
|
mode: string
|
|
) {
|
|
if (!data) throw Error("分析没有返回结果,原内容已保留");
|
|
if (
|
|
mode === "video-analysis" &&
|
|
!normalizeVideoAnalysisRows(data.analysisRows).length
|
|
)
|
|
throw Error("视频解析没有返回有效镜头,原内容已保留");
|
|
if (
|
|
mode === "director-reconstruct" &&
|
|
(!Array.isArray(data.directorScene?.objects) ||
|
|
!data.directorScene.objects.length ||
|
|
!Array.isArray(data.directorScene?.cameras) ||
|
|
!data.directorScene.cameras.length)
|
|
)
|
|
throw Error("识图重建没有返回有效布景,原场景已保留");
|
|
const rows = data.analysisRows || data.shotIdeas;
|
|
if (
|
|
mode === "story-script" &&
|
|
(!Array.isArray(rows) ||
|
|
!rows.length ||
|
|
!rows.some((row) =>
|
|
[row.sceneDescription, row.description, row.imagePrompt].some(
|
|
(value) => typeof value === "string" && value.trim()
|
|
)
|
|
))
|
|
) {
|
|
throw Error("脚本没有返回有效镜头,原内容已保留");
|
|
}
|
|
if (
|
|
mode !== "story-script" &&
|
|
mode !== "video-analysis" &&
|
|
![data.raw, data.summary, ...(data.agentPlan || [])].some(
|
|
(value) => typeof value === "string" && value.trim()
|
|
)
|
|
) {
|
|
throw Error("分析返回了空内容,原内容已保留");
|
|
}
|
|
return data;
|
|
}
|
|
|
|
// Stable result IDs and request IDs prevent repeat polling from duplicating images
|
|
// or an old task from overwriting a later generation on the same node.
|
|
export function reconcileCanvasTasks<
|
|
T extends {
|
|
id: string;
|
|
draftValues?: Record<string, any>;
|
|
[key: string]: any;
|
|
},
|
|
>(nodes: T[], tasks: CanvasGenerationTask[], projectRevision: string) {
|
|
let result = nodes;
|
|
const latestByNode = new Map<string, CanvasGenerationTask>();
|
|
for (const task of tasks) {
|
|
const previous = latestByNode.get(task.request.node.id);
|
|
if (
|
|
!previous ||
|
|
Date.parse(task.createdAt) >= Date.parse(previous.createdAt)
|
|
)
|
|
latestByNode.set(task.request.node.id, task);
|
|
}
|
|
for (const task of latestByNode.values()) {
|
|
const source = task.request.node;
|
|
const hidden = new Set(task.request.hiddenNodeIds || []);
|
|
if (hidden.has(source.id)) continue;
|
|
let index = result.findIndex((node) => node.id === source.id);
|
|
if (index < 0) {
|
|
if (Date.parse(task.createdAt) <= Date.parse(projectRevision)) continue;
|
|
result = [...result, source as unknown as T];
|
|
index = result.length - 1;
|
|
}
|
|
const node = result[index];
|
|
const draft = node.draftValues || {};
|
|
if (draft.taskRequestId && draft.taskRequestId !== task.requestId) {
|
|
const savedTask = tasks.find(
|
|
(item) => item.requestId === draft.taskRequestId
|
|
);
|
|
if (
|
|
!savedTask ||
|
|
Date.parse(task.createdAt) <= Date.parse(savedTask.createdAt) ||
|
|
Date.parse(task.createdAt) <= Date.parse(projectRevision)
|
|
)
|
|
continue;
|
|
}
|
|
const appliedStatus = `${task.id}:${task.status}${["smart-edit", "video-upscale"].includes(task.request.kind) ? `:${task.result?.upscale?.stage || task.result?.smartEdit?.stage || ""}` : ""}`;
|
|
if (draft.appliedTaskStatus === appliedStatus) continue;
|
|
const nextDraft = {
|
|
...draft,
|
|
taskRequestId: task.requestId,
|
|
generationTaskId: task.id,
|
|
appliedTaskStatus: appliedStatus,
|
|
generationError: undefined as string | undefined,
|
|
generationProgress: 0,
|
|
generationStatus: "pending",
|
|
pendingLabel:
|
|
task.status === "pending"
|
|
? "排队中"
|
|
: task.result?.upscale?.stage ||
|
|
task.result?.smartEdit?.stage ||
|
|
"生成中",
|
|
};
|
|
let subtitle = nextDraft.pendingLabel;
|
|
if (task.status === "succeeded") {
|
|
const data = task.result || {};
|
|
Object.assign(nextDraft, {
|
|
generated: true,
|
|
generationStatus: "complete",
|
|
generationProgress: 100,
|
|
pendingLabel: "",
|
|
});
|
|
subtitle = "生成结果";
|
|
if (task.request.kind === "smart-edit") {
|
|
if (task.request.params.operation !== "render") {
|
|
const saved = draft.smartEditDraft;
|
|
const processing = ["separate", "transcribe"].includes(
|
|
String(task.request.params.operation)
|
|
);
|
|
const plans =
|
|
processing && saved?.plans?.length
|
|
? saved.plans.map((plan: unknown, i: number) =>
|
|
i === (saved.selectedPlanIndex || 0)
|
|
? data.plans?.[0] || plan
|
|
: plan
|
|
)
|
|
: data.plans;
|
|
Object.assign(nextDraft, {
|
|
smartEditPlans: plans,
|
|
smartEditPlanTaskId: task.id,
|
|
});
|
|
if (processing && saved)
|
|
Object.assign(nextDraft, {
|
|
smartEditDraft: { ...saved, plans, planTaskId: task.id },
|
|
});
|
|
} else
|
|
Object.assign(nextDraft, {
|
|
mediaUrl: data.url,
|
|
url: [data.url],
|
|
videoMeta: data.metadata,
|
|
durationSeconds: data.metadata?.durationSeconds,
|
|
subtitleUrl: data.subtitleUrl,
|
|
editPlan: data.plan,
|
|
action: "VIDEO_RESOURCE",
|
|
mediaType: "video",
|
|
});
|
|
}
|
|
if (task.request.kind === "audio")
|
|
Object.assign(nextDraft, {
|
|
mediaUrl: data.url,
|
|
url: [data.url],
|
|
action: "AUDIO_RESOURCE",
|
|
mediaType: "audio",
|
|
audioMeta: data.metadata,
|
|
audioParams:
|
|
task.request.node.draftValues?.audioParams || task.request.params,
|
|
});
|
|
if (["video", "video-upscale"].includes(task.request.kind))
|
|
Object.assign(nextDraft, {
|
|
mediaUrl: data.url,
|
|
url: [data.url],
|
|
videoMeta: data.metadata,
|
|
});
|
|
if (
|
|
task.request.kind === "image" ||
|
|
task.request.kind === "image-operation"
|
|
) {
|
|
const urls = (data.images || [])
|
|
.map((image: any) => image.originalUrl || image.thumbnailUrl)
|
|
.filter(Boolean);
|
|
Object.assign(nextDraft, {
|
|
mediaUrl: urls[0],
|
|
url: urls,
|
|
action:
|
|
draft.action === "IMAGE_GENERATE_NINE_GRID"
|
|
? draft.action
|
|
: "IMAGE_RESOURCE",
|
|
});
|
|
for (let i = 1; i < urls.length; i++) {
|
|
const id = `${source.id}-result-${task.id}-${i}`;
|
|
if (!hidden.has(id) && !result.some((item) => item.id === id))
|
|
result = [
|
|
...result,
|
|
{
|
|
...node,
|
|
id,
|
|
title: `${node.title} ${i + 1}`,
|
|
y: node.y + i * (node.height + 40),
|
|
draftValues: {
|
|
...nextDraft,
|
|
mediaUrl: urls[i],
|
|
url: [urls[i]],
|
|
},
|
|
} as T,
|
|
];
|
|
}
|
|
}
|
|
if (
|
|
task.request.kind === "analysis" &&
|
|
task.request.params.mode === "director-reconstruct"
|
|
)
|
|
Object.assign(nextDraft, {
|
|
directorScene: data.directorScene,
|
|
reconstructionSummary: data.summary,
|
|
});
|
|
if (task.request.kind === "analysis")
|
|
Object.assign(nextDraft, {
|
|
content: [
|
|
data.raw || data.summary || data.agentPlan?.join("\n") || "",
|
|
],
|
|
...(Array.isArray(data.analysisRows || data.shotIdeas)
|
|
? { rows: data.analysisRows || data.shotIdeas }
|
|
: {}),
|
|
});
|
|
} else if (["failed", "cancelled", "interrupted"].includes(task.status)) {
|
|
Object.assign(nextDraft, {
|
|
generated: false,
|
|
generationStatus: "failed",
|
|
pendingLabel: "",
|
|
generationError:
|
|
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
|
|
? {
|
|
...item,
|
|
...(task.status === "succeeded" &&
|
|
task.request.kind !== "analysis" &&
|
|
!(
|
|
task.request.kind === "smart-edit" &&
|
|
task.request.params.operation !== "render"
|
|
)
|
|
? {
|
|
kind: "media",
|
|
inputType:
|
|
["video", "video-upscale"].includes(task.request.kind) ||
|
|
task.request.kind === "smart-edit"
|
|
? "video"
|
|
: task.request.kind === "audio"
|
|
? "audio"
|
|
: "image",
|
|
}
|
|
: {}),
|
|
subtitle,
|
|
draftValues: nextDraft,
|
|
}
|
|
: item
|
|
);
|
|
}
|
|
for (const task of tasks) {
|
|
const attachment = task.request.attachment;
|
|
if (
|
|
task.status !== "succeeded" ||
|
|
!attachment ||
|
|
task.request.hiddenNodeIds?.includes(task.request.node.id) ||
|
|
!result.some((node) => node.id === task.request.node.id)
|
|
)
|
|
continue;
|
|
const targetIndex = result.findIndex(
|
|
(node) => node.id === attachment.nodeId
|
|
);
|
|
if (targetIndex < 0) continue;
|
|
const node = result[targetIndex],
|
|
draft = node.draftValues || {};
|
|
const next = mergeStoryTaskResult(
|
|
draft,
|
|
attachment,
|
|
task.result || {},
|
|
task.id
|
|
);
|
|
if (next !== draft)
|
|
result = result.map((item, index) =>
|
|
index === targetIndex ? { ...item, draftValues: next } : item
|
|
);
|
|
}
|
|
return result;
|
|
}
|