220 lines
7.7 KiB
TypeScript
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;
|
|
}
|
|
});
|