import type { EdlError } from "@rakazo/contracts"; import { EdlErrorSchema } from "@rakazo/contracts"; import { describe, expect, it } from "vitest"; import type { EdlContext } from "./edl.js"; import { edlDurationMs, locateOutputTime, locateOutputTimeAll, validateEdl } from "./edl.js"; import { testContext, testEdl, testProfile } from "./test-fixtures.js"; /** Raw plan (pre-defaults) so cases can break it in any way. */ function raw(patch: (plan: Record) => void): unknown { const plan = JSON.parse(JSON.stringify(testEdl())) as Record; patch(plan); return plan; } function codes(input: unknown, context: EdlContext = testContext()): EdlError["code"][] { const result = validateEdl(input, context); return result.ok ? [] : result.errors.map((e) => e.code); } describe("validateEdl", () => { it("accepts a valid plan and returns the parsed EDL", () => { const result = validateEdl(testEdl(), testContext()); expect(result.ok).toBe(true); if (result.ok) expect(result.edl.tracks.video).toHaveLength(2); }); const cases: Array<{ code: EdlError["code"]; input: unknown; context?: Partial; path?: string; }> = [ { code: "schema", input: raw((p) => (p.edlVersion = 2)), path: "edlVersion" }, { code: "schema", input: raw((p) => (p.tracks.video[1].id = "c1")), path: "tracks.video[1].id", }, { code: "schema", input: raw((p) => (p.tracks.video[0].id = "Bad Id;[x]")), path: "tracks.video[0].id", }, { code: "unknown_asset", input: raw((p) => (p.tracks.video[0].assetId = "missing")), path: "tracks.video[0].assetId", }, { code: "unknown_clip", input: raw((p) => { p.transitions = [{ fromClipId: "c1", toClipId: "c9", kind: "cut", durationMs: 0 }]; }), path: "transitions[0].toClipId", }, { code: "unknown_clip", input: raw((p) => { p.redactions.regions = [ { id: "r1", clipId: "zz", keyframes: [{ tMs: 0, box: { x: 0, y: 0, w: 0.1, h: 0.1 } }], treatment: "blur", }, ]; }), }, { code: "unknown_artifact", input: raw((p) => { p.tracks.audio = [ { id: "m", kind: "sfx", artifactId: "nope", inMs: 0, outMs: 1000, atMs: 0 }, ]; }), }, { code: "unknown_artifact", input: raw((p) => (p.overlays = [{ id: "w", kind: "watermark", atMs: 0, durationMs: 1000 }])), context: { brandKit: null }, }, { code: "unknown_mark", input: raw((p) => (p.redactions.marks = [{ markId: "mark-x", treatment: "blur" }])), }, { code: "unknown_person", input: raw((p) => { p.redactions.faces = [{ id: "f", target: { personId: "person-x" }, mode: "blur" }]; }), }, { code: "unknown_track", input: raw((p) => { p.redactions.faces = [{ id: "f", target: { trackId: "track-x" }, mode: "pixelate" }]; }), }, { code: "unknown_template", input: raw((p) => (p.intro = { templateId: "intro-none", durationMs: 1000 })), path: "intro.templateId", }, { code: "unknown_template", input: raw((p) => (p.captions = { mode: "burned", presetId: "missing" })), path: "captions.presetId", }, { code: "range_out_of_asset", input: raw((p) => (p.tracks.video[1].outMs = 12_000)), path: "tracks.video[1].outMs", }, { code: "range_out_of_asset", input: raw((p) => (p.tracks.video[0].reframe = [{ tMs: 9000, cx: 0.5, cy: 0.5, scale: 1 }])), }, { code: "range_empty", input: raw((p) => (p.tracks.video[0].outMs = 0)), }, { code: "range_empty", input: raw( (p) => (p.overlays = [{ id: "t", kind: "text", atMs: 0, durationMs: 0, text: "x" }]), ), }, { code: "overlap", input: raw((p) => (p.tracks.video[1].atMs = 1500)), path: "tracks.video[1].atMs", }, { code: "overlap", // A crossfade needs exactly its duration of overlap. input: raw((p) => { p.transitions = [{ fromClipId: "c1", toClipId: "c2", kind: "crossfade", durationMs: 500 }]; }), }, { code: "overlap", input: raw((p) => { p.tracks.audio = [ { id: "m1", kind: "music", artifactId: "music-1", inMs: 0, outMs: 3000, atMs: 0 }, { id: "m2", kind: "music", artifactId: "music-1", inMs: 0, outMs: 3000, atMs: 2000 }, ]; }), }, { code: "duration_below_min", input: raw((p) => (p.output.durationHardMinMs = 5000)), }, { code: "duration_below_min", input: testEdl(), context: { profile: testProfile({ duration: { hardMinMs: 6000, hardMaxMs: 60_000 } }) }, }, { code: "duration_above_max", input: raw((p) => (p.output.durationHardMaxMs = 3000)), }, { code: "duration_above_max", input: testEdl(), context: { profile: testProfile({ duration: { hardMinMs: 0, hardMaxMs: 3000 } }) }, }, { code: "overlay_outside_safe_zone", input: raw((p) => { p.overlays = [ { id: "t", kind: "text", atMs: 0, durationMs: 1000, text: "Hi", box: { x: 0.1, y: 0.85, w: 0.5, h: 0.1 }, }, ]; }), path: "overlays[0].box", }, { code: "paid_only_segment", input: testEdl(), context: { paidOnly: [{ assetId: "asset-a", startMs: 6500, endMs: 9000 }] }, path: "tracks.video[1]", }, { code: "paid_only_segment", input: testEdl({ output: { ...testEdl().output, profileId: "example-paid-video" } }), context: { profile: testProfile({ id: "example-paid-video", kind: "paid_video" }), paidOnly: [{ assetId: "asset-a", startMs: 0, endMs: 100 }], goal: "teaser", }, }, { code: "profile_mismatch", input: raw((p) => (p.output.profileId = "other-profile")), }, { code: "profile_mismatch", input: raw((p) => { p.output.width = 1920; p.output.height = 1080; }), }, { code: "music_license", input: raw((p) => { p.tracks.audio = [ { id: "m", kind: "music", artifactId: "music-2", inMs: 0, outMs: 1000, atMs: 0 }, ]; }), }, { code: "music_license", input: raw((p) => { p.tracks.audio = [ { id: "m", kind: "music", artifactId: "vo-1", inMs: 0, outMs: 1000, atMs: 0 }, ]; }), }, { code: "music_license", input: raw((p) => { p.tracks.audio = [ { id: "m", kind: "music", artifactId: "music-1", inMs: 0, outMs: 1000, atMs: 0 }, ]; }), context: { profile: testProfile({ audio: { music: "platform_library_only" } }) }, }, ]; it.each(cases)("rejects $code ($path)", ({ code, input, context, path }) => { const result = validateEdl(input, testContext(context)); expect(result.ok).toBe(false); if (result.ok) return; for (const error of result.errors) expect(EdlErrorSchema.safeParse(error).success).toBe(true); const match = result.errors.find((e) => e.code === code); expect(match, JSON.stringify(result.errors)).toBeDefined(); if (path) expect(match!.path).toBe(path); }); it("covers every error code", () => { const covered = new Set(cases.map((c) => c.code)); expect([...covered].sort()).toEqual([...EdlErrorSchema.shape.code.options].sort()); }); it("allows paid-only material in a full release for a paid profile and any music where allowed", () => { const paid = testProfile({ id: "example-paid-video", kind: "paid_video", audio: { music: "any" }, }); const plan = raw((p) => { p.output.profileId = "example-paid-video"; p.tracks.audio = [ { id: "m", kind: "music", artifactId: "music-2", inMs: 0, outMs: 1000, atMs: 0 }, ]; }); expect( codes( plan, testContext({ profile: paid, paidOnly: [{ assetId: "asset-a", startMs: 0, endMs: 9000 }] }), ), ).toEqual([]); }); it("accepts photos without a duration and a crossfade with the right overlap", () => { const plan = raw((p) => { p.tracks.video[0].assetId = "photo-1"; p.tracks.video[1].atMs = 1500; p.transitions = [{ fromClipId: "c1", toClipId: "c2", kind: "crossfade", durationMs: 500 }]; }); expect(codes(plan)).toEqual([]); }); it("reports every failure at once", () => { const plan = raw((p) => { p.tracks.video[0].assetId = "missing"; p.tracks.video[1].outMs = 4000; p.redactions.marks = [{ markId: "mark-x", treatment: "cover" }]; }); expect(codes(plan)).toEqual(["unknown_asset", "range_empty", "unknown_mark"]); }); }); describe("timeline", () => { const edl = testEdl({ intro: { templateId: "intro-minimal", durationMs: 1000 }, outro: { templateId: "outro-simple", durationMs: 500 }, tracks: { video: [ { id: "c1", assetId: "asset-a", inMs: 0, outMs: 2000, atMs: 0, reframe: "none" }, { id: "c2", assetId: "asset-b", inMs: 1000, outMs: 5000, atMs: 1500, speed: 2, reframe: "none", }, ], }, transitions: [{ fromClipId: "c1", toClipId: "c2", kind: "dip", durationMs: 500 }], }); it("measures intro, speed, transition overlap and outro", () => { expect(edlDurationMs(edl)).toBe(1000 + 1500 + 2000 + 500); expect(edlDurationMs(testEdl())).toBe(4000); }); it("maps output time back to clip and source time", () => { expect(locateOutputTime(edl, 500)).toBeNull(); expect(locateOutputTime(edl, 1000)).toEqual({ clipId: "c1", assetId: "asset-a", sourceMs: 0 }); expect(locateOutputTime(edl, 2200)).toEqual({ clipId: "c1", assetId: "asset-a", sourceMs: 1200, }); // Inside the transition both clips are visible; the incoming one wins. expect(locateOutputTimeAll(edl, 2700).map((h) => h.clipId)).toEqual(["c1", "c2"]); expect(locateOutputTime(edl, 2700)).toEqual({ clipId: "c2", assetId: "asset-b", sourceMs: 1400, }); expect(locateOutputTime(edl, 4400)).toEqual({ clipId: "c2", assetId: "asset-b", sourceMs: 4800, }); expect(locateOutputTime(edl, 4600)).toBeNull(); }); });