ai-chatbot/lib/video-canvas/task-contract.ts

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;
}