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

366 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { remoteRender } from "@/lib/video-canvas/smart-edit/remote-render";
import { test, expect } from "@playwright/test";
import {
smartEditRequestSchema,
planTimeline,
compiledSubtitles,
subtitlesToSrt,
subtitlesToAss,
editableCaptions,
defaultCaptionStyle,
editOutputSize,
editRatios,
editResolutions,
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: 400 * SECOND };
over.clips = [{ ...over.clips[0], sourceOut: 400 * SECOND }];
expect(() => planTimeline(over)).toThrow("300");
});
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 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;
}
});
test("manual timeline captions survive source changes and cannot inject ASS commands", () => {
const p = plan();
const source = editableCaptions(p);
expect(source).toHaveLength(2);
p.captions = [
{
id: "manual",
start: 0,
end: 8 * SECOND,
text: "你好\n{\\pos(1,1)}\\N",
style: { ...defaultCaptionStyle, color: "#ffcc00", outlineWidth: 2 },
},
];
expect(compiledSubtitles(p)[0].end).toBe(4 * SECOND);
const ass = subtitlesToAss(p, 1280, 720);
expect(ass).toContain("Noto Sans CJK SC,28,&H0000ccff");
expect(ass).not.toContain("{\\pos");
expect(ass).toContain("你好\\N{\pos(1,1)}\N");
p.subtitles = false;
expect(compiledSubtitles(p)).toEqual([]);
expect(editableCaptions(p)).toHaveLength(1);
});
test("per-boundary transitions override the global fade and stay attached on reorder", () => {
const p = plan();
p.transition = 0.3;
p.clips[1].transition = { duration: 0.2, type: "fadeblack" };
expect(planTimeline(p).duration).toBe(3.8 * SECOND);
expect(planTimeline(p).clips[1].transitionType).toBe("fadeblack");
p.clips[1].transition.duration = 0;
expect(planTimeline(p).duration).toBe(4 * SECOND);
p.clips.reverse();
expect(planTimeline(p).clips[0].overlap).toBe(0);
p.captions = [
{ id: "same", start: 0, end: SECOND, text: "a" },
{ id: "same", start: SECOND, end: 2 * SECOND, text: "b" },
];
expect(() => planTimeline(p)).toThrow("字幕 ID 重复");
});
test("sequence import preserves all seven connected clips without AI selection or five-second truncation", () => {
const assets = Array.from({ length: 7 }, (_, i) => ({
...asset,
id: "source-" + i,
duration: 6 * SECOND,
}));
const params = smartEditRequestSchema.parse({
operation: "plan",
mode: "sequence",
assets,
semantic: false,
});
if (params.operation !== "plan") throw Error();
const candidates = assets.map((a) => ({
id: a.id,
assetId: a.id,
start: 0,
end: a.duration,
text: "",
}));
const result = buildEditPlans(
params,
assets,
candidates,
candidates.map((c) => c.id),
[],
[],
)[0];
expect(result.clips).toHaveLength(7);
expect(planTimeline(result).duration).toBe(42 * SECOND);
expect(result.warnings).toEqual([]);
});
test("all reference aspect ratios and resolutions produce even encoder dimensions", () => {
for (const ratio of editRatios)
for (const resolution of editResolutions) {
const size = editOutputSize({ ratio, resolution }),
[w, h] = ratio.split(":").map(Number);
expect(size.width % 2).toBe(0);
expect(size.height % 2).toBe(0);
expect(Math.min(size.width, size.height)).toBe(parseInt(resolution, 10));
expect(Math.abs(size.width / size.height - w / h)).toBeLessThan(0.005);
}
const p = plan();
p.assets = [{ ...asset, duration: 180 * SECOND }];
p.clips = [
{ ...p.clips[0], sourceOut: 150 * SECOND },
{ ...p.clips[0], id: "long2", sourceOut: 150 * SECOND },
];
expect(planTimeline(p).duration).toBe(300 * SECOND);
});
test("remote result accepts a five-minute ultrawide export and downloads the same job", async () => {
const { mkdtemp, readFile, rm } = await import("node:fs/promises");
const { tmpdir } = await import("node:os");
const { join } = await import("node:path");
const dir = await mkdtemp(join(tmpdir(), "smart-edit-wide-"));
const oldFetch = globalThis.fetch,
savedURL = process.env.SMART_EDIT_RENDER_URL,
savedToken = process.env.SMART_EDIT_RENDER_TOKEN;
let id = "";
process.env.SMART_EDIT_RENDER_URL = "http://127.0.0.1:19399";
process.env.SMART_EDIT_RENDER_TOKEN = "x".repeat(64);
globalThis.fetch = (async (input: string | URL | Request, init?: RequestInit) => {
if (init?.method === "POST")
return Response.json({
id,
status: "succeeded",
stage: "完成",
result: { width: 2520, height: 1080, durationSeconds: 300 },
});
expect(String(input)).toContain(`/v1/renders/${id}/`);
return new Response(String(input).endsWith("/video") ? "fixture-video" : "字幕");
}) as typeof fetch;
try {
const result = await remoteRender(
{ ...plan(), ratio: "21:9", resolution: "1080p" },
"user",
"wide-task",
dir,
{
active: async () => {},
save: async (checkpoint) => {
id = checkpoint.id;
},
},
);
expect(result.width).toBe(2520);
expect(result.duration).toBe(300);
expect(await readFile(result.file, "utf8")).toBe("fixture-video");
expect(await readFile(result.srt, "utf8")).toBe("字幕");
} 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;
await rm(dir, { recursive: true, force: true });
}
});