ai-chatbot/tests/routes/video-canvas-smart-edit.test.ts

220 lines
7.7 KiB
TypeScript

import { test, expect } from "@playwright/test";
import {
smartEditRequestSchema,
planTimeline,
compiledSubtitles,
subtitlesToSrt,
parseSrt,
buildEditPlans,
SECOND,
type EditPlan,
} from "@/lib/video-canvas/smart-edit/contract";
import { getSmartEditCheckpoint, canResumeCanvasTask } from "@/lib/video-canvas/task-recovery";
import { reconcileCanvasTasks } from "@/lib/video-canvas/task-contract";
const asset = {
id: "a",
name: "A",
url: "https://example.com/a.mp4",
duration: 6 * SECOND,
width: 320,
height: 180,
hasAudio: true,
hash: "a".repeat(64),
subtitles: [
{ start: SECOND, end: 2 * SECOND, text: "第一句" },
{ start: 3 * SECOND, end: 4 * SECOND, text: "第二句" },
],
};
const plan = (): EditPlan => ({
version: 1,
title: "QA",
assets: [asset],
ratio: "16:9",
resolution: "720p",
transition: 0,
subtitles: true,
warnings: [],
clips: [
{ id: "c1", assetId: "a", sourceIn: 0, sourceOut: 3 * SECOND, speed: 1, gain: 1, label: "red" },
{
id: "c2",
assetId: "a",
sourceIn: 3 * SECOND,
sourceOut: 5 * SECOND,
speed: 2,
gain: 0,
label: "blue",
},
],
});
test("EDL computes overlap, speed and remapped subtitle timestamps from a shared timeline", () => {
const p = plan();
p.transition = 0.25;
const t = planTimeline(p);
expect(t.duration).toBe(3.75 * SECOND);
expect(t.clips[1].start).toBe(2.75 * SECOND);
expect(compiledSubtitles(p)).toEqual([
{ start: SECOND, end: 2 * SECOND, text: "第一句" },
{ start: 3 * SECOND, end: 3.25 * SECOND, text: "第二句" },
]);
expect(parseSrt(subtitlesToSrt(compiledSubtitles(p)))).toEqual(compiledSubtitles(p));
});
test("invalid source ranges, duplicate identity and overlong compositions cannot render", () => {
const p = plan();
p.clips[1].sourceOut = 8 * SECOND;
expect(() => planTimeline(p)).toThrow("范围");
p.clips[1].sourceOut = 5 * SECOND;
p.clips[1].id = "c1";
expect(() => planTimeline(p)).toThrow("重复");
const over = plan();
over.assets[0] = { ...asset, duration: 100 * SECOND };
over.clips = [{ ...over.clips[0], sourceOut: 100 * SECOND }];
expect(() => planTimeline(over)).toThrow("90");
});
test("spoken sentences are kept intact and missing footage is reported rather than repeated", () => {
const params = smartEditRequestSchema.parse({
operation: "plan",
mode: "talk",
assets: [asset],
targetDuration: 3,
semantic: false,
});
if (params.operation !== "plan") throw Error();
const candidates = [{ id: "one", assetId: "a", start: 0, end: 4 * SECOND, text: "完整语句" }];
const plans = buildEditPlans(params, [asset], candidates, ["one"], [], []);
expect(plans[0].clips[0].sourceOut).toBe(4 * SECOND);
expect(plans[0].warnings.join()).toContain("实际 4.00");
expect(() => buildEditPlans(params, [asset], candidates, ["invented"], [], [])).toThrow("无效");
});
test("music cut points use detected beats and batch variants preserve original source ranges", () => {
const params = smartEditRequestSchema.parse({
operation: "plan",
mode: "ads",
assets: [asset],
targetDuration: 6,
semantic: false,
beatSync: true,
beatsPerCut: "2",
music: { url: "https://example.com/music.wav" },
variants: 2,
});
if (params.operation !== "plan") throw Error();
const candidates = [
{ id: "one", assetId: "a", start: 0, end: 3 * SECOND, text: "" },
{ id: "two", assetId: "a", start: 3 * SECOND, end: 6 * SECOND, text: "" },
];
const plans = buildEditPlans(
params,
[asset],
candidates,
["one", "two"],
[0, 0.5, 1, 1.5, 2, 2.5, 3, 3.5, 4, 4.5, 5].map((t) => t * SECOND),
[],
);
expect(plans).toHaveLength(2);
expect(plans[0].clips[0].sourceOut).toBe(3 * SECOND);
expect(plans[1].clips[0].sourceIn).toBe(3 * SECOND);
});
test("only safe local or completed-model phases recover; an uncertain semantic request never repeats", () => {
const now = Date.now();
const task = {
status: "interrupted",
createdAt: new Date(now - 60000).toISOString(),
updatedAt: new Date(now - 60000).toISOString(),
attempts: 1,
request: { kind: "smart-edit", params: { operation: "plan" } },
result: {
smartEdit: { operation: "smart-edit", action: "plan", phase: "analyzed", stage: "ready" },
},
};
expect(getSmartEditCheckpoint(task)).not.toBeNull();
expect(canResumeCanvasTask(task, now)).toBe(true);
task.result.smartEdit.phase = "selecting";
expect(canResumeCanvasTask(task, now)).toBe(false);
task.result.smartEdit.phase = "selected";
expect(canResumeCanvasTask(task, now)).toBe(true);
});
test("plan tasks retain editor node kind while render results become video resources", () => {
const base: any = {
id: "task",
requestId: "r",
status: "succeeded",
createdAt: "2026-09-17T10:00:00Z",
request: {
kind: "smart-edit",
params: { operation: "plan" },
node: {
id: "n",
title: "剪辑",
kind: "model",
inputType: "video",
draftValues: { nodeType: "smart-edit" },
},
edges: [],
},
result: { plans: [plan()] },
};
const nodes = reconcileCanvasTasks<any>([], [base], "2026-09-17T09:00:00Z");
expect(nodes[0].kind).toBe("model");
expect(nodes[0].draftValues.smartEditPlans).toHaveLength(1);
base.request.params.operation = "render";
base.result = { url: "https://example.com/out.mp4", plan: plan() };
const rendered = reconcileCanvasTasks<any>([], [base], "2026-09-17T09:00:00Z");
expect(rendered[0].kind).toBe("media");
expect(rendered[0].inputType).toBe("video");
expect(rendered[0].draftValues.mediaUrl).toBe(base.result.url);
});
test("SRT input rejects bad time ranges and preserves multiline unicode text", () => {
expect(() => parseSrt("1\n00:00:02,000 --> 00:00:01,000\n坏")).toThrow();
const rows = parseSrt("\uFEFF1\r\n00:00:01,000 --> 00:00:02,500\r\n你好\r\n第二行");
expect(rows[0]).toEqual({ start: SECOND, end: 2.5 * SECOND, text: "你好\n第二行" });
});
test("remote render records deterministic identity before network failure and refuses endpoint changes", async () => {
const { remoteRender } = await import("@/lib/video-canvas/smart-edit/remote-render");
const savedURL = process.env.SMART_EDIT_RENDER_URL,
savedToken = process.env.SMART_EDIT_RENDER_TOKEN,
oldFetch = globalThis.fetch;
process.env.SMART_EDIT_RENDER_URL = "http://127.0.0.1:19399";
process.env.SMART_EDIT_RENDER_TOKEN = "x".repeat(64);
const checkpoints: any[] = [];
let calls = 0;
globalThis.fetch = (async () => {
calls++;
return new Response("{}", { status: 503 });
}) as typeof fetch;
try {
const lifecycle = {
active: async () => {},
save: async (value: unknown) => {
checkpoints.push(value);
},
};
await expect(remoteRender(plan(), "user", "stable-task", "/unused", lifecycle)).rejects.toThrow(
"任务已保存",
);
await expect(
remoteRender(plan(), "user", "stable-task", "/unused", {
...lifecycle,
resume: checkpoints[0],
}),
).rejects.toThrow("任务已保存");
expect(checkpoints[0]).toEqual(checkpoints[1]);
expect(calls).toBe(2);
process.env.SMART_EDIT_RENDER_URL = "http://127.0.0.1:19400";
await expect(
remoteRender(plan(), "user", "stable-task", "/unused", {
...lifecycle,
resume: checkpoints[0],
}),
).rejects.toThrow("所属服务器已变化");
expect(calls).toBe(2);
} finally {
globalThis.fetch = oldFetch;
if (savedURL === undefined) delete process.env.SMART_EDIT_RENDER_URL;
else process.env.SMART_EDIT_RENDER_URL = savedURL;
if (savedToken === undefined) delete process.env.SMART_EDIT_RENDER_TOKEN;
else process.env.SMART_EDIT_RENDER_TOKEN = savedToken;
}
});