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

580 lines
20 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 { resolveSmartEditIntent, assertBeatMusic } from "@/lib/video-canvas/smart-edit/intent";
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("detached audio retains its own timing after video edits and extends the composition", () => {
const p = plan();
p.clips[0].audioDetached = true;
p.audioClips = [
{
id: "audio",
assetId: "a",
start: 3 * SECOND,
sourceIn: SECOND,
sourceOut: 5 * SECOND,
speed: 2,
gain: 0.5,
fadeIn: 0.2,
fadeOut: 0.3,
},
];
expect(planTimeline(p).duration).toBe(5 * SECOND);
expect(planTimeline(p).audioClips[0].duration).toBe(2 * SECOND);
p.clips.reverse();
expect(planTimeline(p).audioClips[0].start).toBe(3 * SECOND);
p.audioClips[0].sourceOut = 7 * SECOND;
expect(() => planTimeline(p)).toThrow("范围");
p.audioClips[0].sourceOut = 5 * SECOND;
p.audioClips[0].start = 300 * SECOND;
expect(() => planTimeline(p)).toThrow("300");
p.audioClips[0].start = 0;
p.assets = [{ ...asset, hasAudio: false }];
expect(() => planTimeline(p)).toThrow("音轨");
});
test("positioned captions produce trusted ASS positions and still escape caption text", () => {
const p = plan();
p.captions = [
{
id: "positioned",
start: 0,
end: SECOND,
text: "移动{\\pos(1,1)}",
style: { ...defaultCaptionStyle, x: 0.25, y: 0.5 },
},
];
const ass = subtitlesToAss(p, 1280, 720);
expect(ass).toContain("{\\pos(320,360)}移动{\pos(1,1)}");
p.clips[0].transform = { x: 0.2, y: -0.2, scale: 0.5 };
expect(planTimeline(p).clips[0].transform).toEqual(p.clips[0].transform);
p.clips[0].transform.scale = 4;
expect(() => planTimeline(p)).toThrow();
});
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("centered transitions keep incoming source captions at their original edit time", () => {
const p = plan();
p.clips[1].transition = { type: "fade", duration: 2, alignment: "center" };
expect(compiledSubtitles(p)).toEqual([
{ start: SECOND, end: 2 * SECOND, text: "第一句" },
{ start: 3 * SECOND, end: 3.5 * SECOND, text: "第二句" },
]);
});
test("outline opacity leaves caption fill visible and preserves legacy whole-text opacity", () => {
const p = plan();
const style = { ...defaultCaptionStyle, outlineWidth: 4, outlineOpacity: 0 };
p.captions = [{ id: "alpha", start: 0, end: SECOND, text: "描边", style }];
let fields = subtitlesToAss(p, 1280, 720)
.split("\n")
.find((s) => s.startsWith("Style:"))!
.split(",");
expect(fields[3]).toBe("&H00ffffff");
expect(fields[5]).toBe("&Hff000000");
p.captions[0].style = { ...defaultCaptionStyle, opacity: 0.5 };
fields = subtitlesToAss(p, 1280, 720)
.split("\n")
.find((s) => s.startsWith("Style:"))!
.split(",");
expect(fields[3]).toBe("&H80ffffff");
expect(fields[5]).toBe("&H80000000");
});
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("centered transitions preserve the edit length and safely bound adjacent short clips", () => {
const p = plan();
p.clips = [0, 1, 2].map((i) => ({
...p.clips[0],
id: String(i),
sourceIn: 0,
sourceOut: 2 * SECOND,
transition: { type: "fadeblack" as const, duration: 4, alignment: "center" as const },
}));
const t = planTimeline(p);
expect(t.duration).toBe(6 * SECOND);
expect(t.clips.map((c) => c.start)).toEqual([0, 2 * SECOND, 4 * SECOND]);
expect(t.clips[1].transitionStart + t.clips[1].overlap).toBeLessThanOrEqual(
t.clips[2].transitionStart,
);
p.clips = p.clips.slice(0, 2);
const full = planTimeline(p);
expect(full.duration).toBe(4 * SECOND);
expect(full.clips[1].transitionStart).toBe(0);
expect(full.clips[1].overlap).toBe(4 * SECOND);
expect(full.clips[0].padAfter).toBe(2 * SECOND);
expect(full.clips[1].padBefore).toBe(2 * SECOND);
p.clips[1].transition!.duration = 2.2;
expect(planTimeline(p).duration).toBe(4 * SECOND);
expect(planTimeline(p).clips[1].transitionStart).toBe(0.9 * SECOND);
});
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 });
}
});
test("muting preserves gain through validation and independent audio edits", () => {
const p = plan();
p.clips[0] = { ...p.clips[0], gain: 10 ** (6 / 20), muted: true };
let timeline = planTimeline(p);
expect(timeline.clips[0].gain).toBeCloseTo(1.99526);
expect(timeline.clips[0].muted).toBe(true);
p.clips[0].muted = false;
timeline = planTimeline(p);
expect(timeline.clips[0].gain).toBeCloseTo(1.99526);
p.clips[0].gain = 4.01;
expect(() => planTimeline(p)).toThrow();
});
test("1080-based new captions and legacy 720-based captions retain output sizing", async () => {
const { newCaptionStyle } = await import("@/lib/video-canvas/smart-edit/contract");
const p = plan();
p.captions = [
{
id: "old",
start: 0,
end: SECOND,
text: "旧字幕",
style: { ...defaultCaptionStyle, fontSize: 28 },
},
{ id: "new", start: SECOND, end: 2 * SECOND, text: "新字幕", style: newCaptionStyle },
];
let ass = subtitlesToAss(p, 1920, 1080);
expect(ass).toContain("Style: c0,Noto Sans CJK SC,42,");
expect(ass).toContain("Style: c1,Source Han Sans SC,56,");
p.captions[1].style = {
...newCaptionStyle,
fontFamily: "noto-serif-sc",
outlineWidth: 20,
spacing: 100,
};
expect(() => planTimeline(p)).not.toThrow();
ass = subtitlesToAss(p, 1280, 720);
expect(ass).toContain("Style: c1,Noto Serif SC,37.33333333333333,");
});
test("node intent honors explicit beat switches, missing music, and ad variants", async () => {
const beat = resolveSmartEditIntent({ prompt: "卡点并突出产品", mode: "ads" });
expect(beat.beatSync).toBe(true);
expect(beat.variants).toBe(3);
expect(() => assertBeatMusic(beat)).toThrow("背景音乐");
expect(() => assertBeatMusic({ ...beat, music: { url: asset.url } })).not.toThrow();
expect(resolveSmartEditIntent({ prompt: "不用卡点" }).beatSync).toBe(false);
expect(resolveSmartEditIntent({ prompt: "需要卡点", beatSync: false }).beatSync).toBe(false);
expect(resolveSmartEditIntent({ mode: "ads", variants: 2 }).variants).toBe(2);
});
test("audio processing retains the editor, other variants, and the selected plan", () => {
for (const operation of ["separate", "transcribe"]) {
const first = plan(),
second = { ...plan(), title: "另一个方案" };
const changed = { ...second, title: "音频处理完成" };
const node = {
id: "edit",
kind: "text",
draftValues: {
action: "SMART_EDIT_WORKFLOW",
smartEditDraft: { plans: [first, second], selectedPlanIndex: 1 },
},
};
const task: any = {
id: "processed",
requestId: "req",
status: "succeeded",
createdAt: "2026-09-18T00:01:00Z",
request: { kind: "smart-edit", node, params: { operation, plan: second } },
result: { plans: [changed] },
};
const result = reconcileCanvasTasks([node], [task], "2026-09-18T00:00:00Z")[0];
expect(result.kind).toBe("text");
expect(result.draftValues.smartEditDraft.plans.map((p: any) => p.title)).toEqual([
first.title,
changed.title,
]);
expect(result.draftValues.smartEditDraft.selectedPlanIndex).toBe(1);
expect(reconcileCanvasTasks([result], [task], "2026-09-18T00:00:00Z")).toEqual([result]);
}
});
test("audio-only stem assets can export, but cannot become a video clip", () => {
const p = plan();
p.assets.push({ ...asset, id: "vocals", width: 0, height: 0 });
p.audioClips = [
{
id: "voice",
assetId: "vocals",
start: 0,
sourceIn: 0,
sourceOut: SECOND,
speed: 1,
gain: 1,
fadeIn: 0,
fadeOut: 0,
},
];
expect(planTimeline(p).duration).toBeGreaterThan(0);
p.clips[0].assetId = "vocals";
expect(() => planTimeline(p)).toThrow();
});