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"; import { tvstarTiming } from "@/lib/video-canvas/tvstar-timing"; 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("TVstar preserves every storyboard shot and trims generated clips to the requested duration", () => { const timing = tvstarTiming("做一个15秒毛笔广告", [ { duration: "2s" }, { duration: "5s" }, { duration: "8s" }, ]); expect(timing).toEqual({ targetDuration: 15, shotDurations: [2, 5, 8] }); const assets = timing.shotDurations.map((_, index) => ({ ...asset, id: `shot-${index}`, name: `镜头 ${index + 1}`, url: `https://example.com/shot-${index}.mp4`, duration: 8 * SECOND, })); const params = smartEditRequestSchema.parse({ operation: "plan", mode: "sequence", assets, targetDuration: timing.targetDuration, shotDurations: timing.shotDurations, semantic: false, }); if (params.operation !== "plan") throw Error(); const candidates = assets.map(item => ({ id: item.id, assetId: item.id, start: 0, end: item.duration, text: "" })); const edit = buildEditPlans(params, assets, candidates, candidates.map(item => item.id), [], [])[0]; expect(edit.clips.map(clip => (clip.sourceOut - clip.sourceIn) / SECOND)).toEqual([2, 5, 8]); expect(planTimeline(edit).duration).toBe(15 * SECOND); }); test("TVstar highlight ranges and adjacent fade keep the requested edit length", () => { const assets = [0, 1].map(index => ({ ...asset, id: `shot-${index}`, url: `https://example.com/shot-${index}.mp4`, duration: 8 * SECOND, })); const params = smartEditRequestSchema.parse({ operation: "plan", mode: "sequence", assets, targetDuration: 6, shotDurations: [2, 4], shotDescriptions: ["书写特写", "品牌露出"], semantic: true, }); if (params.operation !== "plan") throw Error(); const candidates = assets.map((item, index) => ({ id: item.id, assetId: item.id, start: (index + 1) * SECOND, end: item.duration, text: `已验证镜头 ${index + 1}`, })); const edit = buildEditPlans(params, assets, candidates, candidates.map(item => item.id), [], [], ["cut", "fade"])[0]; expect(edit.clips.map(clip => clip.sourceIn / SECOND)).toEqual([1, 2]); expect(edit.clips.map(clip => (clip.sourceOut - clip.sourceIn) / SECOND)).toEqual([2, 4]); expect(edit.clips[1].transition).toEqual({ type: "fade", duration: 0.2, alignment: "center" }); expect(planTimeline(edit).duration).toBe(6 * SECOND); }); 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([], [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([], [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(); });