580 lines
20 KiB
TypeScript
580 lines
20 KiB
TypeScript
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();
|
||
});
|