Includes compiler snapshots, injection-proof escaping checks, and an ffmpeg integration test on synthetic testsrc/sine inputs that is skipped when ffmpeg is not installed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0138YqDj3ttfayRwuMKKrqVj
338 lines
10 KiB
TypeScript
338 lines
10 KiB
TypeScript
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<string, any>) => void): unknown {
|
|
const plan = JSON.parse(JSON.stringify(testEdl())) as Record<string, any>;
|
|
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<EdlContext>;
|
|
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();
|
|
});
|
|
});
|