314 lines
11 KiB
TypeScript
314 lines
11 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"]),
|
|
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()),
|
|
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}${task.request.kind === "smart-edit" ? `:${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?.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.params,
|
|
});
|
|
if (task.request.kind === "video")
|
|
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:
|
|
task.request.kind === "video" || 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;
|
|
}
|