diff --git a/packages/media/src/__snapshots__/compile.test.ts.snap b/packages/media/src/__snapshots__/compile.test.ts.snap
new file mode 100644
index 0000000..94005f5
--- /dev/null
+++ b/packages/media/src/__snapshots__/compile.test.ts.snap
@@ -0,0 +1,491 @@
+// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
+
+exports[`compileRenderPlan > compiles 16:9 to 9:16 with auto-reframe, captions, watermark and loudnorm 1`] = `
+{
+ "outputs": [
+ {
+ "kind": "video",
+ "path": "/work/render-1/output.mp4",
+ },
+ {
+ "kind": "subtitles_srt",
+ "path": "/work/render-1/captions.srt",
+ },
+ {
+ "kind": "subtitles_vtt",
+ "path": "/work/render-1/captions.vtt",
+ },
+ {
+ "kind": "poster",
+ "path": "/work/render-1/poster.jpg",
+ },
+ ],
+ "quality": "final",
+ "steps": [
+ {
+ "content": "[Script Info]
+ScriptType: v4.00+
+PlayResX: 1080
+PlayResY: 1920
+WrapStyle: 2
+ScaledBorderAndShadow: yes
+
+[V4+ Styles]
+Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding
+Style: Default,Example Sans,96,&H00FFFFFF,&H00FFFFFF,&H00000000,&H80000000,0,0,0,0,100,100,0,0,1,2,0,2,92,168,422,1
+
+[Events]
+Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text
+Dialogue: 0,0:00:00.20,0:00:01.80,Default,,0,0,0,,Wait for this!
+Dialogue: 0,0:00:02.10,0:00:03.40,Default,,0,0,0,,Here it is --> now.
+",
+ "id": "captions-ass",
+ "kind": "write_file",
+ "path": "/work/render-1/captions.ass",
+ },
+ {
+ "content": "1
+00:00:00,200 --> 00:00:01,800
+Wait for this!
+
+2
+00:00:02,100 --> 00:00:03,400
+Here it is -> now.
+",
+ "id": "captions-srt",
+ "kind": "write_file",
+ "path": "/work/render-1/captions.srt",
+ },
+ {
+ "content": "WEBVTT
+
+00:00:00.200 --> 00:00:01.800
+Wait for this!
+
+00:00:02.100 --> 00:00:03.400
+Here it <b>is</b> --> now.
+",
+ "id": "captions-vtt",
+ "kind": "write_file",
+ "path": "/work/render-1/captions.vtt",
+ },
+ {
+ "argv": [
+ "-hide_banner",
+ "-nostdin",
+ "-y",
+ "-ss",
+ "0",
+ "-t",
+ "2",
+ "-i",
+ "/media/asset-a/proxy.mp4",
+ "-ss",
+ "5",
+ "-t",
+ "2",
+ "-i",
+ "/media/asset-a/proxy.mp4",
+ "-i",
+ "/brand/watermark.png",
+ "-filter_complex",
+ "[0:v]crop=w=608:h=1080:x=if(lt(t\\,0)\\,170.82\\,if(lt(t\\,1.5)\\,170.82+(167.81)*(t-0)/1.5\\,if(lt(t\\,2)\\,338.62+(19.97)*(t-1.5)/0.5\\,358.59))):y=if(lt(t\\,0)\\,0\\,if(lt(t\\,1.5)\\,0+(0)*(t-0)/1.5\\,if(lt(t\\,2)\\,0+(0)*(t-1.5)/0.5\\,0))),scale=1080:1920,setsar=1,setpts=(PTS-STARTPTS)/1,fps=30,format=yuv420p,tpad=stop_mode=clone:stop_duration=2,trim=duration=2,setpts=PTS-STARTPTS[v_c1];[0:a]asetpts=PTS-STARTPTS,aresample=48000,aformat=sample_fmts=fltp:sample_rates=48000:channel_layouts=stereo,apad,atrim=duration=2,asetpts=PTS-STARTPTS[a_c1];[1:v]crop=w=608:h=1080:x=if(lt(t\\,0)\\,1232\\,if(lt(t\\,2)\\,1232+(0)*(t-0)/2\\,1232)):y=if(lt(t\\,0)\\,0\\,if(lt(t\\,2)\\,0+(0)*(t-0)/2\\,0)),scale=1080:1920,setsar=1,setpts=(PTS-STARTPTS)/1,fps=30,format=yuv420p,tpad=stop_mode=clone:stop_duration=2,trim=duration=2,setpts=PTS-STARTPTS[v_c2];[1:a]asetpts=PTS-STARTPTS,aresample=48000,aformat=sample_fmts=fltp:sample_rates=48000:channel_layouts=stereo,apad,atrim=duration=2,asetpts=PTS-STARTPTS[a_c2];[v_c1][a_c1][v_c2][a_c2]concat=n=2:v=1:a=1[cv1][ca2];[2:v]format=rgba,scale=162:-2,colorchannelmixer=aa=0.6[img3];[cv1][img3]overlay=x=main_w-overlay_w-152:y=214:enable=between(t\\,0\\,4)[ov4];[ov4]ass=filename=/work/render-1/captions.ass:fontsdir=/brand/fonts[cap5];[cap5]format=yuv420p[vout];[ca2]loudnorm=I=-14:TP=-1:LRA=11:linear=true,aresample=48000,aformat=sample_fmts=fltp:sample_rates=48000:channel_layouts=stereo[aout]",
+ "-map",
+ "[vout]",
+ "-map",
+ "[aout]",
+ "-c:v",
+ "libx264",
+ "-preset",
+ "medium",
+ "-crf",
+ "20",
+ "-profile:v",
+ "high",
+ "-maxrate",
+ "8000k",
+ "-bufsize",
+ "16000k",
+ "-pix_fmt",
+ "yuv420p",
+ "-colorspace",
+ "bt709",
+ "-color_primaries",
+ "bt709",
+ "-color_trc",
+ "bt709",
+ "-color_range",
+ "tv",
+ "-bsf:v",
+ "filter_units=remove_types=6",
+ "-r",
+ "30",
+ "-c:a",
+ "aac",
+ "-b:a",
+ "192k",
+ "-ar",
+ "48000",
+ "-ac",
+ "2",
+ "-map_metadata",
+ "-1",
+ "-map_chapters",
+ "-1",
+ "-fflags",
+ "+bitexact",
+ "-flags:v",
+ "+bitexact",
+ "-flags:a",
+ "+bitexact",
+ "-metadata",
+ "title=Example: teaser",
+ "-t",
+ "4",
+ "-movflags",
+ "+faststart",
+ "/work/render-1/output.mp4",
+ ],
+ "durationMs": 4000,
+ "id": "render",
+ "kind": "ffmpeg",
+ "produces": [
+ "/work/render-1/output.mp4",
+ ],
+ },
+ {
+ "argv": [
+ "-hide_banner",
+ "-nostdin",
+ "-y",
+ "-ss",
+ "1.333",
+ "-i",
+ "/work/render-1/output.mp4",
+ "-frames:v",
+ "1",
+ "-map_metadata",
+ "-1",
+ "-fflags",
+ "+bitexact",
+ "-flags:v",
+ "+bitexact",
+ "-q:v",
+ "2",
+ "/work/render-1/poster.jpg",
+ ],
+ "durationMs": 0,
+ "id": "poster",
+ "kind": "ffmpeg",
+ "produces": [
+ "/work/render-1/poster.jpg",
+ ],
+ },
+ ],
+ "workDir": "/work/render-1",
+}
+`;
+
+exports[`compileRenderPlan > compiles transitions, music ducking, voice-over and fades 1`] = `
+{
+ "outputs": [
+ {
+ "kind": "video",
+ "path": "/work/render-1/output.mp4",
+ },
+ {
+ "kind": "poster",
+ "path": "/work/render-1/poster.jpg",
+ },
+ ],
+ "quality": "draft",
+ "steps": [
+ {
+ "argv": [
+ "-hide_banner",
+ "-nostdin",
+ "-y",
+ "-ss",
+ "0",
+ "-t",
+ "2",
+ "-i",
+ "/media/asset-a/proxy.mp4",
+ "-ss",
+ "2",
+ "-t",
+ "2",
+ "-i",
+ "/media/asset-a/proxy.mp4",
+ "-ss",
+ "3",
+ "-t",
+ "1",
+ "-i",
+ "/media/asset-a/proxy.mp4",
+ "-ss",
+ "4",
+ "-t",
+ "1",
+ "-i",
+ "/media/asset-b/original.mov",
+ "-ss",
+ "0",
+ "-t",
+ "8",
+ "-i",
+ "/brand/music/bed.wav",
+ "-ss",
+ "0",
+ "-t",
+ "2",
+ "-i",
+ "/work/render-1/voiceover.wav",
+ "-filter_complex",
+ "[0:v]scale=1080:1920:force_original_aspect_ratio=decrease:force_divisible_by=2,pad=1080:1920:(ow-iw)/2:(oh-ih)/2:color=black,setsar=1,setpts=(PTS-STARTPTS)/1,fps=30,format=yuv420p,tpad=stop_mode=clone:stop_duration=2,trim=duration=2,setpts=PTS-STARTPTS[v_c1];[0:a]asetpts=PTS-STARTPTS,aresample=48000,aformat=sample_fmts=fltp:sample_rates=48000:channel_layouts=stereo,apad,atrim=duration=2,asetpts=PTS-STARTPTS[a_c1];[1:v]scale=1080:1920:force_original_aspect_ratio=decrease:force_divisible_by=2,pad=1080:1920:(ow-iw)/2:(oh-ih)/2:color=black,setsar=1,setpts=(PTS-STARTPTS)/0.5,fps=30,format=yuv420p,tpad=stop_mode=clone:stop_duration=2,trim=duration=4,setpts=PTS-STARTPTS[v_c2];[1:a]asetpts=PTS-STARTPTS,atempo=0.5,aresample=48000,aformat=sample_fmts=fltp:sample_rates=48000:channel_layouts=stereo,apad,atrim=duration=4,asetpts=PTS-STARTPTS[a_c2];[2:v]scale=1080:1920:force_original_aspect_ratio=decrease:force_divisible_by=2,pad=1080:1920:(ow-iw)/2:(oh-ih)/2:color=black,setsar=1,setpts=(PTS-STARTPTS)/1,fps=30,format=yuv420p,tpad=stop_mode=clone:stop_duration=2,trim=duration=1,setpts=PTS-STARTPTS[v_c3];[2:a]asetpts=PTS-STARTPTS,aresample=48000,aformat=sample_fmts=fltp:sample_rates=48000:channel_layouts=stereo,apad,atrim=duration=1,asetpts=PTS-STARTPTS[a_c3];[3:v]crop=w=608:h=1080:x=656:y=0,scale=1080:1920,setsar=1,setpts=(PTS-STARTPTS)/1,fps=30,format=yuv420p,tpad=stop_mode=clone:stop_duration=2,trim=duration=1,setpts=PTS-STARTPTS[v_c4];anullsrc=r=48000:cl=stereo,aformat=sample_fmts=fltp:sample_rates=48000:channel_layouts=stereo,apad,atrim=duration=1,asetpts=PTS-STARTPTS[a_c4];[v_c1][v_c2]xfade=transition=fade:duration=0.5:offset=1.5[xv1];[a_c1][a_c2]acrossfade=d=0.5:c1=tri:c2=tri[xa2];color=c=black:s=1080x1920:r=30,fps=30,format=yuv420p,tpad=stop_mode=clone:stop_duration=2,trim=duration=0.5,setpts=PTS-STARTPTS[gv3];anullsrc=r=48000:cl=stereo,aformat=sample_fmts=fltp:sample_rates=48000:channel_layouts=stereo,apad,atrim=duration=0.5,asetpts=PTS-STARTPTS[ga4];[xv1][xa2][gv3][ga4][v_c3][a_c3][v_c4][a_c4]concat=n=4:v=1:a=1[cv5][ca6];[cv5]format=yuv420p[vout];[4:a]asetpts=PTS-STARTPTS,aresample=48000,aformat=sample_fmts=fltp:sample_rates=48000:channel_layouts=stereo,volume=-8dB,afade=t=in:st=0:d=0.5,afade=t=out:st=7:d=1[ac_bed];[5:a]asetpts=PTS-STARTPTS,aresample=48000,aformat=sample_fmts=fltp:sample_rates=48000:channel_layouts=stereo,adelay=1000|1000[ac_vo];[ca6][ac_vo]amix=inputs=2:duration=first:normalize=0[speech];[speech]asplit=2[speech_mix][speech_key];[ac_bed]anull[duck_bus];[duck_bus][speech_key]sidechaincompress=threshold=0.03:ratio=8:attack=20:release=400:makeup=1[ducked];[speech_mix][ducked]amix=inputs=2:duration=first:normalize=0[mixed];[mixed]loudnorm=I=-16:TP=-1:LRA=11:linear=true,aresample=48000,aformat=sample_fmts=fltp:sample_rates=48000:channel_layouts=stereo[aout]",
+ "-map",
+ "[vout]",
+ "-map",
+ "[aout]",
+ "-c:v",
+ "libx264",
+ "-preset",
+ "veryfast",
+ "-crf",
+ "28",
+ "-profile:v",
+ "high",
+ "-maxrate",
+ "3000k",
+ "-bufsize",
+ "6000k",
+ "-pix_fmt",
+ "yuv420p",
+ "-colorspace",
+ "bt709",
+ "-color_primaries",
+ "bt709",
+ "-color_trc",
+ "bt709",
+ "-color_range",
+ "tv",
+ "-bsf:v",
+ "filter_units=remove_types=6",
+ "-r",
+ "30",
+ "-c:a",
+ "aac",
+ "-b:a",
+ "128k",
+ "-ar",
+ "48000",
+ "-ac",
+ "2",
+ "-map_metadata",
+ "-1",
+ "-map_chapters",
+ "-1",
+ "-fflags",
+ "+bitexact",
+ "-flags:v",
+ "+bitexact",
+ "-flags:a",
+ "+bitexact",
+ "-t",
+ "8",
+ "-movflags",
+ "+faststart",
+ "/work/render-1/output.mp4",
+ ],
+ "durationMs": 8000,
+ "id": "render",
+ "kind": "ffmpeg",
+ "produces": [
+ "/work/render-1/output.mp4",
+ ],
+ },
+ {
+ "argv": [
+ "-hide_banner",
+ "-nostdin",
+ "-y",
+ "-ss",
+ "2.667",
+ "-i",
+ "/work/render-1/output.mp4",
+ "-frames:v",
+ "1",
+ "-map_metadata",
+ "-1",
+ "-fflags",
+ "+bitexact",
+ "-flags:v",
+ "+bitexact",
+ "-q:v",
+ "2",
+ "/work/render-1/poster.jpg",
+ ],
+ "durationMs": 0,
+ "id": "poster",
+ "kind": "ffmpeg",
+ "produces": [
+ "/work/render-1/poster.jpg",
+ ],
+ },
+ ],
+ "workDir": "/work/render-1",
+}
+`;
+
+exports[`compileRenderPlan > compiles voice disguise, metadata stripping, intro/outro and text overlays 1`] = `
+{
+ "outputs": [
+ {
+ "kind": "video",
+ "path": "/work/render-1/output.mp4",
+ },
+ {
+ "kind": "poster",
+ "path": "/work/render-1/poster.jpg",
+ },
+ ],
+ "quality": "final",
+ "steps": [
+ {
+ "fps": 30,
+ "height": 1920,
+ "id": "motion-1",
+ "kind": "motion",
+ "outPath": "/work/render-1/motion-1.mov",
+ "template": {
+ "durationMs": 1000,
+ "params": {},
+ "templateId": "outro-simple",
+ },
+ "width": 1080,
+ },
+ {
+ "fps": 30,
+ "height": 1920,
+ "id": "motion-2",
+ "kind": "motion",
+ "outPath": "/work/render-1/motion-2.mov",
+ "template": {
+ "durationMs": 1000,
+ "params": {},
+ "templateId": "lower-third-a",
+ },
+ "width": 1080,
+ },
+ {
+ "argv": [
+ "-hide_banner",
+ "-nostdin",
+ "-y",
+ "-ss",
+ "0",
+ "-t",
+ "3",
+ "-i",
+ "/media/asset-a/proxy.mp4",
+ "-i",
+ "/work/render-1/intro.mov",
+ "-i",
+ "/work/render-1/motion-1.mov",
+ "-i",
+ "/work/render-1/motion-2.mov",
+ "-filter_complex",
+ "[0:v]deshake,crop=w=406:h=720:x=if(lt(t\\,0)\\,373\\,if(lt(t\\,3)\\,373+(768)*(t-0)/3\\,1141)):y=if(lt(t\\,0)\\,180\\,if(lt(t\\,3)\\,180+(0)*(t-0)/3\\,180)),scale=1080:1920,setsar=1,setpts=(PTS-STARTPTS)/1,eq=brightness=0.05:contrast=1:saturation=1.1,colorbalance=rm=0.02:bm=-0.02,lut3d=file=/brand/look\\\\\\'s.cube,fps=30,format=yuv420p,tpad=stop_mode=clone:stop_duration=2,trim=duration=3,setpts=PTS-STARTPTS[v_c1];[0:a]asetpts=PTS-STARTPTS,aresample=48000,aformat=sample_fmts=fltp:sample_rates=48000:channel_layouts=stereo,apad,atrim=duration=3,asetpts=PTS-STARTPTS[a_c1];[a_c1]rubberband=pitch=0.793701:formant=preserved,aformat=sample_fmts=fltp:sample_rates=48000:channel_layouts=stereo,apad,atrim=duration=3,asetpts=PTS-STARTPTS[vd1];[1:v]scale=1080:1920:force_original_aspect_ratio=decrease:force_divisible_by=2,pad=1080:1920:(ow-iw)/2:(oh-ih)/2:color=black,setsar=1,fps=30,format=yuv420p,tpad=stop_mode=clone:stop_duration=2,trim=duration=1.5,setpts=PTS-STARTPTS[intro_v];anullsrc=r=48000:cl=stereo,aformat=sample_fmts=fltp:sample_rates=48000:channel_layouts=stereo,apad,atrim=duration=1.5,asetpts=PTS-STARTPTS[intro_a];[2:v]scale=1080:1920:force_original_aspect_ratio=decrease:force_divisible_by=2,pad=1080:1920:(ow-iw)/2:(oh-ih)/2:color=black,setsar=1,fps=30,format=yuv420p,tpad=stop_mode=clone:stop_duration=2,trim=duration=1,setpts=PTS-STARTPTS[outro_v];anullsrc=r=48000:cl=stereo,aformat=sample_fmts=fltp:sample_rates=48000:channel_layouts=stereo,apad,atrim=duration=1,asetpts=PTS-STARTPTS[outro_a];[intro_v][intro_a][v_c1][vd1][outro_v][outro_a]concat=n=3:v=1:a=1[prog_v][prog_a];[3:v]format=yuva420p,scale=1080:1920,setpts=PTS-STARTPTS+0/TB[mo2];[prog_v][mo2]overlay=x=0:y=0:eof_action=pass:enable=between(t\\,0\\,1)[ov3];color=c=0xFFAA00:s=1080x10:r=30:d=5.5[pb4];[ov3][pb4]overlay=x=-overlay_w+main_w*min(1\\,(t-1.5)/3):y=1504:enable=between(t\\,1.5\\,4.5)[ov5];[ov5]drawtext=fontfile=/brand/fonts/ExampleSans.ttf:text=Ana\\\\\\'s\\\\: \\[studio\\]\\; 100\\\\\\\\% real:fontsize=77:fontcolor=0xFFFFFF:x=(w-text_w)/2:y=h-text_h-560:box=1:boxcolor=0x112233@0.85:boxborderw=31:enable=between(t\\,2\\,3.5)[ov6];[ov6]format=yuv420p[vout];[prog_a]loudnorm=I=-14:TP=-1:LRA=11:linear=true,aresample=48000,aformat=sample_fmts=fltp:sample_rates=48000:channel_layouts=stereo[aout]",
+ "-map",
+ "[vout]",
+ "-map",
+ "[aout]",
+ "-c:v",
+ "libx264",
+ "-preset",
+ "medium",
+ "-crf",
+ "20",
+ "-profile:v",
+ "high",
+ "-maxrate",
+ "8000k",
+ "-bufsize",
+ "16000k",
+ "-pix_fmt",
+ "yuv420p",
+ "-colorspace",
+ "bt709",
+ "-color_primaries",
+ "bt709",
+ "-color_trc",
+ "bt709",
+ "-color_range",
+ "tv",
+ "-bsf:v",
+ "filter_units=remove_types=6",
+ "-r",
+ "30",
+ "-c:a",
+ "aac",
+ "-b:a",
+ "192k",
+ "-ar",
+ "48000",
+ "-ac",
+ "2",
+ "-map_metadata",
+ "-1",
+ "-map_chapters",
+ "-1",
+ "-fflags",
+ "+bitexact",
+ "-flags:v",
+ "+bitexact",
+ "-flags:a",
+ "+bitexact",
+ "-t",
+ "5.5",
+ "-movflags",
+ "+faststart",
+ "/work/render-1/output.mp4",
+ ],
+ "durationMs": 5500,
+ "id": "render",
+ "kind": "ffmpeg",
+ "produces": [
+ "/work/render-1/output.mp4",
+ ],
+ },
+ {
+ "argv": [
+ "-hide_banner",
+ "-nostdin",
+ "-y",
+ "-ss",
+ "2.5",
+ "-i",
+ "/work/render-1/output.mp4",
+ "-frames:v",
+ "1",
+ "-map_metadata",
+ "-1",
+ "-fflags",
+ "+bitexact",
+ "-flags:v",
+ "+bitexact",
+ "-q:v",
+ "2",
+ "/work/render-1/poster.jpg",
+ ],
+ "durationMs": 0,
+ "id": "poster",
+ "kind": "ffmpeg",
+ "produces": [
+ "/work/render-1/poster.jpg",
+ ],
+ },
+ ],
+ "workDir": "/work/render-1",
+}
+`;
diff --git a/packages/media/src/clustering.test.ts b/packages/media/src/clustering.test.ts
new file mode 100644
index 0000000..c2e333d
--- /dev/null
+++ b/packages/media/src/clustering.test.ts
@@ -0,0 +1,206 @@
+import { describe, expect, it } from "vitest";
+import type { ClusterAsset } from "./clustering.js";
+import {
+ buildSessions,
+ calendarDay,
+ clusterFaces,
+ clusterThemes,
+ ruleLabel,
+} from "./clustering.js";
+
+const at = (iso: string) => new Date(iso);
+
+function asset(
+ id: string,
+ captured: string | null,
+ extra: Partial = {},
+): ClusterAsset {
+ return {
+ id,
+ kind: "photo",
+ capturedAt: captured ? at(captured) : null,
+ gpsLat: null,
+ gpsLon: null,
+ deviceModel: null,
+ albumNames: [],
+ embedding: null,
+ ...extra,
+ };
+}
+
+const vec = (...values: number[]) => new Float32Array(values);
+
+describe("buildSessions", () => {
+ it("splits on gaps longer than 45 minutes and sorts by capture time", () => {
+ const sessions = buildSessions([
+ asset("c", "2026-05-01T10:40:00Z"),
+ asset("a", "2026-05-01T10:00:00Z"),
+ asset("b", "2026-05-01T10:20:00Z"),
+ asset("d", "2026-05-01T11:26:00Z"),
+ ]);
+ expect(sessions.map((s) => s.assetIds)).toEqual([["a", "b", "c"], ["d"]]);
+ expect(sessions[0]!.key).toBe("session:a");
+ expect(sessions[0]!.startAt).toEqual(at("2026-05-01T10:00:00Z"));
+ expect(sessions[0]!.endAt).toEqual(at("2026-05-01T10:40:00Z"));
+ });
+
+ it("keeps a gap of exactly 45 minutes in one session", () => {
+ const sessions = buildSessions([
+ asset("a", "2026-05-01T10:00:00Z"),
+ asset("b", "2026-05-01T10:45:00Z"),
+ ]);
+ expect(sessions).toHaveLength(1);
+ });
+
+ it("puts undated assets into one trailing session", () => {
+ const sessions = buildSessions([
+ asset("z", null),
+ asset("a", "2026-05-01T10:00:00Z"),
+ asset("m", null),
+ asset("bad", null, { capturedAt: new Date(Number.NaN) }),
+ ]);
+ expect(sessions.map((s) => s.key)).toEqual(["session:a", "session:undated"]);
+ expect(sessions[1]!.assetIds).toEqual(["bad", "m", "z"]);
+ expect(sessions[1]!.startAt).toBeNull();
+ });
+
+ it("ignores duplicate ids and mixes photos and videos", () => {
+ const sessions = buildSessions([
+ asset("a", "2026-05-01T10:00:00Z"),
+ asset("v", "2026-05-01T10:05:00Z", { kind: "video" }),
+ asset("a", "2026-05-01T23:00:00Z"),
+ asset("l", "2026-05-01T10:06:00Z", { kind: "live_photo" }),
+ ]);
+ expect(sessions.map((s) => s.assetIds)).toEqual([["a", "v", "l"]]);
+ });
+
+ it("splits on a location jump over 2 km and reports a rounded centroid", () => {
+ const sessions = buildSessions([
+ asset("a", "2026-05-01T10:00:00Z", { gpsLat: 10.0, gpsLon: 20.0 }),
+ asset("b", "2026-05-01T10:05:00Z"),
+ asset("c", "2026-05-01T10:10:00Z", { gpsLat: 10.004, gpsLon: 20.004 }),
+ asset("d", "2026-05-01T10:15:00Z", { gpsLat: 10.1, gpsLon: 20.0 }),
+ ]);
+ expect(sessions.map((s) => s.assetIds)).toEqual([["a", "b", "c"], ["d"]]);
+ expect(sessions[0]!.centroid).toEqual({ lat: 10, lon: 20 });
+ expect(sessions[1]!.centroid).toEqual({ lat: 10.1, lon: 20 });
+ });
+
+ it("breaks on a calendar-day change only with a gap over 3 hours, in the given time zone", () => {
+ const assets = [asset("a", "2026-05-01T22:30:00Z"), asset("b", "2026-05-02T02:00:00Z")];
+ // Long gap rule disabled so only the day-break rule decides.
+ const options = { gapMinutes: 24 * 60 };
+ expect(buildSessions(assets, options)).toHaveLength(2);
+ // In UTC-10 both are on 1 May: one session.
+ expect(buildSessions(assets, { ...options, timeZone: "Pacific/Honolulu" })).toHaveLength(1);
+ // Crossing midnight with a short gap stays one session.
+ expect(
+ buildSessions(
+ [asset("x", "2026-05-01T23:50:00Z"), asset("y", "2026-05-02T00:20:00Z")],
+ options,
+ ),
+ ).toHaveLength(1);
+ expect(calendarDay(at("2026-05-01T22:30:00Z"), "Europe/Berlin")).toBe("2026-05-02");
+ });
+
+ it("merges neighbours with similar embeddings and a gap under 2 hours", () => {
+ const red = vec(1, 0, 0);
+ const blue = vec(0, 1, 0);
+ const sessions = buildSessions([
+ asset("a", "2026-05-01T10:00:00Z", { embedding: red }),
+ asset("b", "2026-05-01T11:00:00Z", { embedding: vec(0.95, 0.05, 0) }),
+ asset("c", "2026-05-01T12:00:00Z", { embedding: blue }),
+ asset("d", "2026-05-01T15:00:00Z", { embedding: blue }),
+ ]);
+ expect(sessions.map((s) => s.assetIds)).toEqual([["a", "b"], ["c"], ["d"]]);
+ });
+
+ it("is deterministic for any input order", () => {
+ const assets = [
+ asset("a", "2026-05-01T10:00:00Z"),
+ asset("b", "2026-05-01T10:00:00Z"),
+ asset("c", "2026-05-02T10:00:00Z"),
+ asset("d", null),
+ ];
+ const forward = buildSessions(assets);
+ const reversed = buildSessions([...assets].reverse());
+ expect(reversed).toEqual(forward);
+ expect(forward[0]!.assetIds).toEqual(["a", "b"]);
+ });
+});
+
+describe("clusterThemes and clusterFaces", () => {
+ const items = [
+ { id: "p3", vector: vec(0.9, 0.1, 0) },
+ { id: "p1", vector: vec(1, 0, 0) },
+ { id: "q1", vector: vec(0, 1, 0) },
+ { id: "p2", vector: vec(0.95, 0.05, 0) },
+ { id: "q2", vector: vec(0.05, 0.95, 0) },
+ { id: "r1", vector: vec(0, 0, 1) },
+ ];
+
+ it("groups by average linkage with stable keys, independent of input order", () => {
+ const themes = clusterThemes(items);
+ expect(themes).toEqual([
+ { key: "theme:p1", ids: ["p1", "p2", "p3"] },
+ { key: "theme:q1", ids: ["q1", "q2"] },
+ { key: "theme:r1", ids: ["r1"] },
+ ]);
+ expect(clusterThemes([...items].reverse())).toEqual(themes);
+ expect(clusterThemes(items, { minSize: 2 }).map((t) => t.key)).toEqual([
+ "theme:p1",
+ "theme:q1",
+ ]);
+ expect(clusterThemes(items, { similarity: -1 })).toHaveLength(1);
+ expect(clusterThemes([])).toEqual([]);
+ });
+
+ it("uses average rather than single linkage", () => {
+ // b is close to a and c, but a and c are far apart: average linkage keeps c out.
+ const chain = [
+ { id: "a", vector: vec(1, 0) },
+ { id: "b", vector: vec(Math.cos(0.4), Math.sin(0.4)) },
+ { id: "c", vector: vec(Math.cos(1.0), Math.sin(1.0)) },
+ ];
+ expect(clusterThemes(chain, { similarity: 0.8 }).map((g) => g.ids)).toEqual([
+ ["a", "b"],
+ ["c"],
+ ]);
+ });
+
+ it("returns face groups with a mean centroid", () => {
+ const faces = clusterFaces([
+ { id: "t1", vector: vec(1, 0) },
+ { id: "t2", vector: vec(0.8, 0.2) },
+ { id: "t3", vector: vec(0, 1) },
+ ]);
+ expect(faces.map((f) => f.key)).toEqual(["person:t1", "person:t3"]);
+ expect(Array.from(faces[0]!.centroid)).toEqual([0.8999999761581421, 0.10000000149011612]);
+ expect(() =>
+ clusterFaces([
+ { id: "x", vector: vec(1) },
+ { id: "y", vector: vec(1, 2) },
+ ]),
+ ).toThrow();
+ });
+});
+
+describe("ruleLabel", () => {
+ it("combines the dominant album with a locale-aware date range", () => {
+ const label = ruleLabel(
+ {
+ startAt: at("2026-05-01T10:00:00Z"),
+ endAt: at("2026-05-03T10:00:00Z"),
+ albumNames: ["Trip", "Favorites", "Trip"],
+ },
+ "en-US",
+ );
+ expect(label.startsWith("Trip · ")).toBe(true);
+ expect(label).toMatch(/May 1\D+3, 2026/);
+ expect(
+ ruleLabel({ startAt: at("2026-05-01T10:00:00Z"), endAt: null, albumNames: [] }, "de-DE"),
+ ).toBe("01.05.2026");
+ expect(ruleLabel({ startAt: null, endAt: null, albumNames: [" "] })).toBe("");
+ expect(ruleLabel({ startAt: null, endAt: null, albumNames: ["B", "A"] })).toBe("A");
+ });
+});
diff --git a/packages/media/src/clustering.ts b/packages/media/src/clustering.ts
index 59b696e..31bf572 100644
--- a/packages/media/src/clustering.ts
+++ b/packages/media/src/clustering.ts
@@ -1,5 +1,5 @@
import type { MediaAssetKind } from "@rakazo/contracts";
-import { notImplemented } from "./not-implemented.js";
+import { cosineSimilarity, haversineKm, roundCoordinate } from "./geo.js";
export interface ClusterAsset {
id: string;
@@ -46,15 +46,157 @@ export interface SessionCluster {
centroid: { lat: number; lon: number } | null;
}
+const MINUTE_MS = 60_000;
+const HOUR_MS = 60 * MINUTE_MS;
+
+const dayFormatters = new Map();
+
+/** Calendar day (YYYY-MM-DD) of an instant in an IANA time zone. */
+export function calendarDay(date: Date, timeZone: string): string {
+ let formatter = dayFormatters.get(timeZone);
+ if (!formatter) {
+ formatter = new Intl.DateTimeFormat("en-CA", {
+ timeZone,
+ year: "numeric",
+ month: "2-digit",
+ day: "2-digit",
+ });
+ dayFormatters.set(timeZone, formatter);
+ }
+ return formatter.format(date);
+}
+
+type DatedAsset = ClusterAsset & { capturedAt: Date };
+type PositionedAsset = ClusterAsset & { gpsLat: number; gpsLon: number };
+
+function hasPosition(asset: ClusterAsset): asset is PositionedAsset {
+ return (
+ asset.gpsLat !== null &&
+ asset.gpsLon !== null &&
+ Number.isFinite(asset.gpsLat) &&
+ Number.isFinite(asset.gpsLon)
+ );
+}
+
+function isDated(asset: ClusterAsset): asset is DatedAsset {
+ return asset.capturedAt !== null && Number.isFinite(asset.capturedAt.getTime());
+}
+
+/** Mean of the given vectors, or null when none are present or dimensions disagree. */
+export function meanVector(vectors: ArrayLike[]): Float32Array | null {
+ const first = vectors[0];
+ if (!first) return null;
+ const sum = new Float64Array(first.length);
+ for (const vector of vectors) {
+ if (vector.length !== first.length) return null;
+ for (let i = 0; i < vector.length; i++) sum[i] = sum[i]! + (vector[i] as number);
+ }
+ const mean = new Float32Array(first.length);
+ for (let i = 0; i < sum.length; i++) mean[i] = sum[i]! / vectors.length;
+ return mean;
+}
+
+function sessionEmbedding(assets: ClusterAsset[]): Float32Array | null {
+ return meanVector(assets.flatMap((asset) => (asset.embedding ? [asset.embedding] : [])));
+}
+
+function finishSession(assets: ClusterAsset[], key: string): SessionCluster {
+ const dated = assets.filter(isDated);
+ const positioned = assets.filter(hasPosition);
+ const mean = (values: number[]) => values.reduce((sum, v) => sum + v, 0) / values.length;
+ return {
+ key,
+ assetIds: assets.map((asset) => asset.id),
+ startAt: dated[0]?.capturedAt ?? null,
+ endAt: dated.at(-1)?.capturedAt ?? null,
+ centroid:
+ positioned.length === 0
+ ? null
+ : {
+ lat: roundCoordinate(mean(positioned.map((a) => a.gpsLat))),
+ lon: roundCoordinate(mean(positioned.map((a) => a.gpsLon))),
+ },
+ };
+}
+
/**
* Deterministic session grouping (spec 4.4). Assets without capturedAt form
* one trailing "undated" session; exact duplicates (same id) are ignored.
+ *
+ * A new session starts on a gap longer than gapMinutes, a jump of more than
+ * maxDistanceKm from the session's last known position, or a calendar-day
+ * change (in timeZone) with a gap longer than dayBreakHours. Adjacent sessions
+ * are then merged when their mean embeddings reach mergeSimilarity and the gap
+ * between them stays below mergeMaxGapHours. Keys derive from the earliest
+ * asset id, so they survive re-clustering as long as that asset stays first.
*/
export function buildSessions(
- _assets: ClusterAsset[],
- _options?: Partial,
+ assets: ClusterAsset[],
+ options?: Partial,
): SessionCluster[] {
- return notImplemented("buildSessions");
+ const opts = { ...DEFAULT_SESSION_OPTIONS, ...options };
+ const unique = new Map();
+ for (const asset of assets) if (!unique.has(asset.id)) unique.set(asset.id, asset);
+
+ const dated = [...unique.values()]
+ .filter(isDated)
+ .sort((a, b) => a.capturedAt.getTime() - b.capturedAt.getTime() || a.id.localeCompare(b.id));
+ const undated = [...unique.values()]
+ .filter((asset) => !isDated(asset))
+ .sort((a, b) => a.id.localeCompare(b.id));
+
+ const split: DatedAsset[][] = [];
+ let lastPosition: { lat: number; lon: number } | null = null;
+ for (const asset of dated) {
+ const current = split.at(-1);
+ const previous = current?.at(-1);
+ let startNew = true;
+ if (current && previous) {
+ const gap = asset.capturedAt.getTime() - previous.capturedAt.getTime();
+ const jumped =
+ hasPosition(asset) &&
+ lastPosition !== null &&
+ haversineKm(lastPosition, { lat: asset.gpsLat, lon: asset.gpsLon }) > opts.maxDistanceKm;
+ const dayChanged =
+ gap > opts.dayBreakHours * HOUR_MS &&
+ calendarDay(asset.capturedAt, opts.timeZone) !==
+ calendarDay(previous.capturedAt, opts.timeZone);
+ startNew = gap > opts.gapMinutes * MINUTE_MS || jumped || dayChanged;
+ }
+ if (startNew || !current) {
+ split.push([asset]);
+ lastPosition = null;
+ } else {
+ current.push(asset);
+ }
+ if (hasPosition(asset)) lastPosition = { lat: asset.gpsLat, lon: asset.gpsLon };
+ }
+
+ // Merge visually similar neighbours that are close in time.
+ const merged: DatedAsset[][] = [];
+ for (const session of split) {
+ const last = merged.at(-1);
+ if (last) {
+ const gap = session[0]!.capturedAt.getTime() - last.at(-1)!.capturedAt.getTime();
+ const a = sessionEmbedding(last);
+ const b = sessionEmbedding(session);
+ if (
+ a &&
+ b &&
+ a.length === b.length &&
+ gap < opts.mergeMaxGapHours * HOUR_MS &&
+ cosineSimilarity(a, b) >= opts.mergeSimilarity
+ ) {
+ last.push(...session);
+ continue;
+ }
+ }
+ merged.push([...session]);
+ }
+
+ const sessions = merged.map((group) => finishSession(group, `session:${group[0]!.id}`));
+ if (undated.length > 0) sessions.push(finishSession(undated, "session:undated"));
+ return sessions;
}
export interface VectorItem {
@@ -62,26 +204,144 @@ export interface VectorItem {
vector: Float32Array;
}
-/** Agglomerative average-linkage clustering; returns groups with stable keys. */
+/**
+ * Average-linkage agglomerative clustering cut at a similarity threshold,
+ * using the nearest-neighbour chain algorithm (O(n²) time and memory). Items
+ * are sorted by id first and ties resolve to the lower index, so the result
+ * does not depend on input order. Callers batch very large libraries.
+ */
+function agglomerate(items: VectorItem[], threshold: number): VectorItem[][] {
+ const unique = new Map();
+ for (const item of items) if (!unique.has(item.id)) unique.set(item.id, item);
+ const sorted = [...unique.values()].sort((a, b) => a.id.localeCompare(b.id));
+ const n = sorted.length;
+ if (n === 0) return [];
+ const dim = sorted[0]!.vector.length;
+ if (sorted.some((item) => item.vector.length !== dim)) throw new Error("vector length mismatch");
+
+ const sim = new Float64Array(n * n);
+ for (let i = 0; i < n; i++) {
+ for (let j = i + 1; j < n; j++) {
+ const value = cosineSimilarity(sorted[i]!.vector, sorted[j]!.vector);
+ sim[i * n + j] = value;
+ sim[j * n + i] = value;
+ }
+ }
+ const size = new Array(n).fill(1);
+ const members: number[][] = sorted.map((_, i) => [i]);
+ const active = new Set(members.map((_, i) => i));
+ const finished: number[][] = [];
+ const chain: number[] = [];
+
+ while (active.size > 0) {
+ if (chain.length === 0) chain.push(Math.min(...active));
+ const top = chain.at(-1)!;
+ const below = chain.length >= 2 ? chain.at(-2)! : -1;
+ let best = -1;
+ let bestSim = Number.NEGATIVE_INFINITY;
+ for (const j of active) {
+ if (j === top) continue;
+ const s = sim[top * n + j]!;
+ if (s > bestSim || (s === bestSim && j < best)) {
+ best = j;
+ bestSim = s;
+ }
+ }
+ // Prefer the chain predecessor on ties so the chain cannot cycle.
+ if (below >= 0 && sim[top * n + below]! === bestSim) best = below;
+ if (best < 0 || bestSim < threshold) {
+ // Average linkage is reducible: merges never raise similarity to this cluster again.
+ active.delete(top);
+ finished.push(members[top]!);
+ chain.length = 0;
+ continue;
+ }
+ if (best !== below) {
+ chain.push(best);
+ continue;
+ }
+ chain.length -= 2;
+ const a = Math.min(top, below);
+ const b = Math.max(top, below);
+ for (const k of active) {
+ if (k === a || k === b) continue;
+ const value =
+ (size[a]! * sim[a * n + k]! + size[b]! * sim[b * n + k]!) / (size[a]! + size[b]!);
+ sim[a * n + k] = value;
+ sim[k * n + a] = value;
+ }
+ size[a] = size[a]! + size[b]!;
+ members[a] = [...members[a]!, ...members[b]!];
+ active.delete(b);
+ }
+ return finished.map((group) => group.sort((x, y) => x - y).map((i) => sorted[i]!));
+}
+
+function sortGroups(groups: T[]): T[] {
+ return groups.sort((a, b) => b.ids.length - a.ids.length || a.key.localeCompare(b.key));
+}
+
+/**
+ * Agglomerative average-linkage clustering; returns groups with stable keys
+ * (from the smallest member id), largest first. Default similarity 0.8.
+ */
export function clusterThemes(
- _items: VectorItem[],
- _options?: { similarity?: number; minSize?: number },
+ items: VectorItem[],
+ options?: { similarity?: number; minSize?: number },
): Array<{ key: string; ids: string[] }> {
- return notImplemented("clusterThemes");
+ const minSize = options?.minSize ?? 1;
+ return sortGroups(
+ agglomerate(items, options?.similarity ?? 0.8)
+ .filter((group) => group.length >= minSize)
+ .map((group) => ({ key: `theme:${group[0]!.id}`, ids: group.map((item) => item.id) })),
+ );
}
-/** Group face tracks into person suggestions by identity embedding. Local only. */
+/** Group face tracks into person suggestions by identity embedding. Local only. Default similarity 0.6. */
export function clusterFaces(
- _tracks: VectorItem[],
- _options?: { similarity?: number },
+ tracks: VectorItem[],
+ options?: { similarity?: number },
): Array<{ key: string; ids: string[]; centroid: Float32Array }> {
- return notImplemented("clusterFaces");
+ return sortGroups(
+ agglomerate(tracks, options?.similarity ?? 0.6).map((group) => ({
+ key: `person:${group[0]!.id}`,
+ ids: group.map((item) => item.id),
+ centroid: meanVector(group.map((item) => item.vector))!,
+ })),
+ );
}
-/** Rule-based title from date range, place centroid, and dominant album. No model involved. */
+/**
+ * Rule-based title from the date range and dominant album. Places are not
+ * named because only rounded coordinates are stored. Returns "" when there is
+ * nothing to say, so the caller shows its own localized placeholder.
+ */
export function ruleLabel(
- _cluster: { startAt: Date | null; endAt: Date | null; albumNames: string[] },
- _locale?: string,
+ cluster: { startAt: Date | null; endAt: Date | null; albumNames: string[] },
+ locale?: string,
+ timeZone = "UTC",
): string {
- return notImplemented("ruleLabel");
+ const counts = new Map();
+ for (const raw of cluster.albumNames) {
+ const name = raw.trim();
+ if (name) counts.set(name, (counts.get(name) ?? 0) + 1);
+ }
+ const album = [...counts.entries()].sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))[0];
+
+ let dates = "";
+ const start = cluster.startAt ?? cluster.endAt;
+ const end = cluster.endAt ?? cluster.startAt;
+ if (start && end) {
+ let formatter: Intl.DateTimeFormat;
+ try {
+ formatter = new Intl.DateTimeFormat(locale, { dateStyle: "medium", timeZone });
+ } catch {
+ formatter = new Intl.DateTimeFormat("en", { dateStyle: "medium", timeZone });
+ }
+ dates =
+ calendarDay(start, timeZone) === calendarDay(end, timeZone)
+ ? formatter.format(start)
+ : formatter.formatRange(start, end);
+ }
+ return [album?.[0], dates].filter(Boolean).join(" · ");
}
diff --git a/packages/media/src/compile.ffmpeg.test.ts b/packages/media/src/compile.ffmpeg.test.ts
new file mode 100644
index 0000000..cd6051e
--- /dev/null
+++ b/packages/media/src/compile.ffmpeg.test.ts
@@ -0,0 +1,322 @@
+import { execFile, execFileSync } from "node:child_process";
+import { existsSync } from "node:fs";
+import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
+import { tmpdir } from "node:os";
+import path from "node:path";
+import { promisify } from "node:util";
+import type { RenderPlan } from "@rakazo/contracts";
+import { afterAll, beforeAll, describe, expect, it } from "vitest";
+import type { CompileInput } from "./compile.js";
+import { compileRenderPlan, escapeFilterText, escapeFilterValue } from "./compile.js";
+import { edlDurationMs } from "./edl.js";
+import { findMetadataLeaks } from "./gate.js";
+import { testEdl, testKit, testProfile } from "./test-fixtures.js";
+
+const run = promisify(execFile);
+
+function onPath(binary: string): boolean {
+ try {
+ execFileSync(binary, ["-version"], { stdio: "ignore" });
+ return true;
+ } catch {
+ return false;
+ }
+}
+
+const hasFfmpeg = onPath("ffmpeg") && onPath("ffprobe");
+const hasRubberband =
+ hasFfmpeg &&
+ execFileSync("ffmpeg", ["-hide_banner", "-filters"]).toString().includes(" rubberband ");
+
+async function ffmpeg(args: string[]): Promise {
+ await run("ffmpeg", ["-hide_banner", "-loglevel", "error", "-nostdin", "-y", ...args]);
+}
+
+async function execute(plan: RenderPlan): Promise {
+ for (const step of plan.steps) {
+ if (step.kind === "write_file") await writeFile(step.path, step.content);
+ else if (step.kind === "ffmpeg") await run("ffmpeg", ["-loglevel", "error", ...step.argv]);
+ else throw new Error(`unexpected ${step.kind} step`);
+ }
+}
+
+interface Probe {
+ durationMs: number;
+ width: number;
+ height: number;
+ tags: Record;
+}
+
+async function probe(file: string): Promise {
+ const { stdout } = await run("ffprobe", [
+ "-v",
+ "error",
+ "-show_format",
+ "-show_streams",
+ "-of",
+ "json",
+ file,
+ ]);
+ const json = JSON.parse(stdout) as {
+ format: { duration: string; tags?: Record };
+ streams: Array<{
+ codec_type: string;
+ width?: number;
+ height?: number;
+ tags?: Record;
+ }>;
+ };
+ const tags: Record = {};
+ for (const [key, value] of Object.entries(json.format.tags ?? {})) tags[`format.${key}`] = value;
+ json.streams.forEach((stream, index) => {
+ for (const [key, value] of Object.entries(stream.tags ?? {}))
+ tags[`stream.${index}.${key}`] = value;
+ });
+ const video = json.streams.find((s) => s.codec_type === "video");
+ return {
+ durationMs: Number(json.format.duration) * 1000,
+ width: video?.width ?? 0,
+ height: video?.height ?? 0,
+ tags,
+ };
+}
+
+describe.skipIf(!hasFfmpeg)("compiled plans run on ffmpeg", () => {
+ let dir: string;
+ let source: string;
+ let watermark: string;
+ let music: string;
+ let intro: string;
+ let lut: string;
+
+ beforeAll(async () => {
+ dir = await mkdtemp(path.join(tmpdir(), "media-compile-"));
+ source = path.join(dir, "source.mp4");
+ watermark = path.join(dir, "watermark.png");
+ music = path.join(dir, "music.wav");
+ intro = path.join(dir, "intro.mov");
+ lut = path.join(dir, "brand look.cube");
+ // Identity 2x2x2 LUT.
+ const lattice = [];
+ for (const b of [0, 1])
+ for (const g of [0, 1]) for (const r of [0, 1]) lattice.push(`${r} ${g} ${b}`);
+ await writeFile(lut, `LUT_3D_SIZE 2\n${lattice.join("\n")}\n`);
+ await ffmpeg([
+ "-f",
+ "lavfi",
+ "-i",
+ "testsrc2=size=480x270:rate=30:duration=8",
+ "-f",
+ "lavfi",
+ "-i",
+ "sine=frequency=440:duration=8",
+ "-c:v",
+ "libx264",
+ "-preset",
+ "ultrafast",
+ "-c:a",
+ "aac",
+ "-shortest",
+ // Tags a phone would write; the export must drop every one of them.
+ "-metadata",
+ "creation_time=2020-01-01T10:00:00Z",
+ "-metadata",
+ "location=+00.0000+000.0000/",
+ "-metadata",
+ "make=ExampleCam",
+ source,
+ ]);
+ await ffmpeg(["-f", "lavfi", "-i", "color=c=white:s=64x32", "-frames:v", "1", watermark]);
+ await ffmpeg(["-f", "lavfi", "-i", "sine=frequency=220:duration=6", music]);
+ await ffmpeg([
+ "-f",
+ "lavfi",
+ "-i",
+ "testsrc=size=320x180:rate=30:duration=1",
+ "-c:v",
+ "libx264",
+ "-preset",
+ "ultrafast",
+ intro,
+ ]);
+ });
+
+ afterAll(async () => {
+ if (dir) await rm(dir, { recursive: true, force: true });
+ });
+
+ const baseInput = (overrides: Partial): CompileInput => ({
+ edl: testEdl(),
+ profile: testProfile(),
+ brandKit: testKit(),
+ quality: "draft",
+ workDir: dir,
+ sources: new Map([
+ [
+ "asset-a",
+ {
+ path: source,
+ width: 480,
+ height: 270,
+ durationMs: 8000,
+ fps: 30,
+ hasAudio: true,
+ rotation: 0,
+ },
+ ],
+ ]),
+ transcripts: new Map([
+ [
+ "asset-a",
+ [
+ { startMs: 200, endMs: 600, text: "Wait" },
+ { startMs: 650, endMs: 1200, text: "for" },
+ { startMs: 1250, endMs: 1800, text: "this!" },
+ { startMs: 5100, endMs: 5600, text: "Here" },
+ { startMs: 5650, endMs: 6400, text: "it is." },
+ ],
+ ],
+ ]),
+ subjects: new Map([
+ [
+ "asset-a",
+ [
+ { tMs: 0, cx: 0.3, cy: 0.5, strength: 1 },
+ { tMs: 6000, cx: 0.7, cy: 0.5, strength: 1 },
+ ],
+ ],
+ ]),
+ motionClips: new Map(),
+ files: {
+ lutPath: null,
+ watermarkPath: watermark,
+ fonts: new Map(),
+ audio: new Map([["music-1", music]]),
+ },
+ videoEncoder: "libx264",
+ ...overrides,
+ });
+
+ it("renders 16:9 to 9:16 with auto-reframe, captions, watermark, ducked music and clean metadata", async () => {
+ const edl = testEdl({
+ tracks: {
+ video: [
+ { id: "c1", assetId: "asset-a", inMs: 0, outMs: 2000, atMs: 0, reframe: "auto" },
+ { id: "c2", assetId: "asset-a", inMs: 5000, outMs: 7000, atMs: 1500, reframe: "auto" },
+ ],
+ audio: [
+ {
+ id: "bed",
+ kind: "music",
+ artifactId: "music-1",
+ inMs: 0,
+ outMs: 3500,
+ atMs: 0,
+ gainDb: -6,
+ fadeOutMs: 500,
+ duckUnderSpeech: true,
+ },
+ ],
+ },
+ transitions: [{ fromClipId: "c1", toClipId: "c2", kind: "crossfade", durationMs: 500 }],
+ captions: { mode: "both" },
+ overlays: [{ id: "wm", kind: "watermark", atMs: 0, durationMs: 3500 }],
+ metadata: { title: "Example teaser" },
+ });
+ const plan = compileRenderPlan(baseInput({ edl }));
+ await execute(plan);
+ const video = plan.outputs.find((o) => o.kind === "video")!.path;
+ const result = await probe(video);
+ expect(result.width).toBe(1080);
+ expect(result.height).toBe(1920);
+ expect(Math.abs(result.durationMs - edlDurationMs(edl))).toBeLessThanOrEqual(1000 / 30 + 1);
+ const keys = Object.keys(result.tags).map((k) => k.toLowerCase());
+ expect(keys.some((k) => k.includes("creation_time") || k.includes("location"))).toBe(false);
+ expect(findMetadataLeaks(result.tags)).toEqual([]);
+ expect(result.tags["format.title"]).toBe("Example teaser");
+
+ const srt = await readFile(plan.outputs.find((o) => o.kind === "subtitles_srt")!.path, "utf8");
+ expect(srt).toContain("Wait for this!");
+ const poster = plan.outputs.find((o) => o.kind === "poster")!.path;
+ expect(existsSync(poster)).toBe(true);
+ expect((await probe(poster)).tags).toEqual({});
+ });
+
+ it("renders an intro, voice disguise and an escaped text overlay", async () => {
+ const edl = testEdl({
+ tracks: {
+ video: [
+ {
+ id: "c1",
+ assetId: "asset-a",
+ inMs: 1000,
+ outMs: 3000,
+ atMs: 0,
+ reframe: "none",
+ stabilize: true,
+ color: { exposure: 0.3, temperature: -0.4, saturation: 1.2, useBrandLut: true },
+ },
+ ],
+ },
+ intro: { templateId: "intro-minimal", durationMs: 1000 },
+ overlays: [
+ {
+ id: "t1",
+ kind: "text",
+ atMs: 1000,
+ durationMs: 1500,
+ text: "50% off: it's [0:v]; rm -rf / %{pts} \\ done",
+ box: { x: 0.1, y: 0.3, w: 0.8, h: 0.05 },
+ },
+ { id: "lt", kind: "lower_third", atMs: 1200, durationMs: 1000, text: "Lower third" },
+ { id: "pb", kind: "progress_bar", atMs: 1000, durationMs: 2000 },
+ ],
+ redactions: { voice: { method: hasRubberband ? "pitch" : "formant", semitones: 3 } },
+ metadata: {},
+ });
+ const plan = compileRenderPlan(
+ baseInput({
+ edl,
+ motionClips: new Map([["intro", intro]]),
+ quality: "final",
+ files: { ...baseInput({}).files, lutPath: lut },
+ }),
+ );
+ await execute(plan);
+ const result = await probe(plan.outputs[0]!.path);
+ expect(result.width).toBe(1080);
+ expect(Math.abs(result.durationMs - 3000)).toBeLessThanOrEqual(1000 / 30 + 1);
+ expect(findMetadataLeaks(result.tags)).toEqual([]);
+ expect(Object.keys(result.tags).some((k) => k.endsWith("title"))).toBe(false);
+ });
+
+ it("draws escaped text exactly like the literal text", async () => {
+ const text = "it's 50% [0:v]; a,b=c \\ %{pts} 'q'";
+ const literal = path.join(dir, "literal.txt");
+ await writeFile(literal, text);
+ const frame = async (filter: string) => {
+ const { stdout } = await run("ffmpeg", [
+ "-hide_banner",
+ "-loglevel",
+ "error",
+ "-f",
+ "lavfi",
+ "-i",
+ `color=c=black:s=320x80:d=0.1,${filter}`,
+ "-frames:v",
+ "1",
+ "-f",
+ "framemd5",
+ "-",
+ ]);
+ return stdout.split("\n").filter((line) => line && !line.startsWith("#"));
+ };
+ const escaped = await frame(`drawtext=text=${escapeFilterText(text)}:fontcolor=white:x=4:y=20`);
+ const reference = await frame(
+ `drawtext=expansion=none:textfile=${escapeFilterValue(literal)}:fontcolor=white:x=4:y=20`,
+ );
+ const blank = await frame("null");
+ expect(escaped).toEqual(reference);
+ expect(escaped).not.toEqual(blank);
+ });
+});
diff --git a/packages/media/src/compile.test.ts b/packages/media/src/compile.test.ts
new file mode 100644
index 0000000..1a14ebf
--- /dev/null
+++ b/packages/media/src/compile.test.ts
@@ -0,0 +1,576 @@
+import type { RenderPlan } from "@rakazo/contracts";
+import { RenderPlanSchema } from "@rakazo/contracts";
+import { describe, expect, it } from "vitest";
+import type { CaptionWord, CompileInput } from "./compile.js";
+import {
+ atempoChain,
+ autoReframe,
+ buildCaptionCues,
+ compileRenderPlan,
+ escapeFilterText,
+ escapeFilterValue,
+ outputCaptionWords,
+ reframeExcluding,
+ toAss,
+ toSrt,
+ toVtt,
+} from "./compile.js";
+import { testEdl, testKit, testProfile } from "./test-fixtures.js";
+
+const words: CaptionWord[] = [
+ { startMs: 200, endMs: 600, text: "Wait" },
+ { startMs: 650, endMs: 1200, text: "for" },
+ { startMs: 1250, endMs: 1800, text: "this!" },
+ { startMs: 5100, endMs: 5600, text: "Here" },
+ { startMs: 5650, endMs: 6400, text: "it is --> now." },
+];
+
+function input(overrides: Partial = {}): CompileInput {
+ return {
+ edl: testEdl(),
+ profile: testProfile(),
+ brandKit: testKit(),
+ quality: "final",
+ workDir: "/work/render-1",
+ sources: new Map([
+ [
+ "asset-a",
+ {
+ path: "/media/asset-a/proxy.mp4",
+ width: 1920,
+ height: 1080,
+ durationMs: 10_000,
+ fps: 30,
+ hasAudio: true,
+ rotation: 0,
+ },
+ ],
+ [
+ "asset-b",
+ {
+ path: "/media/asset-b/original.mov",
+ width: 1080,
+ height: 1920,
+ durationMs: 8000,
+ fps: 60,
+ hasAudio: false,
+ rotation: 90,
+ },
+ ],
+ ]),
+ transcripts: new Map([["asset-a", words]]),
+ subjects: new Map([
+ [
+ "asset-a",
+ [
+ { tMs: 0, cx: 0.2, cy: 0.5, strength: 1 },
+ { tMs: 1500, cx: 0.4, cy: 0.5, strength: 1 },
+ { tMs: 5500, cx: 0.8, cy: 0.4, strength: 0.5 },
+ ],
+ ],
+ ]),
+ motionClips: new Map(),
+ files: {
+ lutPath: "/brand/look's.cube",
+ watermarkPath: "/brand/watermark.png",
+ fonts: new Map([["font-1", "/brand/fonts/ExampleSans.ttf"]]),
+ audio: new Map([
+ ["music-1", "/brand/music/bed.wav"],
+ ["vo-1", "/work/render-1/voiceover.wav"],
+ ]),
+ },
+ videoEncoder: "libx264",
+ ...overrides,
+ };
+}
+
+function ffmpegStep(plan: RenderPlan, id = "render") {
+ const step = plan.steps.find((s) => s.id === id);
+ if (step?.kind !== "ffmpeg") throw new Error(`no ffmpeg step ${id}`);
+ return step;
+}
+
+function graphOf(plan: RenderPlan): string {
+ const argv = ffmpegStep(plan).argv;
+ return argv[argv.indexOf("-filter_complex") + 1]!;
+}
+
+describe("compileRenderPlan", () => {
+ it("compiles 16:9 to 9:16 with auto-reframe, captions, watermark and loudnorm", () => {
+ const plan = compileRenderPlan(
+ input({
+ edl: testEdl({
+ captions: { mode: "both", presetId: "clean" },
+ overlays: [{ id: "wm", kind: "watermark", atMs: 0, durationMs: 4000 }],
+ metadata: { title: "Example: teaser" },
+ }),
+ }),
+ );
+ expect(RenderPlanSchema.parse(plan)).toEqual(plan);
+ expect(plan).toMatchSnapshot();
+ const graph = graphOf(plan);
+ expect(graph).toContain("crop=w=608:h=1080:x=");
+ expect(graph).toContain("scale=1080:1920");
+ expect(graph).toContain("loudnorm=I=-14:TP=-1:LRA=11:linear=true");
+ expect(graph).toContain("colorchannelmixer=aa=0.6");
+ expect(graph).toContain("ass=filename=/work/render-1/captions.ass");
+ expect(plan.outputs.map((o) => o.kind)).toEqual([
+ "video",
+ "subtitles_srt",
+ "subtitles_vtt",
+ "poster",
+ ]);
+ const argv = ffmpegStep(plan).argv;
+ for (const flag of [
+ "-map_metadata",
+ "-map_chapters",
+ "+bitexact",
+ "+faststart",
+ "yuv420p",
+ "bt709",
+ ]) {
+ expect(argv).toContain(flag);
+ }
+ expect(argv).toContain("title=Example: teaser");
+ expect(argv.join(" ")).toContain("-c:a aac -b:a 192k -ar 48000");
+ });
+
+ it("compiles transitions, music ducking, voice-over and fades", () => {
+ const plan = compileRenderPlan(
+ input({
+ edl: testEdl({
+ tracks: {
+ video: [
+ { id: "c1", assetId: "asset-a", inMs: 0, outMs: 2000, atMs: 0, reframe: "none" },
+ {
+ id: "c2",
+ assetId: "asset-a",
+ inMs: 2000,
+ outMs: 4000,
+ atMs: 1500,
+ reframe: "none",
+ speed: 0.5,
+ },
+ {
+ id: "c3",
+ assetId: "asset-a",
+ inMs: 3000,
+ outMs: 4000,
+ atMs: 6000,
+ reframe: "none",
+ },
+ {
+ id: "c4",
+ assetId: "asset-b",
+ inMs: 4000,
+ outMs: 5000,
+ atMs: 7000,
+ reframe: "auto",
+ },
+ ],
+ audio: [
+ {
+ id: "bed",
+ kind: "music",
+ artifactId: "music-1",
+ inMs: 0,
+ outMs: 8000,
+ atMs: 0,
+ gainDb: -8,
+ fadeInMs: 500,
+ fadeOutMs: 1000,
+ duckUnderSpeech: true,
+ },
+ { id: "vo", kind: "voiceover", artifactId: "vo-1", inMs: 0, outMs: 2000, atMs: 1000 },
+ ],
+ },
+ transitions: [
+ { fromClipId: "c1", toClipId: "c2", kind: "crossfade", durationMs: 500 },
+ { fromClipId: "c2", toClipId: "c3", kind: "cut", durationMs: 0 },
+ { fromClipId: "c3", toClipId: "c4", kind: "whip", durationMs: 0 },
+ ],
+ targetLufs: -16,
+ }),
+ quality: "draft",
+ }),
+ );
+ expect(plan).toMatchSnapshot();
+ const graph = graphOf(plan);
+ expect(graph).toContain("xfade=transition=fade:duration=0.5:offset=1.5");
+ expect(graph).toContain("acrossfade=d=0.5");
+ expect(graph).toContain("atempo=0.5");
+ expect(graph).toContain("color=c=black:s=1080x1920"); // gap from 5.5 s to 6 s
+ expect(graph).toContain("sidechaincompress");
+ expect(graph).toContain("adelay=1000|1000");
+ expect(graph).toContain("loudnorm=I=-16");
+ expect(ffmpegStep(plan).argv).toEqual(expect.arrayContaining(["veryfast", "28", "3000k"]));
+ });
+
+ it("compiles voice disguise, metadata stripping, intro/outro and text overlays", () => {
+ const plan = compileRenderPlan(
+ input({
+ edl: testEdl({
+ intro: { templateId: "intro-minimal", durationMs: 1500 },
+ outro: { templateId: "outro-simple", durationMs: 1000 },
+ tracks: {
+ video: [
+ {
+ id: "c1",
+ assetId: "asset-a",
+ inMs: 0,
+ outMs: 3000,
+ atMs: 0,
+ reframe: [
+ { tMs: 0, cx: 0.3, cy: 0.5, scale: 1 },
+ { tMs: 3000, cx: 0.7, cy: 0.5, scale: 1.5 },
+ ],
+ stabilize: true,
+ color: { exposure: 0.5, temperature: 0.2, saturation: 1.1, useBrandLut: true },
+ },
+ ],
+ },
+ overlays: [
+ {
+ id: "lt",
+ kind: "lower_third",
+ atMs: 2000,
+ durationMs: 1500,
+ text: "Ana's: [studio]; 100% real",
+ },
+ {
+ id: "logo-anim",
+ kind: "motion",
+ atMs: 0,
+ durationMs: 1000,
+ motion: { templateId: "lower-third-a", durationMs: 1000 },
+ },
+ { id: "pb", kind: "progress_bar", atMs: 1500, durationMs: 3000 },
+ ],
+ redactions: { voice: { method: "pitch", semitones: -4 } },
+ metadata: {},
+ }),
+ motionClips: new Map([["intro", "/work/render-1/intro.mov"]]),
+ }),
+ );
+ expect(plan).toMatchSnapshot();
+ const graph = graphOf(plan);
+ expect(graph).toContain("rubberband=pitch=0.793701:formant=preserved");
+ expect(graph).toContain("deshake");
+ expect(graph).toContain("lut3d=file=/brand/look\\\\\\'s.cube");
+ expect(graph).toContain("concat=n=3:v=1:a=1[prog_v][prog_a]");
+ expect(plan.steps.filter((s) => s.kind === "motion").map((s) => s.id)).toEqual([
+ "motion-1",
+ "motion-2",
+ ]);
+ const argv = ffmpegStep(plan).argv;
+ expect(argv.some((a) => a.startsWith("title="))).toBe(false);
+ expect(argv).toEqual(expect.arrayContaining(["-map_metadata", "-1", "-map_chapters", "-1"]));
+ });
+
+ it("uses the asetrate chain for formant disguise and HEVC settings for HEVC profiles", () => {
+ const plan = compileRenderPlan(
+ input({
+ profile: testProfile({
+ video: {
+ aspectRatios: ["9:16"],
+ width: 1080,
+ height: 1920,
+ fps: 30,
+ codec: "hevc",
+ maxBitrateKbps: 6000,
+ maxFileSizeMb: 100,
+ },
+ export: { poster: false },
+ }),
+ edl: testEdl({ redactions: { voice: { method: "formant", semitones: 12 } }, metadata: {} }),
+ videoEncoder: "hevc_videotoolbox",
+ }),
+ );
+ expect(graphOf(plan)).toContain("asetrate=96000,aresample=48000,atempo=0.5");
+ const argv = ffmpegStep(plan).argv;
+ expect(argv).toEqual(expect.arrayContaining(["-tag:v", "hvc1", "-b:v", "4800k"]));
+ expect(argv).toContain("filter_units=remove_types=39|40");
+ expect(plan.outputs.map((o) => o.kind)).toEqual(["video"]);
+ });
+
+ it("only ever uses SafeId labels and keeps text out of argv", () => {
+ const plan = compileRenderPlan(
+ input({
+ edl: testEdl({
+ overlays: [
+ { id: "t1", kind: "text", atMs: 0, durationMs: 1000, text: "'; rm -rf / [0:v] %{pts}" },
+ ],
+ }),
+ }),
+ );
+ const graph = graphOf(plan);
+ for (const label of graph.matchAll(/\[([^\]\\]*)\]/g)) {
+ expect(label[1]).toMatch(/^[a-z0-9_:-]+$/);
+ }
+ expect(graph).toContain("text=\\\\\\'\\; rm -rf / \\[0\\\\:v\\] \\\\\\\\%{pts}");
+ expect(ffmpegStep(plan).argv.every((arg) => !arg.includes("\0"))).toBe(true);
+ });
+
+ it("refuses unsafe or impossible input instead of guessing", () => {
+ expect(() =>
+ compileRenderPlan(
+ input({
+ edl: {
+ ...testEdl(),
+ overlays: [{ id: "BAD;ID", kind: "text", atMs: 0, durationMs: 1, text: "x" }],
+ } as never,
+ }),
+ ),
+ ).toThrow();
+ expect(() => compileRenderPlan(input({ videoEncoder: "libx265" }))).toThrow(
+ /does not produce h264/,
+ );
+ expect(() => compileRenderPlan(input({ sources: new Map() }))).toThrow(/no source/);
+ expect(() =>
+ compileRenderPlan(
+ input({
+ edl: testEdl({ overlays: [{ id: "wm", kind: "watermark", atMs: 0, durationMs: 1000 }] }),
+ files: { ...input().files, watermarkPath: null },
+ }),
+ ),
+ ).toThrow(/watermark/);
+ });
+
+ it("moves very large filtergraphs into a script file", () => {
+ const video = Array.from({ length: 300 }, (_, i) => ({
+ id: `c${i}`,
+ assetId: "asset-a",
+ inMs: 0,
+ outMs: 100,
+ atMs: i * 100,
+ reframe: Array.from({ length: 8 }, (_, k) => ({
+ tMs: k * 12,
+ cx: 0.3 + k * 0.05,
+ cy: 0.5,
+ scale: 1,
+ })),
+ }));
+ const plan = compileRenderPlan(input({ edl: testEdl({ tracks: { video } }) }));
+ const script = plan.steps.find((s) => s.id === "filtergraph");
+ expect(script?.kind).toBe("write_file");
+ expect(ffmpegStep(plan).argv).toContain("-filter_complex_script");
+ });
+});
+
+describe("escaping", () => {
+ it("escapes every filtergraph and drawtext special character", () => {
+ expect(escapeFilterText("plain text")).toBe("plain text");
+ expect(escapeFilterText("a:b")).toBe("a\\\\:b");
+ expect(escapeFilterText("a,b;c")).toBe("a\\,b\\;c");
+ expect(escapeFilterText("[0:v]")).toBe("\\[0\\\\:v\\]");
+ expect(escapeFilterText("x=y")).toBe("x\\\\=y");
+ expect(escapeFilterText("it's")).toBe("it\\\\\\'s");
+ expect(escapeFilterText("100%")).toBe("100\\\\\\\\%");
+ expect(escapeFilterText("a\\b")).toBe("a\\\\\\\\\\\\\\\\b");
+ expect(escapeFilterText(" line one\r\nline two\u0007 ")).toBe("line one\nline two");
+ });
+
+ /** av_get_token: one parser level; returns the token and what it left unconsumed. */
+ function getToken(value: string, terms: string): { token: string; rest: string } {
+ let token = "";
+ let i = 0;
+ while (i < value.length && !terms.includes(value[i]!)) {
+ const char = value[i++]!;
+ if (char === "\\" && i < value.length) token += value[i++];
+ else if (char === "'") {
+ while (i < value.length && value[i] !== "'") token += value[i++];
+ i++;
+ } else token += char;
+ }
+ return { token, rest: value.slice(i) };
+ }
+
+ function drawtextExpand(value: string): string {
+ let out = "";
+ for (let i = 0; i < value.length; i++) {
+ if (value[i] === "\\" && i + 1 < value.length) out += value[++i];
+ else if (value[i] === "%") throw new Error("expansion sequence reached drawtext");
+ else out += value[i];
+ }
+ return out;
+ }
+
+ it("neutralizes injection attempts", () => {
+ const attempts = [
+ "'; rm -rf /",
+ "[0:v]",
+ "%{pts}",
+ "%{eif:1:d}",
+ "x:fontfile=/etc/passwd",
+ "a',drawtext=text='b",
+ "a\\",
+ "50% off; [in]null[out]",
+ ];
+ for (const attempt of attempts) {
+ const graphLevel = getToken(escapeFilterText(attempt), "[],;");
+ expect(graphLevel.rest).toBe("");
+ const optionLevel = getToken(graphLevel.token, ":");
+ expect(optionLevel.rest).toBe("");
+ expect(drawtextExpand(optionLevel.token)).toBe(attempt);
+ }
+ expect(escapeFilterText("%{pts}")).toBe("\\\\\\\\%{pts}");
+ expect(escapeFilterValue("C:\\media\\a,b.cube")).toBe("C\\\\:\\\\\\\\media\\\\\\\\a\\,b.cube");
+ expect(() => escapeFilterValue("a\0b")).toThrow();
+ });
+
+ it("chains atempo within its clean range", () => {
+ expect(atempoChain(1)).toEqual([]);
+ expect(atempoChain(0.25)).toEqual(["atempo=0.5", "atempo=0.5"]);
+ expect(atempoChain(3)).toEqual(["atempo=2", "atempo=1.5"]);
+ expect(atempoChain(4)).toEqual(["atempo=2", "atempo=2"]);
+ });
+});
+
+describe("captions", () => {
+ it("builds cues by line length, line count, pauses and sentence ends", () => {
+ const cues = buildCaptionCues(words, { maxCharsPerLine: 10, maxLines: 1 });
+ expect(cues.map((c) => c.text)).toEqual(["Wait for", "this!", "Here", "it is --> now."]);
+ const two = buildCaptionCues(words, { maxCharsPerLine: 10, maxLines: 2 });
+ expect(two[0]).toMatchObject({ startMs: 200, endMs: 1800, text: "Wait for\nthis!" });
+ expect(two[0]!.words).toHaveLength(3);
+ expect(
+ buildCaptionCues([{ startMs: 0, endMs: 0, text: " " }], { maxCharsPerLine: 10, maxLines: 2 }),
+ ).toEqual([]);
+ const short = buildCaptionCues([{ startMs: 1000, endMs: 1100, text: "Hi" }], {
+ maxCharsPerLine: 10,
+ maxLines: 2,
+ });
+ expect(short[0]!.endMs).toBe(1500);
+ });
+
+ it("formats SRT and VTT", () => {
+ const cues = buildCaptionCues(words, { maxCharsPerLine: 32, maxLines: 2 });
+ expect(toSrt(cues)).toBe(
+ "1\n00:00:00,200 --> 00:00:01,800\nWait for this!\n\n2\n00:00:05,100 --> 00:00:06,400\nHere it is -> now.\n",
+ );
+ expect(toVtt(cues)).toBe(
+ "WEBVTT\n\n00:00:00.200 --> 00:00:01.800\nWait for this!\n\n00:00:05.100 --> 00:00:06.400\nHere it <b>is</b> --> now.\n",
+ );
+ expect(toSrt([{ startMs: 3_723_004, endMs: 3_723_500, text: "a\n\nb", words: [] }])).toBe(
+ "1\n01:02:03,004 --> 01:02:03,500\na\nb\n",
+ );
+ });
+
+ it("maps source words to output time across speed and intro", () => {
+ const edl = testEdl({
+ intro: { templateId: "intro-minimal", durationMs: 1000 },
+ tracks: {
+ video: [
+ {
+ id: "c1",
+ assetId: "asset-a",
+ inMs: 1000,
+ outMs: 2000,
+ atMs: 0,
+ reframe: "none",
+ speed: 2,
+ },
+ {
+ id: "c2",
+ assetId: "asset-a",
+ inMs: 5000,
+ outMs: 6000,
+ atMs: 500,
+ reframe: "none",
+ muted: true,
+ },
+ ],
+ },
+ });
+ expect(outputCaptionWords(edl, new Map([["asset-a", words]]))).toEqual([
+ { startMs: 1125, endMs: 1400, text: "this!" },
+ ]);
+ });
+
+ it("writes ASS without override injection and with karaoke timing", () => {
+ const cues = buildCaptionCues(
+ [
+ { startMs: 0, endMs: 400, text: "{\\pos(0,0)}hi" },
+ { startMs: 600, endMs: 900, text: "you\\N" },
+ ],
+ { maxCharsPerLine: 32, maxLines: 2 },
+ );
+ const ass = toAss(cues, {
+ width: 1080,
+ height: 1920,
+ profile: testProfile(),
+ preset: {
+ ...testKit().captionPresets[0]!,
+ template: "karaoke",
+ highlightColor: "#FFAA00",
+ uppercase: true,
+ },
+ fallbackTextColor: "#FFFFFF",
+ position: "lower",
+ maxCharsPerLine: 32,
+ wordHighlight: true,
+ });
+ const dialogue = ass.split("\n").find((line) => line.startsWith("Dialogue:"))!;
+ expect(dialogue).toBe(
+ "Dialogue: 0,0:00:00.00,0:00:00.90,Default,,0,0,0,,{\\k40}\uFF5B\\\u2060POS(0,0)\uFF5DHI {\\k20}{\\k30}YOU\\\u2060N",
+ );
+ expect(ass).toContain("Style: Default,Example Sans,96,&H0000AAFF,&H00FFFFFF");
+ expect(ass).toContain(",2,92,168,422,1");
+ });
+});
+
+describe("reframing", () => {
+ const geometry = {
+ sourceWidth: 1920,
+ sourceHeight: 1080,
+ outWidth: 1080,
+ outHeight: 1920,
+ durationMs: 4000,
+ };
+
+ it("follows the subject smoothly and keeps the crop inside the frame", () => {
+ const path = autoReframe(
+ [
+ { tMs: 0, cx: 0, cy: 0.5, strength: 1 },
+ { tMs: 2000, cx: 0.5, cy: 0.5, strength: 1 },
+ { tMs: 4000, cx: 1, cy: 0.5, strength: 1 },
+ ],
+ geometry,
+ );
+ const half = 608 / 1920 / 2;
+ for (const k of path) {
+ expect(k.cx).toBeGreaterThanOrEqual(half - 1e-4);
+ expect(k.cx).toBeLessThanOrEqual(1 - half + 1e-4);
+ expect(k.cy).toBe(0.5);
+ expect(k.scale).toBe(1);
+ }
+ expect(path[0]!.tMs).toBe(0);
+ expect(path.at(-1)!.tMs).toBe(4000);
+ for (let i = 1; i < path.length; i++)
+ expect(path[i]!.cx).toBeGreaterThanOrEqual(path[i - 1]!.cx);
+ expect(path.length).toBeLessThanOrEqual(64);
+ expect(autoReframe([], geometry)).toEqual([{ tMs: 0, cx: 0.5, cy: 0.5, scale: 1 }]);
+ });
+
+ it("keeps faces out of the frame or reports that it cannot", () => {
+ const left = reframeExcluding(
+ [{ tMs: 100, box: { x: 0.05, y: 0.3, w: 0.15, h: 0.3 } }],
+ geometry,
+ );
+ expect(left).toHaveLength(1);
+ const k = left![0]!;
+ expect(k.tMs).toBe(100);
+ expect(k.scale).toBe(1);
+ expect(k.cx - 608 / 1920 / 2).toBeGreaterThanOrEqual(0.22 - 1e-6);
+ // Faces all over the frame: no crop can exclude them.
+ const everywhere = [0, 0.25, 0.5, 0.75].map((x) => ({
+ tMs: 0,
+ box: { x, y: 0, w: 0.25, h: 1 },
+ }));
+ expect(reframeExcluding(everywhere, geometry)).toBeNull();
+ // A face across the middle forces a tighter crop.
+ const wide = reframeExcluding([{ tMs: 0, box: { x: 0.2, y: 0.4, w: 0.6, h: 0.2 } }], geometry);
+ expect(wide![0]!.scale).toBeGreaterThan(1);
+ expect(reframeExcluding([], geometry)).toEqual([{ tMs: 0, cx: 0.5, cy: 0.5, scale: 1 }]);
+ });
+});
diff --git a/packages/media/src/compile.ts b/packages/media/src/compile.ts
index 913e831..ab16f88 100644
--- a/packages/media/src/compile.ts
+++ b/packages/media/src/compile.ts
@@ -1,23 +1,43 @@
import type {
BrandKit,
+ CaptionStylePreset,
Edl,
+ EdlAudioClip,
+ EdlOverlay,
+ EdlVideoClip,
MediaRenderQuality,
+ MotionTemplateRef,
NormalizedBox,
PlatformProfile,
ReframeKeyframe,
RenderPlan,
+ RenderStep,
} from "@rakazo/contracts";
-import { notImplemented } from "./not-implemented.js";
+import { EdlSchema } from "@rakazo/contracts";
+import {
+ BLENDED_TRANSITIONS,
+ clipLengthMs,
+ edlDurationMs,
+ introMs,
+ mainProgramMs,
+ sortedVideoClips,
+} from "./edl.js";
export interface CompileSource {
/** Redacted intermediate when redactions apply, otherwise original or proxy. */
path: string;
+ /**
+ * Coded size as stored. Rotation 90/270 swaps it for display, because
+ * ffmpeg auto-rotates while decoding.
+ */
width: number;
height: number;
durationMs: number;
fps: number;
hasAudio: boolean;
rotation: 0 | 90 | 180 | 270;
+ /** A still image (photo). Also assumed when durationMs is 0. */
+ still?: boolean;
}
export interface CaptionWord {
@@ -38,28 +58,358 @@ export interface CompileInput {
transcripts: Map;
/** Saliency or subject points per asset for "auto" reframe. */
subjects: Map>;
- /** Pre-rendered motion clips keyed by overlay id, "intro", "outro". */
+ /**
+ * Pre-rendered motion clips keyed by overlay id, "intro", "outro". Missing
+ * ones become `motion` steps that the renderer runs first.
+ */
motionClips: Map;
files: {
lutPath: string | null;
watermarkPath: string | null;
+ /** Brand-kit font artifact id (or family name) → font file. */
fonts: Map;
/** artifactId → absolute path for audio clips. */
audio: Map;
+ /** First brand-kit logo, for logo overlays without a motion clip. */
+ logoPath?: string | null;
};
/** ffmpeg video encoder name, e.g. libx264, h264_vaapi, h264_videotoolbox. */
videoEncoder: string;
+ /** Output time of the poster frame. Defaults to a third into the main program. */
+ posterAtMs?: number;
+}
+
+// ---------------------------------------------------------------------------
+// Escaping
+// ---------------------------------------------------------------------------
+
+/*
+ * A filtergraph value passes three parsers:
+ * 1. the graph parser splits filters on `[ ] , ;` and honours `\` and `'`,
+ * 2. the option parser splits options on `:` (and `=`), again with `\` and `'`,
+ * 3. drawtext expands `%{...}` and treats `\x` as a literal `x`.
+ * Each level is escaped with a backslash, innermost first, so any user text
+ * arrives verbatim and can never open a new option, filter, or expansion.
+ */
+const escapeDrawtextLevel = (value: string) => value.replace(/[\\%]/g, "\\$&");
+const escapeOptionLevel = (value: string) => value.replace(/[\\':=]/g, "\\$&");
+const escapeGraphLevel = (value: string) => value.replace(/[\\'[\],;]/g, "\\$&");
+
+/** Escape text for ffmpeg drawtext/filtergraph values. */
+export function escapeFilterText(text: string): string {
+ const normalized = text
+ .replace(/\r\n?/g, "\n")
+ // biome-ignore lint/suspicious/noControlCharactersInRegex: strip control characters on purpose
+ .replace(/[\u0000-\u0009\u000b-\u001f\u007f]/g, "")
+ .trim();
+ return escapeGraphLevel(escapeOptionLevel(escapeDrawtextLevel(normalized)));
+}
+
+/** Escape a file path or expression used as a filter option value. */
+export function escapeFilterValue(value: string): string {
+ if (value.includes("\0")) throw new Error("filter value contains NUL");
+ return escapeGraphLevel(escapeOptionLevel(value));
+}
+
+// ---------------------------------------------------------------------------
+// Small helpers
+// ---------------------------------------------------------------------------
+
+/** Fixed-point number without exponent notation. */
+function num(value: number, digits = 3): string {
+ const fixed = value.toFixed(digits);
+ return fixed.includes(".") ? fixed.replace(/\.?0+$/, "") || "0" : fixed;
+}
+
+const sec = (ms: number) => num(ms / 1000);
+
+function joinPath(dir: string, name: string): string {
+ return `${dir.replace(/[\\/]+$/, "")}/${name}`;
+}
+
+function dirName(file: string): string {
+ const index = Math.max(file.lastIndexOf("/"), file.lastIndexOf("\\"));
+ return index > 0 ? file.slice(0, index) : ".";
+}
+
+function brandColor(hex: string | undefined, fallback: string): string {
+ return hex ? `0x${hex.slice(1).toUpperCase()}` : fallback;
+}
+
+function even(value: number): number {
+ return Math.max(2, Math.floor(value / 2) * 2);
+}
+
+const clamp = (value: number, min: number, max: number) => Math.min(max, Math.max(min, value));
+
+function enableBetween(startMs: number, endMs: number): string {
+ return escapeFilterValue(`between(t,${sec(startMs)},${sec(endMs)})`);
+}
+
+/** atempo accepts 0.5..2 per instance for clean results; chain for the rest. */
+export function atempoChain(speed: number): string[] {
+ const factors: number[] = [];
+ let rest = speed;
+ while (rest > 2) {
+ factors.push(2);
+ rest /= 2;
+ }
+ while (rest < 0.5) {
+ factors.push(0.5);
+ rest /= 0.5;
+ }
+ if (Math.abs(rest - 1) > 1e-9 || factors.length === 0) factors.push(rest);
+ return factors.filter((f) => Math.abs(f - 1) > 1e-9).map((f) => `atempo=${num(f, 6)}`);
+}
+
+// ---------------------------------------------------------------------------
+// Reframing
+// ---------------------------------------------------------------------------
+
+interface FrameGeometry {
+ sourceWidth: number;
+ sourceHeight: number;
+ outWidth: number;
+ outHeight: number;
+ durationMs: number;
+}
+
+/** Largest crop with the output aspect inside the source, shrunk by scale. */
+function cropSize(geometry: FrameGeometry, scale: number): { w: number; h: number } {
+ const outAspect = geometry.outWidth / geometry.outHeight;
+ let w: number;
+ let h: number;
+ if (geometry.sourceWidth / geometry.sourceHeight > outAspect) {
+ h = geometry.sourceHeight;
+ w = h * outAspect;
+ } else {
+ w = geometry.sourceWidth;
+ h = w / outAspect;
+ }
+ const roundEven = (v: number) => Math.max(2, Math.round(v / 2) * 2);
+ return {
+ w: Math.min(geometry.sourceWidth, roundEven(w / scale)),
+ h: Math.min(geometry.sourceHeight, roundEven(h / scale)),
+ };
+}
+
+const AUTO_STEP_MS = 500;
+const AUTO_WINDOW_MS = 750;
+const AUTO_SMOOTHING = 0.35;
+const AUTO_TOLERANCE = 0.01;
+const MAX_KEYFRAMES = 64;
+
+function smooth(values: number[]): number[] {
+ const forward = [...values];
+ for (let i = 1; i < forward.length; i++) {
+ forward[i] = forward[i - 1]! + AUTO_SMOOTHING * (forward[i]! - forward[i - 1]!);
+ }
+ const backward = [...forward];
+ for (let i = backward.length - 2; i >= 0; i--) {
+ backward[i] = backward[i + 1]! + AUTO_SMOOTHING * (backward[i]! - backward[i + 1]!);
+ }
+ return backward;
+}
+
+/** Keep only keyframes that linear interpolation cannot reproduce, at most MAX_KEYFRAMES. */
+function simplify(keyframes: ReframeKeyframe[]): ReframeKeyframe[] {
+ if (keyframes.length <= 2) return keyframes;
+ const kept: ReframeKeyframe[] = [keyframes[0]!];
+ for (let i = 1; i < keyframes.length - 1; i++) {
+ const last = kept.at(-1)!;
+ const next = keyframes[i + 1]!;
+ const point = keyframes[i]!;
+ const f = (point.tMs - last.tMs) / (next.tMs - last.tMs);
+ const cx = last.cx + (next.cx - last.cx) * f;
+ const cy = last.cy + (next.cy - last.cy) * f;
+ if (Math.abs(cx - point.cx) > AUTO_TOLERANCE || Math.abs(cy - point.cy) > AUTO_TOLERANCE) {
+ kept.push(point);
+ }
+ }
+ kept.push(keyframes.at(-1)!);
+ if (kept.length <= MAX_KEYFRAMES) return kept;
+ const step = (kept.length - 1) / (MAX_KEYFRAMES - 1);
+ return Array.from({ length: MAX_KEYFRAMES }, (_, i) => kept[Math.round(i * step)]!);
}
/**
- * Pure compiler from a validated EDL to ffmpeg argv steps plus caption files.
- * No string ever reaches a shell; text is escaped for filtergraph and ASS,
- * every label comes from SafeId values.
+ * Smoothed crop path that keeps the subject in frame for the output aspect.
+ * Subjects are weighted by strength in a ±750 ms window every 500 ms, the
+ * path is smoothed forwards and backwards (no lag), clamped so the crop stays
+ * inside the source, and reduced to the keyframes that matter. Times are the
+ * subjects' time base, from 0 to durationMs.
*/
-export function compileRenderPlan(_input: CompileInput): RenderPlan {
- return notImplemented("compileRenderPlan");
+export function autoReframe(
+ subjects: Array<{ tMs: number; cx: number; cy: number; strength: number }>,
+ input: FrameGeometry,
+): ReframeKeyframe[] {
+ const crop = cropSize(input, 1);
+ const halfW = crop.w / input.sourceWidth / 2;
+ const halfH = crop.h / input.sourceHeight / 2;
+ const clampX = (x: number) => clamp(x, halfW, 1 - halfW);
+ const clampY = (y: number) => clamp(y, halfH, 1 - halfH);
+ const usable = subjects
+ .filter((s) => s.strength > 0 && Number.isFinite(s.cx) && Number.isFinite(s.cy))
+ .sort((a, b) => a.tMs - b.tMs);
+ if (usable.length === 0) return [{ tMs: 0, cx: clampX(0.5), cy: clampY(0.5), scale: 1 }];
+
+ const times: number[] = [];
+ for (let t = 0; t < input.durationMs; t += AUTO_STEP_MS) times.push(t);
+ times.push(Math.max(0, input.durationMs));
+ const xs: number[] = [];
+ const ys: number[] = [];
+ for (const t of times) {
+ let weight = 0;
+ let x = 0;
+ let y = 0;
+ for (const subject of usable) {
+ const distance = Math.abs(subject.tMs - t);
+ if (distance > AUTO_WINDOW_MS) continue;
+ const w = subject.strength * (1 - distance / (AUTO_WINDOW_MS * 2));
+ weight += w;
+ x += subject.cx * w;
+ y += subject.cy * w;
+ }
+ if (weight > 0) {
+ xs.push(x / weight);
+ ys.push(y / weight);
+ } else if (xs.length > 0) {
+ xs.push(xs.at(-1)!);
+ ys.push(ys.at(-1)!);
+ } else {
+ const nearest = usable.reduce((best, s) =>
+ Math.abs(s.tMs - t) < Math.abs(best.tMs - t) ? s : best,
+ );
+ xs.push(nearest.cx);
+ ys.push(nearest.cy);
+ }
+ }
+ const sx = smooth(xs);
+ const sy = smooth(ys);
+ const round = (v: number) => Math.round(v * 10_000) / 10_000;
+ return simplify(
+ times.map((tMs, i) => ({
+ tMs: Math.round(tMs),
+ cx: round(clampX(sx[i]!)),
+ cy: round(clampY(sy[i]!)),
+ scale: 1,
+ })),
+ );
}
+const REFRAME_SCALES = [1, 1.25, 1.5, 1.75, 2, 2.5, 3, 3.5, 4];
+const FACE_MARGIN = 0.02;
+
+/**
+ * Crop path that keeps every given face box outside the output frame, or
+ * null if impossible. The window is static for the whole shot (a moving
+ * window could sweep across a face between keyframes): it avoids the union of
+ * all face boxes plus a 2 % margin, prefers the widest crop, and then the
+ * position closest to the frame centre.
+ */
+export function reframeExcluding(
+ faces: Array<{ tMs: number; box: NormalizedBox }>,
+ input: FrameGeometry,
+): ReframeKeyframe[] | null {
+ const tMs = faces.reduce((min, face) => Math.min(min, face.tMs), Number.POSITIVE_INFINITY);
+ const boxes = faces.map(({ box }) => ({
+ x0: clamp(box.x - FACE_MARGIN, 0, 1),
+ y0: clamp(box.y - FACE_MARGIN, 0, 1),
+ x1: clamp(box.x + box.w + FACE_MARGIN, 0, 1),
+ y1: clamp(box.y + box.h + FACE_MARGIN, 0, 1),
+ }));
+ for (const scale of REFRAME_SCALES) {
+ const crop = cropSize(input, scale);
+ const w = crop.w / input.sourceWidth;
+ const h = crop.h / input.sourceHeight;
+ const xs = new Set([0.5, w / 2, 1 - w / 2]);
+ const ys = new Set([0.5, h / 2, 1 - h / 2]);
+ for (const b of boxes) {
+ xs.add(b.x0 - w / 2);
+ xs.add(b.x1 + w / 2);
+ ys.add(b.y0 - h / 2);
+ ys.add(b.y1 + h / 2);
+ }
+ const candidates: Array<{ cx: number; cy: number; distance: number }> = [];
+ for (const rawX of xs) {
+ for (const rawY of ys) {
+ const cx = clamp(rawX, w / 2, 1 - w / 2);
+ const cy = clamp(rawY, h / 2, 1 - h / 2);
+ const clear = boxes.every(
+ (b) =>
+ cx + w / 2 <= b.x0 + 1e-9 ||
+ cx - w / 2 >= b.x1 - 1e-9 ||
+ cy + h / 2 <= b.y0 + 1e-9 ||
+ cy - h / 2 >= b.y1 - 1e-9,
+ );
+ if (clear) candidates.push({ cx, cy, distance: Math.hypot(cx - 0.5, cy - 0.5) });
+ }
+ }
+ candidates.sort((a, b) => a.distance - b.distance || a.cx - b.cx || a.cy - b.cy);
+ const best = candidates[0];
+ if (best) {
+ const round = (v: number) => Math.round(v * 10_000) / 10_000;
+ return [
+ {
+ tMs: Number.isFinite(tMs) ? Math.round(tMs) : 0,
+ cx: round(best.cx),
+ cy: round(best.cy),
+ scale,
+ },
+ ];
+ }
+ }
+ return null;
+}
+
+/** Piecewise-linear expression of `t` (seconds) through the given points. */
+function piecewise(points: Array<{ t: number; v: number }>): string {
+ if (points.length === 1) return num(points[0]!.v, 2);
+ let expression = num(points.at(-1)!.v, 2);
+ for (let i = points.length - 2; i >= 0; i--) {
+ const a = points[i]!;
+ const b = points[i + 1]!;
+ const span = Math.max(b.t - a.t, 0.001);
+ const segment = `${num(a.v, 2)}+(${num(b.v - a.v, 2)})*(t-${num(a.t)})/${num(span)}`;
+ expression = `if(lt(t,${num(b.t)}),${segment},${expression})`;
+ }
+ return `if(lt(t,${num(points[0]!.t)}),${num(points[0]!.v, 2)},${expression})`;
+}
+
+/** crop+scale filters for keyframes in source time; uses the tightest scale of the path. */
+function cropFilters(
+ keyframes: ReframeKeyframe[],
+ clip: EdlVideoClip,
+ geometry: FrameGeometry,
+): string[] {
+ const scale = Math.max(...keyframes.map((k) => k.scale));
+ const crop = cropSize(geometry, scale);
+ const points = [...keyframes]
+ .sort((a, b) => a.tMs - b.tMs)
+ .map((k) => ({
+ t: Math.max(0, (k.tMs - clip.inMs) / 1000),
+ x: clamp(k.cx * geometry.sourceWidth - crop.w / 2, 0, geometry.sourceWidth - crop.w),
+ y: clamp(k.cy * geometry.sourceHeight - crop.h / 2, 0, geometry.sourceHeight - crop.h),
+ }));
+ const x = piecewise(points.map((p) => ({ t: p.t, v: p.x })));
+ const y = piecewise(points.map((p) => ({ t: p.t, v: p.y })));
+ return [
+ `crop=w=${crop.w}:h=${crop.h}:x=${escapeFilterValue(x)}:y=${escapeFilterValue(y)}`,
+ `scale=${geometry.outWidth}:${geometry.outHeight}`,
+ ];
+}
+
+function fitFilters(width: number, height: number): string[] {
+ return [
+ `scale=${width}:${height}:force_original_aspect_ratio=decrease:force_divisible_by=2`,
+ `pad=${width}:${height}:(ow-iw)/2:(oh-ih)/2:color=black`,
+ ];
+}
+
+// ---------------------------------------------------------------------------
+// Captions
+// ---------------------------------------------------------------------------
+
export interface CaptionCue {
startMs: number;
endMs: number;
@@ -68,50 +418,952 @@ export interface CaptionCue {
words: CaptionWord[];
}
+const CUE_PAUSE_MS = 1000;
+const CUE_MAX_MS = 7000;
+const CUE_MIN_MS = 500;
+
+function cleanWord(word: CaptionWord): CaptionWord | null {
+ const text = word.text.replace(/\s+/g, " ").trim();
+ if (!text || !(word.endMs >= word.startMs)) return null;
+ return { startMs: word.startMs, endMs: word.endMs, text };
+}
+
+/** Greedy line wrap; a single word longer than the limit gets its own line. */
+function wrapWords(words: CaptionWord[], maxChars: number): CaptionWord[][] {
+ const lines: CaptionWord[][] = [];
+ let line: CaptionWord[] = [];
+ let length = 0;
+ for (const word of words) {
+ const extra = line.length === 0 ? word.text.length : length + 1 + word.text.length;
+ if (line.length > 0 && extra > maxChars) {
+ lines.push(line);
+ line = [word];
+ length = word.text.length;
+ } else {
+ line.push(word);
+ length = extra;
+ }
+ }
+ if (line.length > 0) lines.push(line);
+ return lines;
+}
+
+/**
+ * Group words into cues of at most maxLines lines of maxCharsPerLine. A cue
+ * also ends at a pause over 1 s, after sentence punctuation, or at 7 s. Each
+ * cue is shown at least 500 ms unless the next one starts earlier.
+ */
export function buildCaptionCues(
- _words: CaptionWord[],
- _options: { maxCharsPerLine: number; maxLines: number },
+ words: CaptionWord[],
+ options: { maxCharsPerLine: number; maxLines: number },
): CaptionCue[] {
- return notImplemented("buildCaptionCues");
+ const clean = words
+ .map(cleanWord)
+ .filter((w): w is CaptionWord => w !== null)
+ .sort((a, b) => a.startMs - b.startMs || a.endMs - b.endMs);
+ const groups: CaptionWord[][] = [];
+ let current: CaptionWord[] = [];
+ for (const word of clean) {
+ const last = current.at(-1);
+ const first = current[0];
+ const breaks =
+ last !== undefined &&
+ first !== undefined &&
+ (word.startMs - last.endMs > CUE_PAUSE_MS ||
+ /[.!?…]["»”)]?$/.test(last.text) ||
+ word.endMs - first.startMs > CUE_MAX_MS ||
+ wrapWords([...current, word], options.maxCharsPerLine).length > options.maxLines);
+ if (breaks) {
+ groups.push(current);
+ current = [];
+ }
+ current.push(word);
+ }
+ if (current.length > 0) groups.push(current);
+
+ return groups.map((group, index) => {
+ const startMs = group[0]!.startMs;
+ const next = groups[index + 1]?.[0]?.startMs ?? Number.POSITIVE_INFINITY;
+ const spoken = Math.max(...group.map((w) => w.endMs));
+ const endMs = Math.max(spoken, Math.min(startMs + CUE_MIN_MS, next));
+ return {
+ startMs,
+ endMs: Math.max(endMs, startMs + 1),
+ text: wrapWords(group, options.maxCharsPerLine)
+ .map((line) => line.map((w) => w.text).join(" "))
+ .join("\n"),
+ words: group,
+ };
+ });
}
-export function toSrt(_cues: CaptionCue[]): string {
- return notImplemented("toSrt");
+function clock(ms: number, separator: "," | "."): string {
+ const total = Math.max(0, Math.round(ms));
+ const hours = Math.floor(total / 3_600_000);
+ const minutes = Math.floor((total % 3_600_000) / 60_000);
+ const seconds = Math.floor((total % 60_000) / 1000);
+ const millis = total % 1000;
+ const pad = (v: number, n = 2) => String(v).padStart(n, "0");
+ return `${pad(hours)}:${pad(minutes)}:${pad(seconds)}${separator}${pad(millis, 3)}`;
}
-export function toVtt(_cues: CaptionCue[]): string {
- return notImplemented("toVtt");
+/** Cue text without blank lines, which would end a cue early in both formats. */
+function cueLines(text: string): string {
+ return text
+ .split("\n")
+ .map((line) => line.trim())
+ .filter(Boolean)
+ .join("\n");
}
-/** Smoothed crop path that keeps the subject in frame for the output aspect. */
-export function autoReframe(
- _subjects: Array<{ tMs: number; cx: number; cy: number; strength: number }>,
- _input: {
- sourceWidth: number;
- sourceHeight: number;
- outWidth: number;
- outHeight: number;
- durationMs: number;
+export function toSrt(cues: CaptionCue[]): string {
+ return cues
+ .map(
+ (cue, index) =>
+ `${index + 1}\n${clock(cue.startMs, ",")} --> ${clock(cue.endMs, ",")}\n${cueLines(
+ cue.text,
+ ).replace(/-->/g, "->")}\n`,
+ )
+ .join("\n");
+}
+
+export function toVtt(cues: CaptionCue[]): string {
+ const escapeCue = (text: string) =>
+ text.replace(/&/g, "&").replace(//g, ">");
+ return `WEBVTT\n\n${cues
+ .map(
+ (cue) =>
+ `${clock(cue.startMs, ".")} --> ${clock(cue.endMs, ".")}\n${escapeCue(cueLines(cue.text))}\n`,
+ )
+ .join("\n")}`;
+}
+
+/** ASS colour &HAABBGGRR from #RRGGBB. */
+function assColor(hex: string, alpha = 0): string {
+ const r = hex.slice(1, 3);
+ const g = hex.slice(3, 5);
+ const b = hex.slice(5, 7);
+ return `&H${alpha.toString(16).padStart(2, "0")}${b}${g}${r}`.toUpperCase();
+}
+
+function assTime(ms: number): string {
+ const total = Math.max(0, Math.round(ms / 10));
+ const hours = Math.floor(total / 360_000);
+ const minutes = Math.floor((total % 360_000) / 6000);
+ const seconds = Math.floor((total % 6000) / 100);
+ const centis = total % 100;
+ const pad = (v: number) => String(v).padStart(2, "0");
+ return `${hours}:${pad(minutes)}:${pad(seconds)}.${pad(centis)}`;
+}
+
+/** Plain text for an ASS event: no override blocks, no escape sequences. */
+function assText(text: string): string {
+ return text.replace(/\\/g, "\\").replace(/\{/g, "{").replace(/\}/g, "}");
+}
+
+export function toAss(
+ cues: CaptionCue[],
+ options: {
+ width: number;
+ height: number;
+ profile: PlatformProfile;
+ preset: CaptionStylePreset | null;
+ fallbackTextColor: string;
+ position: "lower" | "middle" | "upper";
+ maxCharsPerLine: number;
+ wordHighlight: boolean;
+ language?: string;
},
-): ReframeKeyframe[] {
- return notImplemented("autoReframe");
+): string {
+ const { width, height, profile, preset } = options;
+ const zones = profile.safeZones;
+ const margin = Math.round(height * 0.02);
+ const alignment = { lower: 2, middle: 5, upper: 8 }[options.position];
+ const marginV =
+ options.position === "upper"
+ ? Math.round((zones.topPct / 100) * height) + margin
+ : options.position === "lower"
+ ? Math.round((zones.bottomPct / 100) * height) + margin
+ : 0;
+ const marginL = Math.round((zones.leftPct / 100) * width) + margin;
+ const marginR = Math.round((zones.rightPct / 100) * width) + margin;
+ const textColor = preset?.textColor ?? options.fallbackTextColor;
+ const highlight = preset?.highlightColor ?? textColor;
+ const karaoke = options.wordHighlight || preset?.template === "karaoke";
+ const bold = preset?.template === "bold_social";
+ const fontName = (preset?.fontFamily ?? "Sans").replace(/[,\r\n]/g, " ");
+ const fontSize = Math.round(((preset?.sizePct ?? 5) / 100) * height);
+ const background = preset?.backgroundColor;
+ const style = [
+ "Default",
+ fontName,
+ fontSize,
+ assColor(karaoke ? highlight : textColor),
+ assColor(textColor),
+ background ? assColor(background) : "&H00000000",
+ background ? assColor(background, 0x40) : "&H80000000",
+ bold ? -1 : 0,
+ 0,
+ 0,
+ 0,
+ 100,
+ 100,
+ 0,
+ 0,
+ background ? 3 : 1,
+ bold ? 4 : 2,
+ 0,
+ alignment,
+ marginL,
+ marginR,
+ marginV,
+ 1,
+ ].join(",");
+ const transform = (text: string) =>
+ preset?.uppercase ? text.toLocaleUpperCase(options.language) : text;
+ const events = cues.map((cue) => {
+ let body: string;
+ if (karaoke && cue.words.length > 0) {
+ let cursor = cue.startMs;
+ body = wrapWords(cue.words, options.maxCharsPerLine)
+ .map((line) =>
+ line
+ .map((word) => {
+ const gap = Math.max(0, Math.round((word.startMs - cursor) / 10));
+ const length = Math.max(1, Math.round((word.endMs - word.startMs) / 10));
+ cursor = Math.max(cursor, word.endMs);
+ return `${gap > 0 ? `{\\k${gap}}` : ""}{\\k${length}}${assText(transform(word.text))}`;
+ })
+ .join(" "),
+ )
+ .join("\\N");
+ } else {
+ body = cueLines(transform(cue.text)).split("\n").map(assText).join("\\N");
+ }
+ return `Dialogue: 0,${assTime(cue.startMs)},${assTime(cue.endMs)},Default,,0,0,0,,${body}`;
+ });
+ return [
+ "[Script Info]",
+ "ScriptType: v4.00+",
+ `PlayResX: ${width}`,
+ `PlayResY: ${height}`,
+ "WrapStyle: 2",
+ "ScaledBorderAndShadow: yes",
+ "",
+ "[V4+ Styles]",
+ "Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding",
+ `Style: ${style}`,
+ "",
+ "[Events]",
+ "Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text",
+ ...events,
+ "",
+ ].join("\n");
}
-/** Crop path that keeps every given face box outside the output frame, or null if impossible. */
-export function reframeExcluding(
- _faces: Array<{ tMs: number; box: NormalizedBox }>,
- _input: {
- sourceWidth: number;
- sourceHeight: number;
- outWidth: number;
- outHeight: number;
- durationMs: number;
- },
-): ReframeKeyframe[] | null {
- return notImplemented("reframeExcluding");
+/** Caption words on the output timeline, from each unmuted clip's transcript. */
+export function outputCaptionWords(
+ edl: Edl,
+ transcripts: Map,
+): CaptionWord[] {
+ const offset = introMs(edl);
+ const words: CaptionWord[] = [];
+ for (const clip of sortedVideoClips(edl)) {
+ if (clip.muted) continue;
+ for (const word of transcripts.get(clip.assetId) ?? []) {
+ const start = Math.max(word.startMs, clip.inMs);
+ const end = Math.min(word.endMs, clip.outMs);
+ if (word.startMs < clip.inMs || start >= clip.outMs || end < start) continue;
+ words.push({
+ startMs: Math.round(offset + clip.atMs + (start - clip.inMs) / clip.speed),
+ endMs: Math.round(offset + clip.atMs + (end - clip.inMs) / clip.speed),
+ text: word.text,
+ });
+ }
+ }
+ return words;
}
-/** Escape text for ffmpeg drawtext/filtergraph values. */
-export function escapeFilterText(_text: string): string {
- return notImplemented("escapeFilterText");
+// ---------------------------------------------------------------------------
+// Compiler
+// ---------------------------------------------------------------------------
+
+const AUDIO_FORMAT = "aformat=sample_fmts=fltp:sample_rates=48000:channel_layouts=stereo";
+const XFADE_KIND = { crossfade: "fade", dip: "fadeblack", whip: "slideleft" } as const;
+const MAX_INLINE_GRAPH = 60_000;
+
+function encoderFamily(encoder: string): "h264" | "hevc" | null {
+ if (/^(libx264|h264_)/.test(encoder)) return "h264";
+ if (/^(libx265|hevc_)/.test(encoder)) return "hevc";
+ return null;
+}
+
+function encodingArgs(input: CompileInput): string[] {
+ const { profile, quality, videoEncoder } = input;
+ const family = encoderFamily(videoEncoder);
+ if (family !== profile.video.codec) {
+ throw new Error(`encoder ${videoEncoder} does not produce ${profile.video.codec}`);
+ }
+ const draft = quality === "draft";
+ const maxKbps = draft
+ ? Math.min(profile.video.maxBitrateKbps, 3000)
+ : profile.video.maxBitrateKbps;
+ const args = ["-c:v", videoEncoder];
+ if (videoEncoder === "libx264" || videoEncoder === "libx265") {
+ args.push("-preset", draft ? "veryfast" : "medium", "-crf", draft ? "28" : "20");
+ if (videoEncoder === "libx264") args.push("-profile:v", "high");
+ } else {
+ args.push("-b:v", `${Math.round(maxKbps * 0.8)}k`);
+ }
+ args.push("-maxrate", `${maxKbps}k`, "-bufsize", `${maxKbps * 2}k`);
+ if (!videoEncoder.endsWith("_vaapi")) args.push("-pix_fmt", "yuv420p");
+ args.push(
+ "-colorspace",
+ "bt709",
+ "-color_primaries",
+ "bt709",
+ "-color_trc",
+ "bt709",
+ "-color_range",
+ "tv",
+ );
+ // Drop SEI messages, which carry encoder name, version and settings.
+ args.push(
+ "-bsf:v",
+ family === "h264" ? "filter_units=remove_types=6" : "filter_units=remove_types=39|40",
+ );
+ if (family === "hevc") args.push("-tag:v", "hvc1");
+ args.push("-r", num(input.edl.output.fps, 3));
+ args.push("-c:a", "aac", "-b:a", draft ? "128k" : "192k", "-ar", "48000", "-ac", "2");
+ return args;
+}
+
+/** Strip every inherited tag, chapter, and encoder string; only the plan's title is written. */
+function metadataArgs(title: string | undefined): string[] {
+ const args = [
+ "-map_metadata",
+ "-1",
+ "-map_chapters",
+ "-1",
+ "-fflags",
+ "+bitexact",
+ "-flags:v",
+ "+bitexact",
+ "-flags:a",
+ "+bitexact",
+ ];
+ const clean = title?.replace(/[\0\r\n]+/g, " ").trim();
+ if (clean) args.push("-metadata", `title=${clean}`);
+ return args;
+}
+
+class Inputs {
+ readonly args: string[] = [];
+ private count = 0;
+ add(...args: string[]): number {
+ if (args.some((arg) => arg.includes("\0"))) throw new Error("argument contains NUL");
+ this.args.push(...args);
+ return this.count++;
+ }
+}
+
+function captionPreset(edl: Edl, kit: BrandKit | null): CaptionStylePreset | null {
+ const presets = kit?.captionPresets ?? [];
+ return presets.find((p) => p.id === edl.captions?.presetId) ?? presets[0] ?? null;
+}
+
+function pickFont(kit: BrandKit | null, fonts: Map, role: string): string | null {
+ const preferred = kit?.fonts.find((font) => font.role === role) ?? kit?.fonts[0];
+ if (preferred) {
+ const path = fonts.get(preferred.artifactId) ?? fonts.get(preferred.family);
+ if (path) return path;
+ }
+ return fonts.values().next().value ?? null;
+}
+
+/**
+ * Pure compiler from a validated EDL to ffmpeg argv steps plus caption files.
+ * No string ever reaches a shell; text is escaped for filtergraph and ASS,
+ * every label comes from SafeId values.
+ *
+ * Loudness: a single loudnorm pass with linear=true targets the profile (or
+ * plan) loudness and true peak. Without measured values loudnorm runs in its
+ * dynamic mode; a renderer may run a measurement pass first and append the
+ * measured_* options to that filter.
+ *
+ * Face, mark, and region redactions are applied before compilation: sources
+ * already point at redacted intermediates.
+ */
+export function compileRenderPlan(input: CompileInput): RenderPlan {
+ const edl = EdlSchema.parse(input.edl);
+ const { profile, brandKit: kit, workDir } = input;
+ const W = edl.output.width;
+ const H = edl.output.height;
+ const F = edl.output.fps;
+ const totalMs = edlDurationMs(edl);
+ const intro = introMs(edl);
+ const mainMs = mainProgramMs(edl);
+ const steps: RenderStep[] = [];
+ const outputs: RenderPlan["outputs"] = [];
+ const inputs = new Inputs();
+ const graph: string[] = [];
+ let motionCount = 0;
+
+ const motionInput = (key: string, template: MotionTemplateRef, width: number, height: number) => {
+ let path = input.motionClips.get(key);
+ if (!path) {
+ motionCount += 1;
+ path = joinPath(workDir, `motion-${motionCount}.mov`);
+ steps.push({
+ kind: "motion",
+ id: `motion-${motionCount}`,
+ template,
+ width,
+ height,
+ fps: F,
+ outPath: path,
+ });
+ }
+ return inputs.add("-i", path);
+ };
+
+ const finishVideo = (lengthMs: number) => [
+ `fps=${num(F, 3)}`,
+ "format=yuv420p",
+ "tpad=stop_mode=clone:stop_duration=2",
+ `trim=duration=${sec(lengthMs)}`,
+ "setpts=PTS-STARTPTS",
+ ];
+ const finishAudio = (lengthMs: number) => [
+ AUDIO_FORMAT,
+ "apad",
+ `atrim=duration=${sec(lengthMs)}`,
+ "asetpts=PTS-STARTPTS",
+ ];
+ const silence = (label: string, lengthMs: number) =>
+ graph.push(`anullsrc=r=48000:cl=stereo,${finishAudio(lengthMs).join(",")}[${label}]`);
+
+ // Per-clip chains.
+ interface Part {
+ v: string;
+ a: string;
+ lengthMs: number;
+ }
+ const clipParts = new Map();
+ for (const clip of sortedVideoClips(edl)) {
+ const source = input.sources.get(clip.assetId);
+ if (!source) throw new Error(`no source for asset in clip ${clip.id}`);
+ const still = source.still === true || source.durationMs === 0;
+ const sourceLength = clip.outMs - clip.inMs;
+ const lengthMs = clipLengthMs(clip);
+ const index = still
+ ? inputs.add(
+ "-loop",
+ "1",
+ "-framerate",
+ num(F, 3),
+ "-t",
+ sec(sourceLength),
+ "-i",
+ source.path,
+ )
+ : inputs.add("-ss", sec(clip.inMs), "-t", sec(sourceLength), "-i", source.path);
+ const rotated = source.rotation === 90 || source.rotation === 270;
+ const geometry: FrameGeometry = {
+ sourceWidth: rotated ? source.height : source.width,
+ sourceHeight: rotated ? source.width : source.height,
+ outWidth: W,
+ outHeight: H,
+ durationMs: sourceLength,
+ };
+ const video: string[] = [];
+ if (clip.stabilize && !still) video.push("deshake");
+ if (clip.reframe === "none") {
+ video.push(...fitFilters(W, H));
+ } else {
+ const keyframes =
+ clip.reframe === "auto"
+ ? autoReframe(
+ (input.subjects.get(clip.assetId) ?? [])
+ .filter(
+ (s) =>
+ s.tMs >= clip.inMs - AUTO_WINDOW_MS && s.tMs <= clip.outMs + AUTO_WINDOW_MS,
+ )
+ .map((s) => ({ ...s, tMs: s.tMs - clip.inMs })),
+ geometry,
+ ).map((k) => ({ ...k, tMs: k.tMs + clip.inMs }))
+ : clip.reframe;
+ video.push(...cropFilters(keyframes, clip, geometry));
+ }
+ video.push("setsar=1", `setpts=(PTS-STARTPTS)/${num(clip.speed, 6)}`);
+ const color = clip.color;
+ if (color) {
+ const brightness = clamp(color.exposure * 0.1, -1, 1);
+ if (brightness !== 0 || color.contrast !== 1 || color.saturation !== 1) {
+ video.push(
+ `eq=brightness=${num(brightness)}:contrast=${num(color.contrast)}:saturation=${num(color.saturation)}`,
+ );
+ }
+ if (color.temperature !== 0) {
+ const shift = num(color.temperature * 0.1);
+ const inverse = num(-color.temperature * 0.1);
+ video.push(`colorbalance=rm=${shift}:bm=${inverse}`);
+ }
+ if (color.useBrandLut) {
+ if (!input.files.lutPath) throw new Error(`clip ${clip.id} needs the brand LUT file`);
+ video.push(`lut3d=file=${escapeFilterValue(input.files.lutPath)}`);
+ }
+ }
+ video.push(...finishVideo(lengthMs));
+ graph.push(`[${index}:v]${video.join(",")}[v_${clip.id}]`);
+
+ if (source.hasAudio && !clip.muted && !still) {
+ const audio = ["asetpts=PTS-STARTPTS", ...atempoChain(clip.speed), "aresample=48000"];
+ graph.push(`[${index}:a]${[...audio, ...finishAudio(lengthMs)].join(",")}[a_${clip.id}]`);
+ } else {
+ silence(`a_${clip.id}`, lengthMs);
+ }
+ clipParts.set(clip.id, { v: `v_${clip.id}`, a: `a_${clip.id}`, lengthMs });
+ }
+
+ // Main program: concatenate runs, blend where transitions ask for it, fill gaps with black.
+ const blends = new Map(
+ edl.transitions
+ .filter((t) => BLENDED_TRANSITIONS.has(t.kind) && t.durationMs > 0)
+ .map((t) => [`${t.fromClipId}>${t.toClipId}`, t] as const),
+ );
+ let mixCount = 0;
+ const nextLabel = (prefix: string) => `${prefix}${++mixCount}`;
+ let accumulated: Part | null = null;
+ let queue: Part[] = [];
+ const flush = () => {
+ const parts = accumulated ? [accumulated, ...queue] : queue;
+ queue = [];
+ if (parts.length <= 1) {
+ accumulated = parts[0] ?? accumulated;
+ return;
+ }
+ const v = nextLabel("cv");
+ const a = nextLabel("ca");
+ graph.push(
+ `${parts.map((p) => `[${p.v}][${p.a}]`).join("")}concat=n=${parts.length}:v=1:a=1[${v}][${a}]`,
+ );
+ accumulated = { v, a, lengthMs: parts.reduce((sum, p) => sum + p.lengthMs, 0) };
+ };
+ let cursor = 0;
+ let previous: EdlVideoClip | null = null;
+ for (const clip of sortedVideoClips(edl)) {
+ const part = clipParts.get(clip.id)!;
+ const blend = previous ? blends.get(`${previous.id}>${clip.id}`) : undefined;
+ if (blend) {
+ flush();
+ const base = accumulated as Part | null;
+ if (!base) throw new Error("transition without a preceding clip");
+ const v = nextLabel("xv");
+ const a = nextLabel("xa");
+ const kind = XFADE_KIND[blend.kind as keyof typeof XFADE_KIND];
+ graph.push(
+ `[${base.v}][${part.v}]xfade=transition=${kind}:duration=${sec(blend.durationMs)}:offset=${sec(
+ base.lengthMs - blend.durationMs,
+ )}[${v}]`,
+ `[${base.a}][${part.a}]acrossfade=d=${sec(blend.durationMs)}:c1=tri:c2=tri[${a}]`,
+ );
+ accumulated = { v, a, lengthMs: base.lengthMs + part.lengthMs - blend.durationMs };
+ } else {
+ const gap = clip.atMs - cursor;
+ if (gap > 1) {
+ const v = nextLabel("gv");
+ const a = nextLabel("ga");
+ graph.push(`color=c=black:s=${W}x${H}:r=${num(F, 3)},${finishVideo(gap).join(",")}[${v}]`);
+ silence(a, gap);
+ queue.push({ v, a, lengthMs: gap });
+ }
+ queue.push(part);
+ }
+ cursor = Math.max(cursor, clip.atMs + part.lengthMs);
+ previous = clip;
+ }
+ flush();
+ const main = accumulated as Part | null;
+ if (!main) throw new Error("plan has no video clips");
+
+ let mainAudio = main.a;
+ const voice = edl.redactions.voice;
+ if (voice) {
+ const ratio = 2 ** (voice.semitones / 12);
+ const label = nextLabel("vd");
+ const filter =
+ voice.method === "pitch"
+ ? `rubberband=pitch=${num(ratio, 6)}:formant=preserved`
+ : `asetrate=${num(48000 * ratio, 2)},aresample=48000,atempo=${num(1 / ratio, 6)}`;
+ graph.push(`[${mainAudio}]${filter},${finishAudio(main.lengthMs).join(",")}[${label}]`);
+ mainAudio = label;
+ }
+
+ // Intro and outro.
+ const program: Part[] = [];
+ const bookend = (key: "intro" | "outro", ref: MotionTemplateRef | undefined) => {
+ if (!ref || ref.durationMs <= 0) return;
+ const index = motionInput(key, ref, W, H);
+ const v = `${key}_v`;
+ const a = `${key}_a`;
+ graph.push(
+ `[${index}:v]${[...fitFilters(W, H), "setsar=1", ...finishVideo(ref.durationMs)].join(",")}[${v}]`,
+ );
+ silence(a, ref.durationMs);
+ program.push({ v, a, lengthMs: ref.durationMs });
+ };
+ bookend("intro", edl.intro);
+ program.push({ v: main.v, a: mainAudio, lengthMs: main.lengthMs });
+ bookend("outro", edl.outro);
+ let videoLabel = main.v;
+ let programAudio = mainAudio;
+ if (program.length > 1) {
+ videoLabel = "prog_v";
+ programAudio = "prog_a";
+ graph.push(
+ `${program.map((p) => `[${p.v}][${p.a}]`).join("")}concat=n=${program.length}:v=1:a=1[prog_v][prog_a]`,
+ );
+ }
+
+ // Overlays on the output timeline.
+ const zones = profile.safeZones;
+ const margin = Math.round(W * 0.02);
+ const safeLeft = Math.round((zones.leftPct / 100) * W) + margin;
+ const safeRight = Math.round((zones.rightPct / 100) * W) + margin;
+ const safeTop = Math.round((zones.topPct / 100) * H) + margin;
+ const safeBottom = Math.round((zones.bottomPct / 100) * H) + margin;
+ const textColor = brandColor(kit?.colors.text, "white");
+ const overlays = [...edl.overlays].sort((a, b) => a.atMs - b.atMs || a.id.localeCompare(b.id));
+ const imageOverlay = (
+ path: string,
+ overlay: EdlOverlay,
+ widthPx: number,
+ opacity: number,
+ position: { x: string; y: string },
+ ) => {
+ const index = inputs.add("-i", path);
+ const label = nextLabel("img");
+ const out = nextLabel("ov");
+ const alpha = opacity < 1 ? `,colorchannelmixer=aa=${num(opacity)}` : "";
+ graph.push(
+ `[${index}:v]format=rgba,scale=${even(widthPx)}:-2${alpha}[${label}]`,
+ `[${videoLabel}][${label}]overlay=x=${escapeFilterValue(position.x)}:y=${escapeFilterValue(
+ position.y,
+ )}:enable=${enableBetween(overlay.atMs, overlay.atMs + overlay.durationMs)}[${out}]`,
+ );
+ videoLabel = out;
+ };
+ const boxPosition = (box: NormalizedBox) => ({
+ x: String(Math.round(box.x * W)),
+ y: String(Math.round(box.y * H)),
+ });
+ const cornerPosition = (corner: string) => ({
+ x: corner.endsWith("left")
+ ? String(safeLeft)
+ : corner === "center"
+ ? "(main_w-overlay_w)/2"
+ : `main_w-overlay_w-${safeRight}`,
+ y: corner.startsWith("top")
+ ? String(safeTop)
+ : corner === "center"
+ ? "(main_h-overlay_h)/2"
+ : `main_h-overlay_h-${safeBottom}`,
+ });
+
+ for (const overlay of overlays) {
+ const endMs = overlay.atMs + overlay.durationMs;
+ const motionPath = input.motionClips.get(overlay.id);
+ if (overlay.motion || motionPath) {
+ const box = overlay.box;
+ const width = box ? even(box.w * W) : W;
+ const height = box ? even(box.h * H) : H;
+ const template = overlay.motion ?? {
+ templateId: "overlay",
+ params: {},
+ durationMs: overlay.durationMs,
+ };
+ const index = motionInput(overlay.id, template, width, height);
+ const label = nextLabel("mo");
+ const out = nextLabel("ov");
+ const position = box ? boxPosition(box) : { x: "0", y: "0" };
+ graph.push(
+ `[${index}:v]format=yuva420p,scale=${width}:${height},setpts=PTS-STARTPTS+${sec(overlay.atMs)}/TB[${label}]`,
+ `[${videoLabel}][${label}]overlay=x=${position.x}:y=${position.y}:eof_action=pass:enable=${enableBetween(
+ overlay.atMs,
+ endMs,
+ )}[${out}]`,
+ );
+ videoLabel = out;
+ continue;
+ }
+ if (overlay.kind === "watermark") {
+ const watermark = kit?.watermark;
+ if (!watermark || !input.files.watermarkPath) {
+ throw new Error(`overlay ${overlay.id} needs the brand watermark file`);
+ }
+ const width = overlay.box ? overlay.box.w * W : watermark.scale * W;
+ imageOverlay(
+ input.files.watermarkPath,
+ overlay,
+ width,
+ watermark.opacity,
+ overlay.box ? boxPosition(overlay.box) : cornerPosition(watermark.position),
+ );
+ continue;
+ }
+ if (overlay.kind === "logo") {
+ if (!input.files.logoPath) throw new Error(`overlay ${overlay.id} needs a logo file`);
+ const width = overlay.box ? overlay.box.w * W : 0.15 * W;
+ imageOverlay(
+ input.files.logoPath,
+ overlay,
+ width,
+ 1,
+ overlay.box ? boxPosition(overlay.box) : cornerPosition("top_right"),
+ );
+ continue;
+ }
+ if (overlay.kind === "progress_bar") {
+ const barHeight = Math.max(4, even(H * 0.006));
+ const label = nextLabel("pb");
+ const out = nextLabel("ov");
+ const y = overlay.box ? Math.round(overlay.box.y * H) : H - safeBottom - barHeight;
+ const x = `-overlay_w+main_w*min(1,(t-${sec(overlay.atMs)})/${sec(Math.max(1, overlay.durationMs))})`;
+ graph.push(
+ `color=c=${brandColor(kit?.colors.accent, "white")}:s=${W}x${barHeight}:r=${num(F, 3)}:d=${sec(totalMs)}[${label}]`,
+ `[${videoLabel}][${label}]overlay=x=${escapeFilterValue(x)}:y=${y}:enable=${enableBetween(
+ overlay.atMs,
+ endMs,
+ )}[${out}]`,
+ );
+ videoLabel = out;
+ continue;
+ }
+ // text, lower_third, cta_end_card
+ const text = overlay.text?.trim();
+ if (!text) throw new Error(`overlay ${overlay.id} has no text and no motion clip`);
+ const lines = text.split(/\r?\n/).length;
+ const fontSize = overlay.box
+ ? Math.max(8, Math.round((overlay.box.h * H * 0.8) / lines))
+ : Math.round(H * (overlay.kind === "cta_end_card" ? 0.05 : 0.04));
+ const position = overlay.box
+ ? boxPosition(overlay.box)
+ : {
+ x: "(w-text_w)/2",
+ y:
+ overlay.kind === "lower_third"
+ ? `h-text_h-${safeBottom + Math.round(H * 0.08)}`
+ : overlay.kind === "cta_end_card"
+ ? "(h-text_h)/2"
+ : String(safeTop + Math.round(H * 0.05)),
+ };
+ const font = pickFont(
+ kit,
+ input.files.fonts,
+ overlay.kind === "cta_end_card" ? "heading" : "body",
+ );
+ const options = [
+ ...(font ? [`fontfile=${escapeFilterValue(font)}`] : []),
+ `text=${escapeFilterText(text)}`,
+ `fontsize=${fontSize}`,
+ `fontcolor=${textColor}`,
+ `x=${escapeFilterValue(position.x)}`,
+ `y=${escapeFilterValue(position.y)}`,
+ ];
+ if (overlay.kind !== "text") {
+ options.push(
+ "box=1",
+ `boxcolor=${brandColor(kit?.colors.primary, "black")}@0.85`,
+ `boxborderw=${Math.round(fontSize * 0.4)}`,
+ );
+ }
+ options.push(`enable=${enableBetween(overlay.atMs, endMs)}`);
+ const out = nextLabel("ov");
+ graph.push(`[${videoLabel}]drawtext=${options.join(":")}[${out}]`);
+ videoLabel = out;
+ }
+
+ // Captions.
+ const captions = edl.captions;
+ if (captions && captions.mode !== "none") {
+ const cues = buildCaptionCues(outputCaptionWords(edl, input.transcripts), {
+ maxCharsPerLine: captions.maxCharsPerLine,
+ maxLines: profile.captions.maxLines,
+ });
+ if (cues.length > 0) {
+ if (captions.mode === "burned" || captions.mode === "both") {
+ const preset = captionPreset(edl, kit);
+ const assPath = joinPath(workDir, "captions.ass");
+ steps.push({
+ kind: "write_file",
+ id: "captions-ass",
+ path: assPath,
+ content: toAss(cues, {
+ width: W,
+ height: H,
+ profile,
+ preset,
+ fallbackTextColor: kit?.colors.text ?? "#FFFFFF",
+ position: captions.position,
+ maxCharsPerLine: captions.maxCharsPerLine,
+ wordHighlight: captions.wordHighlight,
+ language: captions.language,
+ }),
+ });
+ const font = pickFont(kit, input.files.fonts, "caption");
+ const fontsDir = font ? `:fontsdir=${escapeFilterValue(dirName(font))}` : "";
+ const out = nextLabel("cap");
+ graph.push(`[${videoLabel}]ass=filename=${escapeFilterValue(assPath)}${fontsDir}[${out}]`);
+ videoLabel = out;
+ }
+ if (captions.mode === "sidecar" || captions.mode === "both") {
+ const srt = joinPath(workDir, "captions.srt");
+ const vtt = joinPath(workDir, "captions.vtt");
+ steps.push(
+ { kind: "write_file", id: "captions-srt", path: srt, content: toSrt(cues) },
+ { kind: "write_file", id: "captions-vtt", path: vtt, content: toVtt(cues) },
+ );
+ outputs.push({ kind: "subtitles_srt", path: srt }, { kind: "subtitles_vtt", path: vtt });
+ }
+ }
+ }
+ const finalVideo = input.videoEncoder.endsWith("_vaapi")
+ ? "format=nv12,hwupload"
+ : "format=yuv420p";
+ graph.push(`[${videoLabel}]${finalVideo}[vout]`);
+
+ // Audio: program (+ voice-over) is speech; clips marked duckUnderSpeech are compressed under it.
+ const speech: string[] = [programAudio];
+ const ducked: string[] = [];
+ const beds: string[] = [];
+ const audioClips = [...edl.tracks.audio].sort(
+ (a, b) => a.atMs - b.atMs || a.id.localeCompare(b.id),
+ );
+ for (const clip of audioClips) {
+ const path = input.files.audio.get(clip.artifactId);
+ if (!path) throw new Error(`no file for audio clip ${clip.id}`);
+ const length = clip.outMs - clip.inMs;
+ const index = inputs.add("-ss", sec(clip.inMs), "-t", sec(length), "-i", path);
+ graph.push(`[${index}:a]${audioClipFilters(clip, length).join(",")}[ac_${clip.id}]`);
+ const label = `ac_${clip.id}`;
+ if (clip.kind === "voiceover") speech.push(label);
+ else if (clip.duckUnderSpeech) ducked.push(label);
+ else beds.push(label);
+ }
+ const mix = (labels: string[], out: string) => {
+ if (labels.length === 1) {
+ graph.push(`[${labels[0]}]anull[${out}]`);
+ } else {
+ graph.push(
+ `${labels.map((l) => `[${l}]`).join("")}amix=inputs=${labels.length}:duration=first:normalize=0[${out}]`,
+ );
+ }
+ };
+ const finalInputs: string[] = [];
+ if (ducked.length > 0) {
+ mix(speech, "speech");
+ graph.push("[speech]asplit=2[speech_mix][speech_key]");
+ mix(ducked, "duck_bus");
+ graph.push(
+ "[duck_bus][speech_key]sidechaincompress=threshold=0.03:ratio=8:attack=20:release=400:makeup=1[ducked]",
+ );
+ finalInputs.push("speech_mix", "ducked");
+ } else {
+ finalInputs.push(...speech);
+ }
+ finalInputs.push(...beds);
+ const targetLufs = edl.targetLufs ?? profile.audio.targetLufs;
+ const loudnorm = `loudnorm=I=${num(targetLufs, 1)}:TP=${num(profile.audio.truePeakDb, 1)}:LRA=11:linear=true`;
+ const mixed = finalInputs.length > 1 ? "mixed" : finalInputs[0]!;
+ if (finalInputs.length > 1) mix(finalInputs, "mixed");
+ graph.push(`[${mixed}]${loudnorm},aresample=48000,${AUDIO_FORMAT}[aout]`);
+
+ // Render step.
+ const graphText = graph.join(";");
+ const graphArgs =
+ graphText.length > MAX_INLINE_GRAPH
+ ? ["-filter_complex_script", joinPath(workDir, "filtergraph.txt")]
+ : ["-filter_complex", graphText];
+ if (graphText.length > MAX_INLINE_GRAPH) {
+ steps.push({
+ kind: "write_file",
+ id: "filtergraph",
+ path: joinPath(workDir, "filtergraph.txt"),
+ content: graphText,
+ });
+ }
+ const videoPath = joinPath(workDir, "output.mp4");
+ steps.push({
+ kind: "ffmpeg",
+ id: "render",
+ argv: [
+ "-hide_banner",
+ "-nostdin",
+ "-y",
+ ...inputs.args,
+ ...graphArgs,
+ "-map",
+ "[vout]",
+ "-map",
+ "[aout]",
+ ...encodingArgs(input),
+ ...metadataArgs(edl.metadata.title),
+ "-t",
+ sec(totalMs),
+ "-movflags",
+ "+faststart",
+ videoPath,
+ ],
+ produces: [videoPath],
+ durationMs: totalMs,
+ });
+ outputs.unshift({ kind: "video", path: videoPath });
+
+ if (profile.export.poster) {
+ const posterPath = joinPath(workDir, "poster.jpg");
+ const at = clamp(input.posterAtMs ?? intro + mainMs / 3, 0, Math.max(0, totalMs - 1));
+ steps.push({
+ kind: "ffmpeg",
+ id: "poster",
+ argv: [
+ "-hide_banner",
+ "-nostdin",
+ "-y",
+ "-ss",
+ sec(at),
+ "-i",
+ videoPath,
+ "-frames:v",
+ "1",
+ "-map_metadata",
+ "-1",
+ "-fflags",
+ "+bitexact",
+ "-flags:v",
+ "+bitexact",
+ "-q:v",
+ "2",
+ posterPath,
+ ],
+ produces: [posterPath],
+ durationMs: 0,
+ });
+ outputs.push({ kind: "poster", path: posterPath });
+ }
+
+ return { quality: input.quality, workDir, steps, outputs };
+}
+
+function audioClipFilters(clip: EdlAudioClip, lengthMs: number): string[] {
+ const filters = ["asetpts=PTS-STARTPTS", "aresample=48000", AUDIO_FORMAT];
+ if (clip.gainDb !== 0) filters.push(`volume=${num(clip.gainDb, 2)}dB`);
+ if (clip.fadeInMs > 0) filters.push(`afade=t=in:st=0:d=${sec(clip.fadeInMs)}`);
+ if (clip.fadeOutMs > 0) {
+ filters.push(
+ `afade=t=out:st=${sec(Math.max(0, lengthMs - clip.fadeOutMs))}:d=${sec(clip.fadeOutMs)}`,
+ );
+ }
+ if (clip.atMs > 0) filters.push(`adelay=${clip.atMs}|${clip.atMs}`);
+ return filters;
}
diff --git a/packages/media/src/edl.test.ts b/packages/media/src/edl.test.ts
new file mode 100644
index 0000000..f72e9f9
--- /dev/null
+++ b/packages/media/src/edl.test.ts
@@ -0,0 +1,337 @@
+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();
+ });
+});
diff --git a/packages/media/src/edl.ts b/packages/media/src/edl.ts
index 4c3a06e..384f3d3 100644
--- a/packages/media/src/edl.ts
+++ b/packages/media/src/edl.ts
@@ -1,5 +1,30 @@
-import type { BrandKit, Edl, EdlError, MediaAssetKind, PlatformProfile } from "@rakazo/contracts";
-import { notImplemented } from "./not-implemented.js";
+import type {
+ AspectRatio,
+ BrandKit,
+ Edl,
+ EdlError,
+ EdlTransition,
+ EdlVideoClip,
+ MediaAssetKind,
+ MediaProjectGoal,
+ NormalizedBox,
+ PlatformProfile,
+} from "@rakazo/contracts";
+import { EdlSchema } from "@rakazo/contracts";
+
+/*
+ * Timeline model shared by validation, compilation, gate and auto-fix:
+ *
+ * - The output is intro + main program + outro, played back to back.
+ * - `tracks.video[].atMs` is a position on the main program, which starts
+ * after the intro. A clip lasts (outMs - inMs) / speed.
+ * - Clips may only overlap where a crossfade, dip or whip transition joins
+ * them, and then by exactly the transition duration. Cuts and match cuts
+ * never overlap.
+ * - Overlays and audio clips are positioned on the output timeline (intro
+ * included), so music can run under the intro.
+ * - Reframe keyframes are in source time of the clip's asset.
+ */
export interface EdlContext {
profile: PlatformProfile;
@@ -18,28 +43,384 @@ export interface EdlContext {
templateIds: Set;
/** Segments the user reserved for the paid release. */
paidOnly: Array<{ assetId: string; startMs: number; endMs: number }>;
+ /** Project goal; a teaser never uses paid-only segments, whatever the profile. */
+ goal?: MediaProjectGoal;
}
export type EdlValidation = { ok: true; edl: Edl } | { ok: false; errors: EdlError[] };
+/** Transitions rendered as a timed blend (xfade). Cuts and match cuts are plain concatenation. */
+export const BLENDED_TRANSITIONS: ReadonlySet = new Set([
+ "crossfade",
+ "dip",
+ "whip",
+]);
+
+export const ASPECT_RATIO_VALUES: Record = {
+ "9:16": 9 / 16,
+ "4:5": 4 / 5,
+ "1:1": 1,
+ "16:9": 16 / 9,
+ "4:3": 4 / 3,
+ "21:9": 21 / 9,
+};
+
+const ASPECT_TOLERANCE = 0.01;
+
+/** Whether width × height matches one of the allowed aspect ratios within 1 %. */
+export function matchesAspect(width: number, height: number, allowed: AspectRatio[]): boolean {
+ const actual = width / height;
+ return allowed.some(
+ (ratio) =>
+ Math.abs(actual - ASPECT_RATIO_VALUES[ratio]) / ASPECT_RATIO_VALUES[ratio] <=
+ ASPECT_TOLERANCE,
+ );
+}
+
+/** Output length of one video clip after speed change. */
+export function clipLengthMs(clip: Pick): number {
+ return Math.max(0, Math.round((clip.outMs - clip.inMs) / clip.speed));
+}
+
+/** End of the main program (excluding intro and outro). */
+export function mainProgramMs(edl: Edl): number {
+ let end = 0;
+ for (const clip of edl.tracks.video) end = Math.max(end, clip.atMs + clipLengthMs(clip));
+ return end;
+}
+
+export function introMs(edl: Edl): number {
+ return edl.intro?.durationMs ?? 0;
+}
+
+/** Output length in ms including intro, outro, speed, and transition overlap. */
+export function edlDurationMs(edl: Edl): number {
+ return introMs(edl) + mainProgramMs(edl) + (edl.outro?.durationMs ?? 0);
+}
+
+/** Every clip visible at an output timestamp (two during a blended transition), earliest first. */
+export function locateOutputTimeAll(
+ edl: Edl,
+ outputMs: number,
+): Array<{ clipId: string; assetId: string; sourceMs: number }> {
+ const t = outputMs - introMs(edl);
+ if (t < 0) return [];
+ return [...edl.tracks.video]
+ .filter((clip) => t >= clip.atMs && t < clip.atMs + clipLengthMs(clip))
+ .sort((a, b) => a.atMs - b.atMs || a.id.localeCompare(b.id))
+ .map((clip) => ({
+ clipId: clip.id,
+ assetId: clip.assetId,
+ sourceMs: Math.min(clip.outMs, Math.round(clip.inMs + (t - clip.atMs) * clip.speed)),
+ }));
+}
+
+/**
+ * Map an output timestamp back to the clip and source time it shows. During a
+ * blended transition the incoming clip wins; intro and outro map to null.
+ */
+export function locateOutputTime(
+ edl: Edl,
+ outputMs: number,
+): { clipId: string; assetId: string; sourceMs: number } | null {
+ return locateOutputTimeAll(edl, outputMs).at(-1) ?? null;
+}
+
+/** Whether a normalized box lies inside the profile's safe zones. */
+export function boxInsideSafeZone(box: NormalizedBox, profile: PlatformProfile): boolean {
+ const epsilon = 1e-6;
+ const zones = profile.safeZones;
+ return (
+ box.x >= zones.leftPct / 100 - epsilon &&
+ box.y >= zones.topPct / 100 - epsilon &&
+ box.x + box.w <= 1 - zones.rightPct / 100 + epsilon &&
+ box.y + box.h <= 1 - zones.bottomPct / 100 + epsilon
+ );
+}
+
+function formatPath(path: ReadonlyArray): string {
+ let out = "";
+ for (const part of path) {
+ if (typeof part === "number") out += `[${part}]`;
+ else out += out ? `.${String(part)}` : String(part);
+ }
+ return out || "(root)";
+}
+
+/** Video clips in timeline order, ties broken by id for determinism. */
+export function sortedVideoClips(edl: Edl): EdlVideoClip[] {
+ return [...edl.tracks.video].sort((a, b) => a.atMs - b.atMs || a.id.localeCompare(b.id));
+}
+
/**
* Parse with EdlSchema, then check references, ranges, overlaps, durations,
* safe zones, paid-only use (teaser-style profiles), and music licensing.
* Every failure is reported; nothing is silently corrected.
*/
-export function validateEdl(_input: unknown, _context: EdlContext): EdlValidation {
- return notImplemented("validateEdl");
-}
+export function validateEdl(input: unknown, context: EdlContext): EdlValidation {
+ const parsed = EdlSchema.safeParse(input);
+ if (!parsed.success) {
+ return {
+ ok: false,
+ errors: parsed.error.issues.map((issue) => ({
+ code: "schema",
+ path: formatPath(issue.path),
+ message: issue.message,
+ })),
+ };
+ }
+ const edl = parsed.data;
+ const errors: EdlError[] = [];
+ const fail = (code: EdlError["code"], path: string, message: string) =>
+ errors.push({ code, path, message });
+ const { profile } = context;
-/** Output length in ms including intro, outro, speed, and transition overlap. */
-export function edlDurationMs(_edl: Edl): number {
- return notImplemented("edlDurationMs");
-}
+ if (edl.output.profileId !== profile.id) {
+ fail("profile_mismatch", "output.profileId", `plan targets ${edl.output.profileId}`);
+ }
+ if (!matchesAspect(edl.output.width, edl.output.height, profile.video.aspectRatios)) {
+ fail(
+ "profile_mismatch",
+ "output",
+ `${edl.output.width}x${edl.output.height} is not one of ${profile.video.aspectRatios.join(", ")}`,
+ );
+ }
+ if (edl.output.fps > profile.video.fps * 1.001) {
+ fail("profile_mismatch", "output.fps", `fps above profile maximum ${profile.video.fps}`);
+ }
-/** Map an output timestamp back to the clip and source time it shows. */
-export function locateOutputTime(
- _edl: Edl,
- _outputMs: number,
-): { clipId: string; assetId: string; sourceMs: number } | null {
- return notImplemented("locateOutputTime");
+ const seenIds = new Map();
+ const claimId = (id: string, path: string) => {
+ const previous = seenIds.get(id);
+ if (previous) fail("schema", path, `id ${id} already used at ${previous}`);
+ else seenIds.set(id, path);
+ };
+
+ const clipsById = new Map();
+ edl.tracks.video.forEach((clip, index) => {
+ const path = `tracks.video[${index}]`;
+ claimId(clip.id, `${path}.id`);
+ clipsById.set(clip.id, clip);
+ if (clip.outMs <= clip.inMs) fail("range_empty", path, "outMs must be after inMs");
+ const asset = context.assets.get(clip.assetId);
+ if (!asset) {
+ fail("unknown_asset", `${path}.assetId`, `asset ${clip.assetId} is not available`);
+ } else if (asset.durationMs !== null && asset.kind !== "photo") {
+ if (clip.outMs > asset.durationMs) {
+ fail(
+ "range_out_of_asset",
+ `${path}.outMs`,
+ `outMs ${clip.outMs} exceeds asset duration ${asset.durationMs}`,
+ );
+ }
+ if (clip.inMs >= asset.durationMs) {
+ fail("range_out_of_asset", `${path}.inMs`, `inMs ${clip.inMs} is past the asset end`);
+ }
+ }
+ if (Array.isArray(clip.reframe)) {
+ clip.reframe.forEach((keyframe, k) => {
+ if (keyframe.tMs < clip.inMs || keyframe.tMs > clip.outMs) {
+ fail(
+ "range_out_of_asset",
+ `${path}.reframe[${k}].tMs`,
+ "reframe keyframe lies outside the clip's source range",
+ );
+ }
+ });
+ }
+ if (
+ (profile.kind === "free_social" || context.goal === "teaser") &&
+ context.paidOnly.some(
+ (segment) =>
+ segment.assetId === clip.assetId &&
+ segment.startMs < clip.outMs &&
+ clip.inMs < segment.endMs,
+ )
+ ) {
+ fail("paid_only_segment", path, "clip uses material reserved for the paid release");
+ }
+ });
+
+ // Transitions: known clips, adjacent in time, at most one in and one out per clip.
+ const blendBetween = new Map();
+ const outgoing = new Set();
+ const incoming = new Set();
+ const order = sortedVideoClips(edl);
+ const nextOf = new Map();
+ for (let i = 0; i + 1 < order.length; i++) nextOf.set(order[i]!.id, order[i + 1]!.id);
+ edl.transitions.forEach((transition, index) => {
+ const path = `transitions[${index}]`;
+ const from = clipsById.get(transition.fromClipId);
+ const to = clipsById.get(transition.toClipId);
+ if (!from) fail("unknown_clip", `${path}.fromClipId`, `no clip ${transition.fromClipId}`);
+ if (!to) fail("unknown_clip", `${path}.toClipId`, `no clip ${transition.toClipId}`);
+ if (!from || !to) return;
+ if (nextOf.get(from.id) !== to.id) {
+ fail("schema", path, "a transition must join a clip to the clip that follows it");
+ return;
+ }
+ if (outgoing.has(from.id) || incoming.has(to.id)) {
+ fail("schema", path, "a clip can have only one transition on each side");
+ return;
+ }
+ outgoing.add(from.id);
+ incoming.add(to.id);
+ if (BLENDED_TRANSITIONS.has(transition.kind) && transition.durationMs > 0) {
+ const shortest = Math.min(clipLengthMs(from), clipLengthMs(to));
+ if (transition.durationMs >= shortest) {
+ fail("overlap", `${path}.durationMs`, "transition is longer than one of its clips");
+ return;
+ }
+ blendBetween.set(`${from.id}>${to.id}`, transition.durationMs);
+ }
+ });
+
+ // Overlaps on the video track: only blended transitions may overlap, by exactly their length.
+ for (let i = 0; i + 1 < order.length; i++) {
+ const a = order[i]!;
+ const b = order[i + 1]!;
+ const overlap = a.atMs + clipLengthMs(a) - b.atMs;
+ const blend = blendBetween.get(`${a.id}>${b.id}`);
+ const path = `tracks.video[${edl.tracks.video.indexOf(b)}].atMs`;
+ if (blend !== undefined) {
+ if (Math.abs(overlap - blend) > 1) {
+ fail(
+ "overlap",
+ path,
+ `clips ${a.id} and ${b.id} must overlap by the ${blend} ms transition`,
+ );
+ }
+ } else if (overlap > 1) {
+ fail("overlap", path, `clip ${b.id} starts ${overlap} ms before ${a.id} ends`);
+ }
+ }
+ for (let i = 0; i + 2 < order.length; i++) {
+ const a = order[i]!;
+ const c = order[i + 2]!;
+ if (a.atMs + clipLengthMs(a) > c.atMs + 1) {
+ fail("overlap", `tracks.video[${edl.tracks.video.indexOf(c)}].atMs`, "three clips overlap");
+ }
+ }
+
+ // Audio clips.
+ const audioByKind = new Map>();
+ edl.tracks.audio.forEach((clip, index) => {
+ const path = `tracks.audio[${index}]`;
+ claimId(clip.id, `${path}.id`);
+ if (clip.outMs <= clip.inMs) fail("range_empty", path, "outMs must be after inMs");
+ if (!context.artifactIds.has(clip.artifactId)) {
+ fail(
+ "unknown_artifact",
+ `${path}.artifactId`,
+ `artifact ${clip.artifactId} is not available`,
+ );
+ }
+ if (clip.fadeInMs + clip.fadeOutMs > clip.outMs - clip.inMs && clip.outMs > clip.inMs) {
+ fail("range_empty", path, "fades are longer than the clip");
+ }
+ if (clip.kind === "music") {
+ if (profile.audio.music === "platform_library_only") {
+ fail(
+ "music_license",
+ `${path}.artifactId`,
+ "this platform only allows music added from its own library",
+ );
+ } else if (profile.audio.music === "licensed_only") {
+ const license = context.music.get(clip.artifactId);
+ if (!license?.commercialUse) {
+ fail(
+ "music_license",
+ `${path}.artifactId`,
+ license ? "music is not licensed for commercial use" : "music license is unknown",
+ );
+ }
+ }
+ }
+ if (clip.kind !== "sfx") {
+ const list = audioByKind.get(clip.kind) ?? [];
+ list.push({ start: clip.atMs, end: clip.atMs + (clip.outMs - clip.inMs), index });
+ audioByKind.set(clip.kind, list);
+ }
+ });
+ for (const list of audioByKind.values()) {
+ list.sort((a, b) => a.start - b.start || a.index - b.index);
+ for (let i = 0; i + 1 < list.length; i++) {
+ if (list[i]!.end > list[i + 1]!.start) {
+ fail("overlap", `tracks.audio[${list[i + 1]!.index}].atMs`, "audio clips overlap");
+ }
+ }
+ }
+
+ // Templates.
+ const checkTemplate = (templateId: string, path: string) => {
+ if (!context.templateIds.has(templateId)) {
+ fail("unknown_template", path, `template ${templateId} is not available`);
+ }
+ };
+ if (edl.intro) checkTemplate(edl.intro.templateId, "intro.templateId");
+ if (edl.outro) checkTemplate(edl.outro.templateId, "outro.templateId");
+
+ // Overlays.
+ edl.overlays.forEach((overlay, index) => {
+ const path = `overlays[${index}]`;
+ claimId(overlay.id, `${path}.id`);
+ if (overlay.durationMs <= 0)
+ fail("range_empty", `${path}.durationMs`, "overlay has no duration");
+ if (overlay.motion) checkTemplate(overlay.motion.templateId, `${path}.motion.templateId`);
+ if (overlay.box && !boxInsideSafeZone(overlay.box, profile)) {
+ fail("overlay_outside_safe_zone", `${path}.box`, "overlay leaves the profile safe zone");
+ }
+ if (overlay.kind === "watermark" && !context.brandKit?.watermark) {
+ fail("unknown_artifact", path, "the brand kit has no watermark");
+ }
+ if ((overlay.kind === "text" || overlay.kind === "lower_third") && !overlay.text?.trim()) {
+ if (!overlay.motion) fail("range_empty", `${path}.text`, "text overlay has no text");
+ }
+ });
+
+ // Captions preset.
+ const presetId = edl.captions?.presetId;
+ if (presetId && context.brandKit) {
+ if (!context.brandKit.captionPresets.some((preset) => preset.id === presetId)) {
+ fail("unknown_template", "captions.presetId", `caption preset ${presetId} is not in the kit`);
+ }
+ }
+
+ // Redactions.
+ edl.redactions.faces.forEach((redaction, index) => {
+ const path = `redactions.faces[${index}]`;
+ claimId(redaction.id, `${path}.id`);
+ const target = redaction.target;
+ if ("trackId" in target && !context.faceTrackIds.has(target.trackId)) {
+ fail("unknown_track", `${path}.target.trackId`, `face track ${target.trackId} is unknown`);
+ }
+ if ("personId" in target && !context.personIds.has(target.personId)) {
+ fail("unknown_person", `${path}.target.personId`, `person ${target.personId} is unknown`);
+ }
+ });
+ edl.redactions.marks.forEach((mark, index) => {
+ if (!context.markIds.has(mark.markId)) {
+ fail("unknown_mark", `redactions.marks[${index}].markId`, `mark ${mark.markId} is unknown`);
+ }
+ });
+ edl.redactions.regions.forEach((region, index) => {
+ const path = `redactions.regions[${index}]`;
+ claimId(region.id, `${path}.id`);
+ if (!clipsById.has(region.clipId)) {
+ fail("unknown_clip", `${path}.clipId`, `no clip ${region.clipId}`);
+ }
+ });
+
+ // Duration against both the plan's and the profile's hard bounds.
+ const duration = edlDurationMs(edl);
+ const minMs = Math.max(edl.output.durationHardMinMs, profile.duration.hardMinMs);
+ const maxMs = Math.min(edl.output.durationHardMaxMs, profile.duration.hardMaxMs);
+ if (duration < minMs) {
+ fail("duration_below_min", "output", `duration ${duration} ms is below ${minMs} ms`);
+ }
+ if (duration > maxMs) {
+ fail("duration_above_max", "output", `duration ${duration} ms is above ${maxMs} ms`);
+ }
+
+ return errors.length > 0 ? { ok: false, errors } : { ok: true, edl };
}
diff --git a/packages/media/src/gate.test.ts b/packages/media/src/gate.test.ts
new file mode 100644
index 0000000..acb75e3
--- /dev/null
+++ b/packages/media/src/gate.test.ts
@@ -0,0 +1,531 @@
+import type { FfprobeReport, GateReport } from "@rakazo/contracts";
+import { GateReportSchema, MAX_AUTO_FIX_ROUNDS } from "@rakazo/contracts";
+import { describe, expect, it } from "vitest";
+import { edlDurationMs } from "./edl.js";
+import type { GateInput } from "./gate.js";
+import {
+ checkCopy,
+ checkTechnical,
+ evaluateGate,
+ findMetadataLeaks,
+ METADATA_ALLOWED_TAGS,
+ planAutoFix,
+} from "./gate.js";
+import { testEdl, testPaidProfile, testProfile } from "./test-fixtures.js";
+
+const cleanTags = {
+ "format.major_brand": "isom",
+ "format.minor_version": "512",
+ "format.compatible_brands": "isomiso2avc1mp41",
+ "format.title": "Example",
+ "stream.0.language": "und",
+ "stream.0.handler_name": "VideoHandler",
+ "stream.0.vendor_id": "[0][0][0][0]",
+ "stream.0.encoder": "Lavc libx264",
+ "stream.1.handler_name": "SoundHandler",
+};
+
+function probe(overrides: Partial = {}): FfprobeReport {
+ return {
+ durationMs: 4000,
+ width: 1080,
+ height: 1920,
+ fps: 30,
+ videoCodec: "h264",
+ audioCodec: "aac",
+ sizeBytes: 2_000_000,
+ integratedLufs: -14.3,
+ truePeakDb: -1.2,
+ tags: cleanTags,
+ ...overrides,
+ };
+}
+
+function gateInput(overrides: Partial = {}): GateInput {
+ return {
+ profile: testProfile(),
+ edl: testEdl(),
+ ffprobe: probe(),
+ content: [
+ { tMs: 0, scores: { explicit_nudity: 0.01 } },
+ { tMs: 500, scores: { explicit_nudity: 0.02, violence: 0.1 } },
+ ],
+ faces: [],
+ trackingGaps: [],
+ marks: [],
+ people: [],
+ copy: { title: "A day out", caption: "Short caption #one", tags: ["two"] },
+ aiAlterations: [],
+ bundleHasDisclosure: false,
+ watermarkBox: { x: 0.7, y: 0.12, w: 0.15, h: 0.05 },
+ sampledFrames: 8,
+ autoFixRound: 0,
+ now: new Date("2026-10-01T12:00:00Z"),
+ ...overrides,
+ };
+}
+
+const blockCodes = (report: GateReport) =>
+ report.issues.filter((i) => i.severity === "block").map((i) => i.code);
+
+describe("metadata", () => {
+ it("allows only harmless container tags", () => {
+ expect(METADATA_ALLOWED_TAGS).toContain("title");
+ expect(findMetadataLeaks(cleanTags)).toEqual([]);
+ expect(
+ findMetadataLeaks({
+ ...cleanTags,
+ "format.creation_time": "2026-01-01T00:00:00Z",
+ "format.location": "+00.0000+000.0000/",
+ "format.com.apple.quicktime.make": "Example",
+ "format.com.apple.quicktime.location.ISO6709": "+00+000/",
+ "format.make": "Example",
+ "format.model": "Example",
+ "format.software": "Example 1.0",
+ "stream.0.creation_time": "2026-01-01T00:00:00Z",
+ GPSLatitude: "0",
+ "format.encoder": "Lavf60.16.100",
+ "stream:0:encoder": "x264 core 164",
+ }),
+ ).toEqual([
+ "GPSLatitude",
+ "format.com.apple.quicktime.location.ISO6709",
+ "format.com.apple.quicktime.make",
+ "format.creation_time",
+ "format.encoder",
+ "format.location",
+ "format.make",
+ "format.model",
+ "format.software",
+ "stream.0.creation_time",
+ "stream:0:encoder",
+ ]);
+ expect(findMetadataLeaks({ title: "x", major_brand: "isom" })).toEqual([]);
+ });
+});
+
+describe("evaluateGate", () => {
+ it("passes a clean render and produces a schema-valid report", () => {
+ const report = evaluateGate(gateInput());
+ expect(report.issues).toEqual([]);
+ expect(report.passed).toBe(true);
+ expect(GateReportSchema.parse(report)).toEqual(report);
+ expect(report.checkedAt).toBe("2026-10-01T12:00:00.000Z");
+ });
+
+ it("blocks technical problems", () => {
+ const edl = testEdl();
+ expect(checkTechnical(testProfile(), edl, probe())).toEqual([]);
+ const cases: Array<[Partial, string]> = [
+ [{ width: 1920, height: 1080 }, "resolution_mismatch"],
+ [{ width: 1920, height: 1080 }, "aspect_not_allowed"],
+ [{ fps: 25 }, "fps_mismatch"],
+ [{ durationMs: 70_000 }, "duration_too_long"],
+ [{ durationMs: 500 }, "duration_too_short"],
+ [{ sizeBytes: 600_000_000 }, "file_too_large"],
+ [{ videoCodec: "vp9" }, "codec_mismatch"],
+ [{ integratedLufs: -20 }, "loudness_off_target"],
+ [{ integratedLufs: null }, "loudness_unmeasured"],
+ [{ truePeakDb: 0 }, "true_peak_exceeded"],
+ [{ tags: { ...cleanTags, "format.creation_time": "2020" } }, "metadata_leak"],
+ ];
+ for (const [patch, code] of cases) {
+ const report = evaluateGate(gateInput({ ffprobe: probe(patch) }));
+ expect(report.passed, code).toBe(false);
+ expect(blockCodes(report), code).toContain(code);
+ }
+ const drift = checkTechnical(testProfile(), edl, probe({ durationMs: 4500 }));
+ expect(drift).toEqual([expect.objectContaining({ code: "duration_drift", severity: "warn" })]);
+ expect(
+ checkTechnical(testProfile(), edl, probe({ audioCodec: null, integratedLufs: null })),
+ ).toEqual([]);
+ });
+
+ it("blocks classifier hits with timestamps, category and score", () => {
+ const report = evaluateGate(
+ gateInput({
+ content: [
+ { tMs: 0, scores: { explicit_nudity: 0.05 } },
+ { tMs: 1500, scores: { explicit_nudity: 0.4 } },
+ { tMs: 2000, scores: { explicit_nudity: 0.6 } },
+ { tMs: 3500, scores: { violence: 0.9, drugs: 1 } },
+ ],
+ }),
+ );
+ expect(report.passed).toBe(false);
+ expect(report.issues).toEqual([
+ expect.objectContaining({
+ check: "content",
+ code: "content_threshold",
+ category: "explicit_nudity",
+ atMs: 1500,
+ endMs: 2000,
+ score: 0.6,
+ }),
+ expect.objectContaining({ category: "violence", atMs: 3500, severity: "block" }),
+ ]);
+ });
+
+ it("only blocks above the ratio when the profile says so", () => {
+ const profile = testProfile({
+ content: { thresholds: { violence: 0.5 }, blockOn: "ratio", blockRatio: 0.25 },
+ });
+ const samples = Array.from({ length: 10 }, (_, i) => ({
+ tMs: i * 500,
+ scores: { violence: i < 2 ? 0.9 : 0 },
+ }));
+ const below = evaluateGate(gateInput({ profile, content: samples }));
+ expect(below.passed).toBe(true);
+ expect(below.issues[0]).toMatchObject({ severity: "warn", category: "violence" });
+ const above = samples.map((s, i) => ({ ...s, scores: { violence: i < 4 ? 0.9 : 0 } }));
+ expect(evaluateGate(gateInput({ profile, content: above })).passed).toBe(false);
+ });
+
+ it("blocks anonymization gaps, uncovered and unknown faces, and visible marks", () => {
+ const people = [
+ { id: "person-1", anonymizeByDefault: "blur", consentRecordedAt: null, ageVerifiedAt: null },
+ { id: "person-2", anonymizeByDefault: "none", consentRecordedAt: null, ageVerifiedAt: null },
+ ];
+ const box = { x: 0.4, y: 0.2, w: 0.1, h: 0.1 };
+ const report = evaluateGate(
+ gateInput({
+ people,
+ faces: [
+ { tMs: 100, box, personId: "person-1", coverage: 1 },
+ { tMs: 200, box, personId: "person-1", coverage: 0.9 },
+ { tMs: 300, box, personId: "person-2", coverage: 0 },
+ { tMs: 400, box, personId: null, coverage: 0 },
+ ],
+ trackingGaps: [
+ { startMs: 1000, endMs: 1400, personId: "person-1" },
+ { startMs: 2000, endMs: 2600, personId: "person-1" },
+ ],
+ marks: [
+ { markId: "mark-1", tMs: 100, residualVisibility: 0.01 },
+ { markId: "mark-1", tMs: 300, residualVisibility: 0.3 },
+ ],
+ }),
+ );
+ expect(report.issues.filter((i) => i.check === "anonymization")).toEqual([
+ expect.objectContaining({ code: "face_not_covered", personId: "person-1", atMs: 200 }),
+ expect.objectContaining({ code: "unknown_face", atMs: 400 }),
+ expect.objectContaining({
+ code: "tracking_gap",
+ atMs: 2000,
+ endMs: 2600,
+ personId: "person-1",
+ }),
+ expect.objectContaining({ code: "mark_visible", markId: "mark-1", atMs: 300 }),
+ ]);
+ // Without protected people an unmatched face is not an anonymization issue.
+ const open = evaluateGate(gateInput({ faces: [{ tMs: 0, box, personId: null, coverage: 0 }] }));
+ expect(open.passed).toBe(true);
+ });
+
+ it("requires consent and age verification only where the profile does", () => {
+ const people = [
+ {
+ id: "p-ok",
+ anonymizeByDefault: "none",
+ consentRecordedAt: "2026-01-01T00:00:00Z",
+ ageVerifiedAt: "2026-01-01T00:00:00Z",
+ },
+ {
+ id: "p-no-consent",
+ anonymizeByDefault: "none",
+ consentRecordedAt: null,
+ ageVerifiedAt: "2026-01-01T00:00:00Z",
+ },
+ {
+ id: "p-no-age",
+ anonymizeByDefault: "none",
+ consentRecordedAt: "2026-01-01T00:00:00Z",
+ ageVerifiedAt: null,
+ },
+ ];
+ const faces = [{ tMs: 1000, box: { x: 0, y: 0, w: 0.1, h: 0.1 }, personId: null, coverage: 0 }];
+ expect(evaluateGate(gateInput({ people, faces })).passed).toBe(true);
+ const profile = testPaidProfile();
+ const edl = testEdl({
+ output: { ...testEdl().output, profileId: profile.id, width: 1920, height: 1080 },
+ });
+ const report = evaluateGate(
+ gateInput({ profile, edl, people, faces, ffprobe: probe({ width: 1920, height: 1080 }) }),
+ );
+ expect(report.issues.filter((i) => i.check === "consent")).toEqual([
+ expect.objectContaining({ code: "age_unverified", personId: "p-no-age" }),
+ expect.objectContaining({ code: "consent_missing", personId: "p-no-consent" }),
+ expect.objectContaining({ code: "unknown_performer", atMs: 1000 }),
+ ]);
+ expect(report.passed).toBe(false);
+ });
+
+ it("requires the AI label when the profile does and warns about the watermark", () => {
+ const report = evaluateGate(
+ gateInput({
+ aiAlterations: ["synthetic_identity"],
+ watermarkBox: { x: 0.9, y: 0.01, w: 0.1, h: 0.05 },
+ }),
+ );
+ expect(report.issues).toEqual([
+ expect.objectContaining({ check: "disclosure", code: "ai_label_missing", severity: "block" }),
+ expect.objectContaining({
+ check: "brand",
+ code: "watermark_outside_safe_zone",
+ severity: "warn",
+ }),
+ ]);
+ expect(
+ evaluateGate(gateInput({ aiAlterations: ["inpaint"], bundleHasDisclosure: true })).passed,
+ ).toBe(true);
+ const edl = testEdl({ metadata: { aiAltered: true, aiAlterations: ["voice_disguise"] } });
+ expect(blockCodes(evaluateGate(gateInput({ edl })))).toEqual(["ai_label_missing"]);
+ const relaxed = testProfile({ disclosure: { aiAlteredLabelRequired: false } });
+ expect(evaluateGate(gateInput({ profile: relaxed, edl })).passed).toBe(true);
+ });
+});
+
+describe("checkCopy", () => {
+ const profile = testProfile({
+ text: {
+ titleMaxChars: 20,
+ captionMaxChars: 60,
+ hashtagLimit: 2,
+ linkPolicy: "bio_only",
+ bannedPatterns: [
+ { pattern: "free\\s+money", reason: "misleading claim" },
+ { pattern: "([a-z", reason: "broken pattern" },
+ ],
+ },
+ });
+
+ it("checks banned patterns case-insensitively and reports invalid ones without crashing", () => {
+ const issues = checkCopy(profile, {
+ title: "FREE Money",
+ caption: null,
+ tags: ["free money"],
+ });
+ expect(issues.map((i) => [i.code, i.message])).toEqual([
+ ["banned_pattern", "title: misleading claim"],
+ ["banned_pattern", "tags[0]: misleading claim"],
+ ["invalid_banned_pattern", "profile pattern cannot be checked: broken pattern"],
+ ]);
+ });
+
+ it("checks lengths, hashtags and links", () => {
+ const codes = (copy: { title: string | null; caption: string | null; tags: string[] }) =>
+ checkCopy(profile, copy)
+ .map((i) => i.code)
+ .filter((c) => c !== "invalid_banned_pattern");
+ expect(codes({ title: "x".repeat(21), caption: "y".repeat(61), tags: [] })).toEqual([
+ "title_too_long",
+ "caption_too_long",
+ ]);
+ expect(codes({ title: null, caption: "#a #b", tags: ["#c"] })).toEqual(["too_many_hashtags"]);
+ expect(codes({ title: null, caption: "#a #A", tags: ["a"] })).toEqual([]);
+ expect(codes({ title: null, caption: "see https://example.com/x", tags: [] })).toEqual([
+ "link_bio_only",
+ ]);
+ expect(codes({ title: null, caption: "visit example.shop today", tags: [] })).toEqual([
+ "link_bio_only",
+ ]);
+ expect(codes({ title: "Link in bio", caption: "Ready? Go.", tags: [] })).toEqual([]);
+ const forbidden = testProfile({
+ text: { captionMaxChars: 100, hashtagLimit: 5, linkPolicy: "forbidden" },
+ });
+ expect(
+ checkCopy(forbidden, { title: "www.example.com", caption: null, tags: [] })[0]!.code,
+ ).toBe("link_forbidden");
+ const allowed = testProfile({
+ text: { captionMaxChars: 100, hashtagLimit: 5, linkPolicy: "allowed" },
+ });
+ expect(checkCopy(allowed, { title: null, caption: "https://example.com", tags: [] })).toEqual(
+ [],
+ );
+ });
+});
+
+describe("planAutoFix", () => {
+ const edl = testEdl({
+ tracks: {
+ video: [
+ {
+ id: "c1",
+ assetId: "asset-a",
+ inMs: 0,
+ outMs: 2000,
+ atMs: 0,
+ reframe: "auto",
+ segmentId: "s1",
+ },
+ {
+ id: "c2",
+ assetId: "asset-a",
+ inMs: 5000,
+ outMs: 7000,
+ atMs: 2000,
+ reframe: "auto",
+ segmentId: "s2",
+ },
+ {
+ id: "c3",
+ assetId: "asset-a",
+ inMs: 8000,
+ outMs: 10_000,
+ atMs: 4000,
+ reframe: "auto",
+ segmentId: "s3",
+ },
+ ],
+ },
+ transitions: [{ fromClipId: "c2", toClipId: "c3", kind: "cut", durationMs: 0 }],
+ overlays: [
+ { id: "wm", kind: "watermark", atMs: 0, durationMs: 6000 },
+ { id: "cta", kind: "cta_end_card", atMs: 4500, durationMs: 1500, text: "Link in bio" },
+ ],
+ redactions: {
+ faces: [{ id: "f1", target: { personId: "person-1" }, mode: "blur", paddingPct: 20 }],
+ marks: [{ markId: "mark-1", treatment: "inpaint" }],
+ },
+ metadata: { aiAltered: true, aiAlterations: ["inpaint"] },
+ });
+ const report = (issues: GateReport["issues"], round = 0): GateReport => ({
+ profileId: "example-short-vertical",
+ passed: !issues.some((i) => i.severity === "block"),
+ checkedAt: "2026-10-01T12:00:00.000Z",
+ sampledFrames: 12,
+ issues,
+ autoFixRound: round,
+ });
+ const contentHit = (atMs: number, endMs = atMs) =>
+ ({
+ check: "content",
+ severity: "block",
+ code: "content_threshold",
+ message: "violence",
+ atMs,
+ endMs,
+ category: "violence",
+ score: 0.9,
+ }) as const;
+
+ const assertNoNewOverlays = (fixed: typeof edl) => {
+ const before = new Map(edl.overlays.map((o) => [o.id, o]));
+ for (const overlay of fixed.overlays) {
+ const original = before.get(overlay.id);
+ expect(original, `overlay ${overlay.id} was added`).toBeDefined();
+ expect(overlay.kind).toBe(original!.kind);
+ expect(overlay.text).toBe(original!.text);
+ expect(overlay.durationMs).toBeLessThanOrEqual(original!.durationMs);
+ }
+ expect(fixed.overlays.length).toBeLessThanOrEqual(edl.overlays.length);
+ };
+
+ it("swaps an offending clip for a clean alternative segment", () => {
+ const fix = planAutoFix(edl, report([contentHit(2500, 3000)]), [
+ { segmentId: "s2", assetId: "asset-a", startMs: 5000, endMs: 7000 },
+ { segmentId: "s9", assetId: "asset-a", startMs: 5500, endMs: 9000 },
+ { segmentId: "s8", assetId: "asset-b", startMs: 1000, endMs: 4000 },
+ ])!;
+ expect(fix.changes).toEqual(["clip c2: swapped to segment s8 (content_threshold)"]);
+ expect(fix.edl.tracks.video.find((c) => c.id === "c2")).toMatchObject({
+ assetId: "asset-b",
+ inMs: 1000,
+ outMs: 3000,
+ segmentId: "s8",
+ atMs: 2000,
+ });
+ expect(edlDurationMs(fix.edl)).toBe(6000);
+ assertNoNewOverlays(fix.edl);
+ });
+
+ it("trims or drops the clip when no alternative fits, and repacks the timeline", () => {
+ const trimmed = planAutoFix(edl, report([contentHit(3800)]), [])!;
+ expect(trimmed.changes).toEqual(["clip c2: trimmed the end at 6300 ms (content_threshold)"]);
+ expect(trimmed.edl.tracks.video.map((c) => [c.id, c.atMs, c.inMs, c.outMs])).toEqual([
+ ["c1", 0, 0, 2000],
+ ["c2", 2000, 5000, 6300],
+ ["c3", 3300, 8000, 10_000],
+ ]);
+ assertNoNewOverlays(trimmed.edl);
+ expect(trimmed.edl.overlays.find((o) => o.id === "wm")!.durationMs).toBe(5300);
+
+ const dropped = planAutoFix(edl, report([contentHit(2300, 3700)]), [])!;
+ expect(dropped.changes).toEqual(["clip c2: removed (content_threshold)"]);
+ expect(dropped.edl.tracks.video.map((c) => c.id)).toEqual(["c1", "c3"]);
+ expect(dropped.edl.transitions).toEqual([]);
+ assertNoNewOverlays(dropped.edl);
+ });
+
+ it("cuts tracking gaps, widens face redactions, adds a blur for unknown faces, and covers marks", () => {
+ const fix = planAutoFix(
+ edl,
+ report([
+ {
+ check: "anonymization",
+ severity: "block",
+ code: "face_not_covered",
+ message: "",
+ atMs: 100,
+ personId: "person-1",
+ },
+ { check: "anonymization", severity: "block", code: "unknown_face", message: "", atMs: 300 },
+ {
+ check: "anonymization",
+ severity: "block",
+ code: "mark_visible",
+ message: "",
+ atMs: 300,
+ markId: "mark-1",
+ },
+ {
+ check: "anonymization",
+ severity: "block",
+ code: "tracking_gap",
+ message: "",
+ atMs: 4100,
+ endMs: 4800,
+ },
+ ]),
+ [],
+ )!;
+ expect(fix.edl.redactions.faces).toEqual([
+ expect.objectContaining({ id: "f1", paddingPct: 45 }),
+ expect.objectContaining({
+ id: "fix-face-unassigned",
+ target: { unassignedFaces: true },
+ mode: "blur",
+ }),
+ ]);
+ expect(fix.edl.redactions.marks).toEqual([{ markId: "mark-1", treatment: "cover" }]);
+ expect(fix.edl.metadata.aiAlterations).toEqual([]);
+ expect(fix.edl.tracks.video.find((c) => c.id === "c3")).toMatchObject({ inMs: 9300 });
+ assertNoNewOverlays(fix.edl);
+ });
+
+ it("stops at the round limit, on passing reports, and when nothing safe applies", () => {
+ expect(planAutoFix(edl, report([contentHit(2500)], MAX_AUTO_FIX_ROUNDS), [])).toBeNull();
+ expect(planAutoFix(edl, report([]), [])).toBeNull();
+ const textOnly = report([
+ { check: "text", severity: "block", code: "banned_pattern", message: "" },
+ ]);
+ expect(planAutoFix(edl, textOnly, [])).toBeNull();
+ const consent = report([
+ { check: "consent", severity: "block", code: "consent_missing", message: "", personId: "p" },
+ ]);
+ expect(planAutoFix(edl, consent, [])).toBeNull();
+ });
+
+ it("runs at most three rounds in a loop", () => {
+ let current = edl;
+ let rounds = 0;
+ for (let round = 0; round < 10; round++) {
+ const fix = planAutoFix(current, report([contentHit(500)], round), []);
+ if (!fix) break;
+ current = fix.edl;
+ rounds += 1;
+ assertNoNewOverlays(current);
+ }
+ expect(rounds).toBe(MAX_AUTO_FIX_ROUNDS);
+ });
+});
diff --git a/packages/media/src/gate.ts b/packages/media/src/gate.ts
index f3e4610..56d41ea 100644
--- a/packages/media/src/gate.ts
+++ b/packages/media/src/gate.ts
@@ -2,16 +2,74 @@ import type {
AiAlteration,
ContentCategory,
Edl,
+ EdlVideoClip,
+ FaceRedaction,
FfprobeReport,
GateIssue,
GateReport,
NormalizedBox,
PlatformProfile,
} from "@rakazo/contracts";
-import { notImplemented } from "./not-implemented.js";
+import { CONTENT_CATEGORIES, MAX_AUTO_FIX_ROUNDS } from "@rakazo/contracts";
+import {
+ BLENDED_TRANSITIONS,
+ boxInsideSafeZone,
+ clipLengthMs,
+ edlDurationMs,
+ locateOutputTimeAll,
+ matchesAspect,
+ sortedVideoClips,
+} from "./edl.js";
-/** Tags a clean export may carry. Everything else is a metadata leak. */
-export const METADATA_ALLOWED_TAGS: readonly string[] = [];
+/**
+ * Tags a clean export may carry. Everything else is a metadata leak.
+ *
+ * - `title`: the only descriptive tag the compiler writes, and only when set.
+ * - `major_brand`, `minor_version`, `compatible_brands`: the MP4 file-type
+ * box, always written by the muxer; it names the format, not the author.
+ * - `handler_name`, `vendor_id`, `language`: per-stream boilerplate the MP4
+ * muxer writes ("VideoHandler", "[0][0][0][0]", "und").
+ * - `encoder`: the MP4 sample description always names the codec. It is
+ * allowed only without a version number (bitexact output writes
+ * "Lavc libx264"); a versioned value such as "Lavf60.16.100" is a leak.
+ *
+ * `creation_time`, `location`, `com.apple.quicktime.*`, `make`, `model`,
+ * `software`, GPS keys and anything unknown always count as leaks.
+ */
+export const METADATA_ALLOWED_TAGS: readonly string[] = [
+ "title",
+ "major_brand",
+ "minor_version",
+ "compatible_brands",
+ "handler_name",
+ "vendor_id",
+ "language",
+ "encoder",
+];
+
+const ALLOWED = new Set(METADATA_ALLOWED_TAGS);
+/** "Lavf60.16.100", "Lavc60.31", "x264 core 164", "HandBrake 1.7.0". */
+const VERSIONED = /\d+\.\d+|lav[cf]\d|core\s*\d/i;
+
+/** Tag name without a "format." / "stream.0." style prefix, lower-cased. */
+function tagName(key: string): string {
+ return key
+ .trim()
+ .toLowerCase()
+ .replace(/^(?:format|streams?(?:[.:/]\d+)?)[.:/]/, "");
+}
+
+export function findMetadataLeaks(tags: Record): string[] {
+ return Object.entries(tags)
+ .filter(([key, value]) => {
+ const name = tagName(key);
+ if (!ALLOWED.has(name)) return true;
+ if (name === "encoder") return VERSIONED.test(value);
+ return false;
+ })
+ .map(([key]) => key)
+ .sort();
+}
export interface GateInput {
profile: PlatformProfile;
@@ -44,28 +102,370 @@ export interface GateInput {
now: Date;
}
-/** Deterministic gate (spec 6). Any blocking issue fails the report. */
-export function evaluateGate(_input: GateInput): GateReport {
- return notImplemented("evaluateGate");
+/** Longest tracking gap the anonymizer may bridge (spec 7). */
+export const MAX_TRACKING_GAP_MS = 500;
+/** Residual visibility of a treated mark above which the gate blocks. */
+export const MAX_MARK_RESIDUAL = 0.05;
+/** Loudness tolerance around the target, in LU. */
+export const LOUDNESS_TOLERANCE_LU = 2;
+const TRUE_PEAK_TOLERANCE_DB = 0.5;
+
+const ms = (value: number) => Math.max(0, Math.round(value));
+
+/** Merge time-sorted hits into ranges when they are at most `gapMs` apart. */
+function ranges(hits: T[], gapMs: number): T[][] {
+ const sorted = [...hits].sort((a, b) => a.tMs - b.tMs);
+ const out: T[][] = [];
+ for (const hit of sorted) {
+ const last = out.at(-1);
+ if (last && hit.tMs - last.at(-1)!.tMs <= gapMs) last.push(hit);
+ else out.push([hit]);
+ }
+ return out;
}
export function checkTechnical(
- _profile: PlatformProfile,
- _edl: Edl,
- _probe: FfprobeReport,
+ profile: PlatformProfile,
+ edl: Edl,
+ probe: FfprobeReport,
): GateIssue[] {
- return notImplemented("checkTechnical");
+ const issues: GateIssue[] = [];
+ const block = (code: string, message: string) =>
+ issues.push({ check: "technical", severity: "block", code, message });
+ if (probe.width !== edl.output.width || probe.height !== edl.output.height) {
+ block(
+ "resolution_mismatch",
+ `output is ${probe.width}x${probe.height}, plan expects ${edl.output.width}x${edl.output.height}`,
+ );
+ }
+ if (!matchesAspect(probe.width, probe.height, profile.video.aspectRatios)) {
+ block("aspect_not_allowed", `${probe.width}x${probe.height} is not an allowed aspect ratio`);
+ }
+ if (Math.max(probe.width, probe.height) > Math.max(profile.video.width, profile.video.height)) {
+ block("resolution_too_high", "output is larger than the profile allows");
+ }
+ if (
+ Math.abs(probe.fps - edl.output.fps) > edl.output.fps * 0.01 ||
+ probe.fps > profile.video.fps * 1.001
+ ) {
+ block("fps_mismatch", `output runs at ${probe.fps} fps`);
+ }
+ const frameMs = 1000 / Math.max(1, edl.output.fps);
+ const expected = edlDurationMs(edl);
+ if (probe.durationMs < profile.duration.hardMinMs - frameMs) {
+ block("duration_too_short", `${probe.durationMs} ms is below ${profile.duration.hardMinMs} ms`);
+ }
+ if (probe.durationMs > profile.duration.hardMaxMs + frameMs) {
+ block("duration_too_long", `${probe.durationMs} ms is above ${profile.duration.hardMaxMs} ms`);
+ }
+ if (Math.abs(probe.durationMs - expected) > Math.max(2 * frameMs, 100)) {
+ issues.push({
+ check: "technical",
+ severity: "warn",
+ code: "duration_drift",
+ message: `output is ${probe.durationMs} ms, plan is ${expected} ms`,
+ });
+ }
+ if (probe.sizeBytes > profile.video.maxFileSizeMb * 1_000_000) {
+ block("file_too_large", `file is larger than ${profile.video.maxFileSizeMb} MB`);
+ }
+ const codec = probe.videoCodec.toLowerCase();
+ const expectedCodec = profile.video.codec === "hevc" ? ["hevc", "h265"] : ["h264", "avc1"];
+ if (!expectedCodec.includes(codec)) {
+ block("codec_mismatch", `video codec ${probe.videoCodec} is not ${profile.video.codec}`);
+ }
+ if (probe.audioCodec !== null && probe.audioCodec.toLowerCase() !== "aac") {
+ issues.push({
+ check: "technical",
+ severity: "warn",
+ code: "audio_codec",
+ message: `audio codec ${probe.audioCodec} is not AAC`,
+ });
+ }
+ const target = edl.targetLufs ?? profile.audio.targetLufs;
+ if (probe.audioCodec !== null) {
+ if (probe.integratedLufs === null) {
+ block("loudness_unmeasured", "loudness was not measured");
+ } else if (Math.abs(probe.integratedLufs - target) > LOUDNESS_TOLERANCE_LU) {
+ block("loudness_off_target", `${probe.integratedLufs} LUFS, target ${target} LUFS`);
+ }
+ if (
+ probe.truePeakDb !== null &&
+ probe.truePeakDb > profile.audio.truePeakDb + TRUE_PEAK_TOLERANCE_DB
+ ) {
+ block("true_peak_exceeded", `true peak ${probe.truePeakDb} dBTP`);
+ }
+ }
+ const leaks = findMetadataLeaks(probe.tags);
+ if (leaks.length > 0) block("metadata_leak", `metadata not removed: ${leaks.join(", ")}`);
+ return issues;
}
-export function findMetadataLeaks(_tags: Record): string[] {
- return notImplemented("findMetadataLeaks");
+const URL_PATTERN =
+ /\b(?:https?:\/\/|www\.)\S+|\b[a-z0-9-]+(?:\.[a-z0-9-]+)*\.(?:com|net|org|io|de|at|ch|eu|ly|co|me|link|shop|store|app|tv|gg|xyz)\b(?:\/\S*)?/i;
+const HASHTAG_PATTERN = /#[\p{L}\p{N}_]+/gu;
+
+/** Profile patterns compile case-insensitively; an invalid one is reported, never thrown. */
+function compilePattern(pattern: string): RegExp | null {
+ try {
+ return new RegExp(pattern, "i");
+ } catch {
+ return null;
+ }
}
export function checkCopy(
- _profile: PlatformProfile,
- _copy: { title: string | null; caption: string | null; tags: string[] },
+ profile: PlatformProfile,
+ copy: { title: string | null; caption: string | null; tags: string[] },
): GateIssue[] {
- return notImplemented("checkCopy");
+ const issues: GateIssue[] = [];
+ const block = (code: string, message: string) =>
+ issues.push({ check: "text", severity: "block", code, message });
+ const { text } = profile;
+ const title = copy.title ?? "";
+ const caption = copy.caption ?? "";
+ if (text.titleMaxChars !== undefined && [...title].length > text.titleMaxChars) {
+ block(
+ "title_too_long",
+ `title has ${[...title].length} characters, limit ${text.titleMaxChars}`,
+ );
+ }
+ if ([...caption].length > text.captionMaxChars) {
+ block(
+ "caption_too_long",
+ `caption has ${[...caption].length} characters, limit ${text.captionMaxChars}`,
+ );
+ }
+ const hashtags = new Set();
+ for (const tag of copy.tags) {
+ const clean = tag.replace(/^#+/, "").trim().toLowerCase();
+ if (clean) hashtags.add(clean);
+ }
+ for (const match of caption.matchAll(HASHTAG_PATTERN))
+ hashtags.add(match[0].slice(1).toLowerCase());
+ if (hashtags.size > text.hashtagLimit) {
+ block("too_many_hashtags", `${hashtags.size} hashtags, limit ${text.hashtagLimit}`);
+ }
+ const fields: Array<[string, string]> = [
+ ["title", title],
+ ["caption", caption],
+ ...copy.tags.map((tag, i): [string, string] => [`tags[${i}]`, tag]),
+ ];
+ for (const banned of text.bannedPatterns) {
+ const regex = compilePattern(banned.pattern);
+ if (!regex) {
+ block("invalid_banned_pattern", `profile pattern cannot be checked: ${banned.reason}`);
+ continue;
+ }
+ for (const [field, value] of fields) {
+ if (value && regex.test(value)) block("banned_pattern", `${field}: ${banned.reason}`);
+ }
+ }
+ if (text.linkPolicy !== "allowed") {
+ for (const [field, value] of fields) {
+ if (!value || !URL_PATTERN.test(value)) continue;
+ if (text.linkPolicy === "forbidden") block("link_forbidden", `${field} contains a link`);
+ else block("link_bio_only", `${field} contains a link; links belong in the bio`);
+ }
+ }
+ return issues;
+}
+
+function checkContent(input: GateInput): GateIssue[] {
+ const { profile } = input;
+ const issues: GateIssue[] = [];
+ const thresholds = profile.content.thresholds;
+ const gapMs = (1000 / profile.content.sampleFps) * 1.5;
+ const sampled = Math.max(input.content.length, input.sampledFrames, 1);
+ for (const category of CONTENT_CATEGORIES) {
+ const threshold = thresholds[category];
+ if (threshold === undefined) continue;
+ const hits = input.content.flatMap((sample) => {
+ const score = sample.scores[category];
+ return score !== undefined && score > threshold ? [{ tMs: sample.tMs, score }] : [];
+ });
+ if (hits.length === 0) continue;
+ const blocking =
+ profile.content.blockOn === "any_frame" ||
+ hits.length / sampled > (profile.content.blockRatio ?? 0);
+ for (const range of ranges(hits, gapMs)) {
+ const score = Math.max(...range.map((h) => h.score));
+ issues.push({
+ check: "content",
+ severity: blocking ? "block" : "warn",
+ code: "content_threshold",
+ message: `${category} ${score.toFixed(2)} above ${threshold}`,
+ atMs: ms(range[0]!.tMs),
+ endMs: ms(range.at(-1)!.tMs),
+ category,
+ score,
+ });
+ }
+ }
+ return issues;
+}
+
+function checkAnonymization(input: GateInput): GateIssue[] {
+ const issues: GateIssue[] = [];
+ const protectedIds = new Set(
+ input.people.filter((p) => p.anonymizeByDefault !== "none").map((p) => p.id),
+ );
+ const gapMs = 1000;
+ const uncovered = input.faces.filter(
+ (face) => face.personId !== null && protectedIds.has(face.personId) && face.coverage < 1,
+ );
+ const byPerson = new Map();
+ for (const face of uncovered) {
+ const list = byPerson.get(face.personId!) ?? [];
+ list.push(face);
+ byPerson.set(face.personId!, list);
+ }
+ for (const [personId, faces] of [...byPerson.entries()].sort((a, b) =>
+ a[0].localeCompare(b[0]),
+ )) {
+ for (const range of ranges(faces, gapMs)) {
+ const coverage = Math.min(...range.map((f) => f.coverage));
+ issues.push({
+ check: "anonymization",
+ severity: "block",
+ code: "face_not_covered",
+ message: `face covered ${(coverage * 100).toFixed(0)} %`,
+ atMs: ms(range[0]!.tMs),
+ endMs: ms(range.at(-1)!.tMs),
+ personId,
+ score: coverage,
+ });
+ }
+ }
+ if (protectedIds.size > 0) {
+ const unknown = input.faces.filter((face) => face.personId === null);
+ for (const range of ranges(unknown, gapMs)) {
+ issues.push({
+ check: "anonymization",
+ severity: "block",
+ code: "unknown_face",
+ message: "a face that cannot be matched appears next to a protected person",
+ atMs: ms(range[0]!.tMs),
+ endMs: ms(range.at(-1)!.tMs),
+ });
+ }
+ }
+ for (const gap of input.trackingGaps) {
+ if (gap.endMs - gap.startMs <= MAX_TRACKING_GAP_MS) continue;
+ issues.push({
+ check: "anonymization",
+ severity: "block",
+ code: "tracking_gap",
+ message: `face tracking lost for ${Math.round(gap.endMs - gap.startMs)} ms (needs review)`,
+ atMs: ms(gap.startMs),
+ endMs: ms(gap.endMs),
+ ...(gap.personId ? { personId: gap.personId } : {}),
+ });
+ }
+ const visibleMarks = input.marks.filter((mark) => mark.residualVisibility > MAX_MARK_RESIDUAL);
+ const byMark = new Map();
+ for (const mark of visibleMarks) {
+ const list = byMark.get(mark.markId) ?? [];
+ list.push(mark);
+ byMark.set(mark.markId, list);
+ }
+ for (const [markId, marks] of [...byMark.entries()].sort((a, b) => a[0].localeCompare(b[0]))) {
+ for (const range of ranges(marks, gapMs)) {
+ const residual = Math.max(...range.map((m) => m.residualVisibility));
+ issues.push({
+ check: "anonymization",
+ severity: "block",
+ code: "mark_visible",
+ message: `treated mark still ${(residual * 100).toFixed(0)} % visible`,
+ atMs: ms(range[0]!.tMs),
+ endMs: ms(range.at(-1)!.tMs),
+ markId,
+ score: residual,
+ });
+ }
+ }
+ return issues;
+}
+
+function checkConsent(input: GateInput): GateIssue[] {
+ const { consent } = input.profile;
+ if (!consent.requirePerformerConsent && !consent.requireAgeVerification) return [];
+ const issues: GateIssue[] = [];
+ for (const person of [...input.people].sort((a, b) => a.id.localeCompare(b.id))) {
+ if (consent.requirePerformerConsent && !person.consentRecordedAt) {
+ issues.push({
+ check: "consent",
+ severity: "block",
+ code: "consent_missing",
+ message: "no recorded consent",
+ personId: person.id,
+ });
+ }
+ if (consent.requireAgeVerification && !person.ageVerifiedAt) {
+ issues.push({
+ check: "consent",
+ severity: "block",
+ code: "age_unverified",
+ message: "age is not verified",
+ personId: person.id,
+ });
+ }
+ }
+ for (const range of ranges(
+ input.faces.filter((face) => face.personId === null),
+ 1000,
+ )) {
+ issues.push({
+ check: "consent",
+ severity: "block",
+ code: "unknown_performer",
+ message: "a visible face is not assigned to a person with consent",
+ atMs: ms(range[0]!.tMs),
+ endMs: ms(range.at(-1)!.tMs),
+ });
+ }
+ return issues;
+}
+
+/** Deterministic gate (spec 6). Any blocking issue fails the report. */
+export function evaluateGate(input: GateInput): GateReport {
+ const { profile } = input;
+ const issues: GateIssue[] = [
+ ...checkTechnical(profile, input.edl, input.ffprobe),
+ ...checkContent(input),
+ ...checkAnonymization(input),
+ ...checkConsent(input),
+ ...checkCopy(profile, input.copy),
+ ];
+ const alterations = new Set([...input.aiAlterations, ...input.edl.metadata.aiAlterations]);
+ if (
+ alterations.size > 0 &&
+ profile.disclosure.aiAlteredLabelRequired &&
+ !input.bundleHasDisclosure
+ ) {
+ issues.push({
+ check: "disclosure",
+ severity: "block",
+ code: "ai_label_missing",
+ message: `AI alterations (${[...alterations].sort().join(", ")}) need the platform label`,
+ });
+ }
+ if (input.watermarkBox && !boxInsideSafeZone(input.watermarkBox, profile)) {
+ issues.push({
+ check: "brand",
+ severity: "warn",
+ code: "watermark_outside_safe_zone",
+ message: "watermark is covered by platform interface",
+ });
+ }
+ return {
+ profileId: profile.id,
+ passed: !issues.some((issue) => issue.severity === "block"),
+ checkedAt: input.now.toISOString(),
+ sampledFrames: Math.max(0, Math.round(input.sampledFrames)),
+ issues,
+ autoFixRound: Math.min(MAX_AUTO_FIX_ROUNDS, Math.max(0, Math.round(input.autoFixRound))),
+ };
}
export interface AutoFixResult {
@@ -73,15 +473,227 @@ export interface AutoFixResult {
changes: string[];
}
+const PADDING_STEP = 25;
+const CUT_MARGIN_MS = 500;
+const MIN_KEEP_MS = 500;
+
+/** Lay clips out back to back again, keeping blended transitions' overlap. */
+function repack(edl: Edl): Edl {
+ const clips = sortedVideoClips(edl);
+ const ids = new Set(clips.map((c) => c.id));
+ const transitions = edl.transitions.filter((t) => ids.has(t.fromClipId) && ids.has(t.toClipId));
+ const adjacent = new Set();
+ for (let i = 0; i + 1 < clips.length; i++) adjacent.add(`${clips[i]!.id}>${clips[i + 1]!.id}`);
+ const kept = transitions.filter((t) => adjacent.has(`${t.fromClipId}>${t.toClipId}`));
+ const blend = new Map(
+ kept
+ .filter((t) => BLENDED_TRANSITIONS.has(t.kind) && t.durationMs > 0)
+ .map((t) => [`${t.fromClipId}>${t.toClipId}`, t.durationMs]),
+ );
+ let cursor = 0;
+ const packed: EdlVideoClip[] = clips.map((clip, i) => {
+ const previous = clips[i - 1];
+ let overlap = previous ? (blend.get(`${previous.id}>${clip.id}`) ?? 0) : 0;
+ overlap = Math.min(overlap, clipLengthMs(clip) - 1, cursor);
+ const atMs = Math.max(0, cursor - Math.max(0, overlap));
+ cursor = atMs + clipLengthMs(clip);
+ return { ...clip, atMs };
+ });
+ const regions = edl.redactions.regions.filter((r) => ids.has(r.clipId));
+ const next = {
+ ...edl,
+ tracks: { ...edl.tracks, video: packed },
+ transitions: kept.filter((t) => {
+ if (!BLENDED_TRANSITIONS.has(t.kind)) return true;
+ const from = packed.find((c) => c.id === t.fromClipId)!;
+ const to = packed.find((c) => c.id === t.toClipId)!;
+ return t.durationMs < Math.min(clipLengthMs(from), clipLengthMs(to));
+ }),
+ redactions: { ...edl.redactions, regions },
+ };
+ // Overlays never grow or move; they are only clamped to the new end.
+ const total = edlDurationMs(next);
+ next.overlays = edl.overlays.flatMap((overlay) => {
+ if (overlay.atMs >= total) return [];
+ return [{ ...overlay, durationMs: Math.min(overlay.durationMs, total - overlay.atMs) }];
+ });
+ return next;
+}
+
/**
* Compliance-only repairs: trim or drop offending clips, change crop, widen
* redaction padding, swap to another segment. Never adds obfuscation aimed at
* classifiers. Returns null when the round limit is reached or nothing safe applies.
+ *
+ * - Content blocks and tracking gaps: the clip showing that moment is swapped
+ * for an alternative segment of at least the same length that does not
+ * overlap the offending source range, else trimmed (when the range sits at
+ * one end) or dropped. The timeline is then repacked.
+ * - Uncovered faces: padding of the person's face redaction grows by 25
+ * points; a missing redaction is added as blur.
+ * - Unknown faces: the unassigned-face redaction grows or is added as blur.
+ * - Visible marks: the mark treatment falls back to cover.
*/
export function planAutoFix(
- _edl: Edl,
- _report: GateReport,
- _alternatives: Array<{ segmentId: string; assetId: string; startMs: number; endMs: number }>,
+ edl: Edl,
+ report: GateReport,
+ alternatives: Array<{ segmentId: string; assetId: string; startMs: number; endMs: number }>,
): AutoFixResult | null {
- return notImplemented("planAutoFix");
+ if (report.autoFixRound >= MAX_AUTO_FIX_ROUNDS || report.passed) return null;
+ const blocks = report.issues.filter((issue) => issue.severity === "block");
+ const changes: string[] = [];
+ let clips: EdlVideoClip[] = edl.tracks.video.map((clip) => ({ ...clip }));
+ let faces: FaceRedaction[] = edl.redactions.faces.map((f) => ({ ...f }));
+ let marks = edl.redactions.marks.map((m) => ({ ...m }));
+ let clipsChanged = false;
+
+ // Source ranges to remove, per clip.
+ const offending = new Map>();
+ for (const issue of blocks) {
+ const timeline =
+ issue.check === "content" ||
+ issue.code === "tracking_gap" ||
+ (issue.code === "mark_visible" &&
+ !edl.redactions.marks.some((m) => m.markId === issue.markId));
+ if (!timeline || issue.atMs === undefined) continue;
+ const endMs = issue.endMs ?? issue.atMs;
+ const step = Math.max(1, Math.floor(1000 / edl.output.fps));
+ const times: number[] = [];
+ for (let t = issue.atMs; t < endMs; t += step) times.push(t);
+ times.push(endMs);
+ for (const t of times) {
+ for (const hit of locateOutputTimeAll(edl, t)) {
+ const list = offending.get(hit.clipId) ?? [];
+ const last = list.at(-1);
+ if (last && last.reason === issue.code && hit.sourceMs - last.end <= step * 4) {
+ last.end = Math.max(last.end, hit.sourceMs);
+ } else {
+ list.push({ start: hit.sourceMs, end: hit.sourceMs, reason: issue.code });
+ }
+ offending.set(hit.clipId, list);
+ }
+ }
+ }
+ const usedAlternatives = new Set();
+ for (const [clipId, found] of [...offending.entries()].sort((a, b) => a[0].localeCompare(b[0]))) {
+ const clip = clips.find((c) => c.id === clipId);
+ if (!clip) continue;
+ const start = Math.max(clip.inMs, Math.min(...found.map((r) => r.start)) - CUT_MARGIN_MS);
+ const end = Math.min(clip.outMs, Math.max(...found.map((r) => r.end)) + CUT_MARGIN_MS);
+ const length = clip.outMs - clip.inMs;
+ const alternative = alternatives
+ .filter(
+ (a) =>
+ !usedAlternatives.has(a.segmentId) &&
+ a.segmentId !== clip.segmentId &&
+ a.endMs - a.startMs >= length &&
+ !(a.assetId === clip.assetId && a.startMs < end && start < a.endMs),
+ )
+ .sort((a, b) => a.segmentId.localeCompare(b.segmentId))[0];
+ clipsChanged = true;
+ if (alternative) {
+ usedAlternatives.add(alternative.segmentId);
+ Object.assign(clip, {
+ assetId: alternative.assetId,
+ segmentId: alternative.segmentId,
+ inMs: alternative.startMs,
+ outMs: alternative.startMs + length,
+ reframe: clip.reframe === "none" ? "none" : "auto",
+ });
+ changes.push(
+ `clip ${clip.id}: swapped to segment ${alternative.segmentId} (${found[0]!.reason})`,
+ );
+ } else if (start - clip.inMs >= MIN_KEEP_MS && end >= clip.outMs - 1) {
+ clip.outMs = start;
+ changes.push(`clip ${clip.id}: trimmed the end at ${start} ms (${found[0]!.reason})`);
+ } else if (clip.outMs - end >= MIN_KEEP_MS && start <= clip.inMs + 1) {
+ clip.inMs = end;
+ changes.push(`clip ${clip.id}: trimmed the start to ${end} ms (${found[0]!.reason})`);
+ } else {
+ clips = clips.filter((c) => c.id !== clip.id);
+ changes.push(`clip ${clip.id}: removed (${found[0]!.reason})`);
+ }
+ }
+ for (const clip of clips) {
+ if (Array.isArray(clip.reframe)) {
+ const inside = clip.reframe.filter((k) => k.tMs >= clip.inMs && k.tMs <= clip.outMs);
+ clip.reframe = inside.length > 0 ? inside : "auto";
+ }
+ }
+
+ const widen = (match: (f: FaceRedaction) => boolean, add: () => FaceRedaction, label: string) => {
+ const existing = faces.filter(match);
+ if (existing.length === 0) {
+ faces = [...faces, add()];
+ changes.push(`${label}: added blur redaction`);
+ return;
+ }
+ for (const face of existing) {
+ if (face.paddingPct >= 100) continue;
+ face.paddingPct = Math.min(100, face.paddingPct + PADDING_STEP);
+ changes.push(`${label}: redaction ${face.id} padding ${face.paddingPct} %`);
+ }
+ };
+ const widened = new Set();
+ for (const issue of blocks) {
+ if (issue.code === "face_not_covered" && issue.personId && !widened.has(issue.personId)) {
+ const personId = issue.personId;
+ widened.add(personId);
+ widen(
+ (f) => "personId" in f.target && f.target.personId === personId,
+ () => ({
+ id: `fix-face-${widened.size}`,
+ target: { personId },
+ mode: "blur",
+ paddingPct: 20 + PADDING_STEP,
+ featherPct: 10,
+ minSizePct: 2,
+ }),
+ `person ${personId}`,
+ );
+ }
+ if (issue.code === "unknown_face" && !widened.has("(unassigned)")) {
+ widened.add("(unassigned)");
+ widen(
+ (f) => "unassignedFaces" in f.target,
+ () => ({
+ id: "fix-face-unassigned",
+ target: { unassignedFaces: true },
+ mode: "blur",
+ paddingPct: 20 + PADDING_STEP,
+ featherPct: 10,
+ minSizePct: 2,
+ }),
+ "unassigned faces",
+ );
+ }
+ if (issue.code === "mark_visible" && issue.markId) {
+ const markId = issue.markId;
+ marks = marks.map((mark) => {
+ if (mark.markId !== markId || mark.treatment === "cover") return mark;
+ changes.push(`mark ${markId}: ${mark.treatment} replaced by cover`);
+ return { ...mark, treatment: "cover" as const };
+ });
+ }
+ }
+ if (changes.length === 0 || clips.length === 0) return null;
+
+ let next: Edl = {
+ ...edl,
+ tracks: { ...edl.tracks, video: clips },
+ redactions: { ...edl.redactions, faces, marks },
+ };
+ if (clipsChanged) next = repack(next);
+ const stillAltered = new Set(
+ next.metadata.aiAlterations.filter(
+ (a) => a !== "inpaint" || marks.some((m) => m.treatment === "inpaint"),
+ ),
+ );
+ const explicit = edl.metadata.aiAltered && edl.metadata.aiAlterations.length === 0;
+ next.metadata = {
+ ...next.metadata,
+ aiAlterations: [...stillAltered],
+ aiAltered: stillAltered.size > 0 || explicit,
+ };
+ return { edl: next, changes };
}
diff --git a/packages/media/src/marketing.test.ts b/packages/media/src/marketing.test.ts
new file mode 100644
index 0000000..f2d43fc
--- /dev/null
+++ b/packages/media/src/marketing.test.ts
@@ -0,0 +1,320 @@
+import type { MetricCsvMapping, MotionTemplateInfo } from "@rakazo/contracts";
+import { MetricCsvMappingSchema } from "@rakazo/contracts";
+import { describe, expect, it } from "vitest";
+import {
+ buildTrackingUrl,
+ contrastRatio,
+ introFits,
+ normalizeHashtags,
+ parseCsvRecords,
+ parseMetricsCsv,
+ parseMoneyToCents,
+ proposeIntroVariants,
+ uploadChecklist,
+ validateBrandKit,
+} from "./marketing.js";
+import { testKit, testPaidProfile, testProfile } from "./test-fixtures.js";
+
+const utm = { source: "example-short-vertical", medium: "short", campaign: "spring launch" };
+
+describe("buildTrackingUrl", () => {
+ it("appends UTM parameters and keeps the existing query and fragment", () => {
+ expect(buildTrackingUrl("https://example.com/shop", utm)).toBe(
+ "https://example.com/shop?utm_source=example-short-vertical&utm_medium=short&utm_campaign=spring%20launch",
+ );
+ expect(
+ buildTrackingUrl("https://example.com/p?ref=a%20b&x=1&utm_source=old#top", {
+ ...utm,
+ content: "thumb-2",
+ term: "ä&b",
+ }),
+ ).toBe(
+ "https://example.com/p?ref=a%20b&x=1&utm_source=example-short-vertical&utm_medium=short&utm_campaign=spring%20launch&utm_content=thumb-2&utm_term=%C3%A4%26b#top",
+ );
+ expect(buildTrackingUrl("http://example.com", utm)).toMatch(
+ /^http:\/\/example\.com\/\?utm_source=/,
+ );
+ });
+
+ it("rejects non-http schemes and invalid URLs", () => {
+ expect(() => buildTrackingUrl("javascript:alert(1)", utm)).toThrow(/http/);
+ expect(() => buildTrackingUrl("ftp://example.com", utm)).toThrow(/http/);
+ expect(() => buildTrackingUrl("not a url", utm)).toThrow(/valid/);
+ });
+});
+
+describe("normalizeHashtags", () => {
+ it("normalizes, dedupes and limits", () => {
+ expect(
+ normalizeHashtags(["#Summer", "summer", "##new day", "über-cool!", "", "#"], 10),
+ ).toEqual(["#Summer", "#newday", "#übercool"]);
+ expect(normalizeHashtags(["a", "b", "c"], 2)).toEqual(["#a", "#b"]);
+ expect(normalizeHashtags(["a"], 0)).toEqual([]);
+ });
+});
+
+describe("CSV", () => {
+ const mapping = (overrides: Partial = {}): MetricCsvMapping =>
+ MetricCsvMappingSchema.parse({
+ name: "Example",
+ columns: {
+ publication: "Link",
+ observedAt: "Date",
+ views: "Views",
+ clicks: "Clicks",
+ conversions: "Sales",
+ revenue: "Revenue",
+ },
+ publicationKey: "tracking_url",
+ ...overrides,
+ });
+
+ it("parses RFC 4180 records with quotes, delimiters and newlines in fields", () => {
+ expect(parseCsvRecords('a,"b,c","say ""hi""\nthere"\r\n1,2,3\n', ",")).toEqual([
+ ["a", "b,c", 'say "hi"\nthere'],
+ ["1", "2", "3"],
+ ]);
+ expect(parseCsvRecords("x;y\n", ";")).toEqual([["x", "y"]]);
+ expect(parseCsvRecords("", ",")).toEqual([]);
+ });
+
+ it("imports metrics with BOM, comma delimiter and major-unit revenue", () => {
+ const csv =
+ 'Link,Date,Views,Clicks,Sales,Revenue\n"https://example.com/?utm_content=a,b",2026-09-01,"1,200",30,2,"19.98"\n';
+ const result = parseMetricsCsv(csv, mapping());
+ expect(result.skipped).toEqual([]);
+ expect(result.rows).toEqual([
+ {
+ row: 2,
+ publicationKey: "https://example.com/?utm_content=a,b",
+ observedAt: new Date("2026-09-01T00:00:00Z"),
+ views: 1200,
+ clicks: 30,
+ conversions: 2,
+ revenueCents: 1998,
+ currency: "EUR",
+ },
+ ]);
+ });
+
+ it("handles semicolons, tabs, decimal commas, currencies and skipped rows", () => {
+ const semicolon = [
+ "link;date;views;clicks;sales;revenue;currency",
+ "pub-1;01.09.2026;1.500;12;1;1.234,50 €;",
+ "pub-2;2026/09/02;10;1;0;12,5;usd",
+ "pub-3;31.02.2026;1;0;0;0;",
+ "pub-4;2026-09-03;ten;0;0;0;",
+ ";2026-09-03;1;0;0;0;",
+ "pub-5;2026-09-04;1;0;0;-5;",
+ "pub-6;2026-09-04;1;0;0;1,2,3;",
+ "pub-7;2026-09-04;1;0;0;5;EURO",
+ ";;;;;;",
+ "pub-8;2026-09-05T10:30:00Z;7;;;$3.10;",
+ ].join("\r\n");
+ const result = parseMetricsCsv(
+ semicolon,
+ mapping({
+ delimiter: ";",
+ columns: {
+ publication: "LINK",
+ observedAt: "date",
+ views: "views",
+ clicks: "clicks",
+ conversions: "sales",
+ revenue: "revenue",
+ currency: "currency",
+ },
+ }),
+ );
+ expect(result.rows.map((r) => [r.publicationKey, r.views, r.revenueCents, r.currency])).toEqual(
+ [
+ ["pub-1", 1500, 123_450, "EUR"],
+ ["pub-2", 10, 1250, "USD"],
+ ["pub-8", 7, 310, "USD"],
+ ],
+ );
+ expect(result.rows[2]!.observedAt).toEqual(new Date("2026-09-05T10:30:00Z"));
+ expect(result.skipped).toEqual([
+ { row: 4, reason: "date is not recognized" },
+ { row: 5, reason: "views is not a whole number" },
+ { row: 6, reason: "publication is empty" },
+ { row: 7, reason: "revenue is negative" },
+ { row: 8, reason: "revenue is not a number" },
+ { row: 9, reason: "currency is not a 3-letter code" },
+ ]);
+
+ const tabs = parseMetricsCsv(
+ "Link\tDate\tRevenue\nx\t2026-09-01\t250\n",
+ mapping({
+ delimiter: "\t",
+ revenueUnit: "cents",
+ columns: { publication: "Link", observedAt: "Date", revenue: "Revenue" },
+ }),
+ );
+ expect(tabs.rows[0]).toMatchObject({ views: 0, clicks: 0, conversions: 0, revenueCents: 250 });
+ });
+
+ it("reports missing columns and empty files", () => {
+ expect(parseMetricsCsv("Other,Date\n", mapping())).toEqual({
+ rows: [],
+ skipped: [{ row: 1, reason: "missing columns: Link, Views, Clicks, Sales, Revenue" }],
+ });
+ expect(parseMetricsCsv("", mapping()).skipped).toEqual([{ row: 1, reason: "file is empty" }]);
+ });
+
+ it("converts money in either notation to cents", () => {
+ expect(parseMoneyToCents("1,234.56")).toEqual({ cents: 123_456, currency: null });
+ expect(parseMoneyToCents("1.234,56")).toEqual({ cents: 123_456, currency: null });
+ expect(parseMoneyToCents("1,234")).toEqual({ cents: 123_400, currency: null });
+ expect(parseMoneyToCents("0.995")).toEqual({ cents: 100, currency: null });
+ expect(parseMoneyToCents("2.5")).toEqual({ cents: 250, currency: null });
+ expect(parseMoneyToCents("CHF 7")).toEqual({ cents: 700, currency: "CHF" });
+ expect(parseMoneyToCents("12.345.678,9")).toEqual({ cents: 1_234_567_890, currency: null });
+ expect(parseMoneyToCents("1,5,0")).toBeNull();
+ });
+});
+
+describe("intro", () => {
+ const templates: MotionTemplateInfo[] = [
+ {
+ id: "intro-glitch",
+ kind: "intro",
+ name: "Glitch energy",
+ minDurationMs: 1000,
+ maxDurationMs: 5000,
+ params: [{ name: "primaryColor", type: "color", required: true }],
+ },
+ {
+ id: "intro-elegant",
+ kind: "intro",
+ name: "Elegant fade",
+ minDurationMs: 2000,
+ maxDurationMs: 3000,
+ params: [
+ { name: "brandName", type: "string", required: true },
+ { name: "sound", type: "string", required: false },
+ ],
+ },
+ {
+ id: "intro-kinetic",
+ kind: "intro",
+ name: "Kinetic type",
+ minDurationMs: 1500,
+ maxDurationMs: 4000,
+ params: [],
+ },
+ {
+ id: "intro-logo",
+ kind: "intro",
+ name: "Minimal logo reveal",
+ minDurationMs: 1500,
+ maxDurationMs: 4000,
+ params: [{ name: "logoArtifactId", type: "string", required: true }],
+ },
+ {
+ id: "outro-a",
+ kind: "outro",
+ name: "Outro",
+ minDurationMs: 1000,
+ maxDurationMs: 3000,
+ params: [],
+ },
+ ];
+
+ it("keeps shorts hook-first and allows short intros on long-form profiles", () => {
+ expect(introFits(testProfile(), 1500)).toBe(false);
+ expect(introFits(testProfile(), 0)).toBe(true);
+ expect(introFits(testPaidProfile(), 3000)).toBe(true);
+ expect(introFits(testPaidProfile(), 5000)).toBe(false);
+ });
+
+ it("proposes three distinct variants ordered by tone", () => {
+ const kit = testKit({ tone: "calm, premium", logoArtifactIds: ["logo-1"] });
+ const variants = proposeIntroVariants(kit, templates, 3);
+ expect(variants.map((v) => v.template.templateId)).toEqual([
+ "intro-elegant",
+ "intro-logo",
+ "intro-glitch",
+ ]);
+ expect(new Set(variants.map((v) => v.template.templateId)).size).toBe(3);
+ for (const variant of variants) {
+ expect(variant.template.durationMs).toBeGreaterThanOrEqual(1500);
+ expect(variant.template.durationMs).toBeLessThanOrEqual(4000);
+ }
+ expect(variants[0]!.template.params).toEqual({ brandName: "Studio Example", sound: "music-1" });
+ expect(variants[0]!.rationale).toContain('matches the tone "calm, premium"');
+ expect(variants[1]!.template.params).toEqual({ logoArtifactId: "logo-1" });
+ expect(proposeIntroVariants(kit, templates, 3)).toEqual(variants);
+ const energetic = proposeIntroVariants(testKit({ tone: "bold and energetic" }), templates, 3);
+ expect(energetic[0]!.template).toMatchObject({
+ templateId: "intro-glitch",
+ params: { primaryColor: "#112233" },
+ });
+ expect(proposeIntroVariants(kit, [], 3)).toEqual([]);
+ expect(
+ proposeIntroVariants(kit, templates.slice(0, 1), 3).map((v) => v.template.durationMs),
+ ).toEqual([2500, 3000, 1500]);
+ });
+});
+
+describe("validateBrandKit", () => {
+ it("flags low contrast, duplicate presets, unlicensed music and fonts", () => {
+ expect(validateBrandKit(testKit())).toEqual([]);
+ expect(contrastRatio("#FFFFFF", "#000000")).toBeCloseTo(21, 5);
+ const preset = testKit().captionPresets[0]!;
+ const warnings = validateBrandKit(
+ testKit({
+ colors: { ...testKit().colors, text: "#777777", background: "#888888" },
+ captionPresets: [preset, { ...preset, backgroundColor: "#EEEEEE" }],
+ musicBeds: [
+ { artifactId: "m", title: "Borrowed", license: "unknown", commercialUse: false },
+ ],
+ fonts: [{ family: "Nameless", artifactId: "f", role: "body" }],
+ }),
+ );
+ expect(warnings).toEqual([
+ "text on background has contrast 1.3:1, below 4.5:1",
+ 'caption preset id "clean" is used twice',
+ 'caption preset "clean" has contrast 1.2:1, below 4.5:1',
+ 'music bed "Borrowed" is not licensed for commercial use',
+ 'font "Nameless" has no license note',
+ ]);
+ });
+});
+
+describe("uploadChecklist", () => {
+ it("lists the AI label, age restriction, link policy and captions where they apply", () => {
+ const paid = uploadChecklist({
+ profile: testPaidProfile(),
+ aiAltered: true,
+ hasCaptions: true,
+ trackingUrl: "https://example.com/?utm_source=x",
+ });
+ expect(paid).toContain("# Upload checklist: Example paid video");
+ expect(paid).toContain("AI-generated or altered content label");
+ expect(paid).toContain("age-restricted");
+ expect(paid).toContain("Add the tracking link: ");
+ expect(paid).toContain("SRT or VTT");
+
+ const short = uploadChecklist({
+ profile: testProfile({ captions: { recommended: true, required: true } }),
+ aiAltered: false,
+ hasCaptions: false,
+ trackingUrl: "https://example.com/",
+ });
+ expect(short).not.toContain("AI-generated");
+ expect(short).not.toContain("age-restricted");
+ expect(short).toContain("in the profile bio, not in the caption");
+ expect(short).toContain("Captions are required");
+ expect(short).toContain("at most 3 hashtags");
+ expect(short.split("\n").filter((l) => l.startsWith("- [ ] "))).toHaveLength(4);
+
+ const relaxed = uploadChecklist({
+ profile: testProfile({ disclosure: { aiAlteredLabelRequired: false } }),
+ aiAltered: true,
+ hasCaptions: true,
+ trackingUrl: null,
+ });
+ expect(relaxed).not.toContain("AI-generated");
+ });
+});
diff --git a/packages/media/src/marketing.ts b/packages/media/src/marketing.ts
index e63db11..2a0ee0c 100644
--- a/packages/media/src/marketing.ts
+++ b/packages/media/src/marketing.ts
@@ -6,15 +6,70 @@ import type {
PlatformProfile,
Utm,
} from "@rakazo/contracts";
-import { notImplemented } from "./not-implemented.js";
-/** Append UTM parameters to an http(s) target. Rejects other schemes. */
-export function buildTrackingUrl(_targetUrl: string, _utm: Utm): string {
- return notImplemented("buildTrackingUrl");
+const UTM_KEYS: Array<[keyof Utm, string]> = [
+ ["source", "utm_source"],
+ ["medium", "utm_medium"],
+ ["campaign", "utm_campaign"],
+ ["content", "utm_content"],
+ ["term", "utm_term"],
+];
+
+/**
+ * Append UTM parameters to an http(s) target. Rejects other schemes. Existing
+ * query parameters keep their exact encoding; existing utm_* values for the
+ * keys being set are replaced, and the fragment stays last.
+ */
+export function buildTrackingUrl(targetUrl: string, utm: Utm): string {
+ let url: URL;
+ try {
+ url = new URL(targetUrl.trim());
+ } catch {
+ throw new Error("tracking target is not a valid URL");
+ }
+ if (url.protocol !== "http:" && url.protocol !== "https:") {
+ throw new Error("tracking target must use http or https");
+ }
+ const setting = UTM_KEYS.filter(([key]) => utm[key]?.trim());
+ const replaced = new Set(setting.map(([, name]) => name));
+ const kept = url.search
+ .replace(/^\?/, "")
+ .split("&")
+ .filter((part) => {
+ if (!part) return false;
+ const rawKey = part.split("=")[0] ?? "";
+ let key = rawKey;
+ try {
+ key = decodeURIComponent(rawKey.replace(/\+/g, " "));
+ } catch {
+ // keep undecodable keys as they are
+ }
+ return !replaced.has(key);
+ });
+ const added = setting.map(
+ ([key, name]) => `${name}=${encodeURIComponent((utm[key] as string).trim())}`,
+ );
+ const query = [...kept, ...added].join("&");
+ return `${url.origin}${url.pathname}${query ? `?${query}` : ""}${url.hash}`;
}
-export function normalizeHashtags(_tags: string[], _limit: number): string[] {
- return notImplemented("normalizeHashtags");
+/**
+ * Normalize to "#tag": strip leading #, drop spaces and punctuation (letters,
+ * digits and _ stay, in any script), dedupe case-insensitively, and cap.
+ */
+export function normalizeHashtags(tags: string[], limit: number): string[] {
+ const seen = new Set();
+ const out: string[] = [];
+ for (const raw of tags) {
+ const tag = raw.normalize("NFC").replace(/[^\p{L}\p{N}_]/gu, "");
+ if (!tag) continue;
+ const key = tag.toLocaleLowerCase();
+ if (seen.has(key)) continue;
+ seen.add(key);
+ out.push(`#${tag}`);
+ if (out.length >= Math.max(0, limit)) break;
+ }
+ return out.slice(0, Math.max(0, limit));
}
export interface ParsedMetricRow {
@@ -28,39 +83,450 @@ export interface ParsedMetricRow {
currency: string;
}
-/** RFC 4180 CSV parsing with the saved column mapping. */
+/** RFC 4180 records; quoted fields may hold delimiters, quotes ("") and newlines. */
+export function parseCsvRecords(csv: string, delimiter: string): string[][] {
+ const text = csv.replace(/^/, "");
+ const records: string[][] = [];
+ let record: string[] = [];
+ let field = "";
+ let quoted = false;
+ let i = 0;
+ while (i < text.length) {
+ const char = text[i]!;
+ if (quoted) {
+ if (char === '"') {
+ if (text[i + 1] === '"') {
+ field += '"';
+ i += 2;
+ continue;
+ }
+ quoted = false;
+ i += 1;
+ continue;
+ }
+ field += char;
+ i += 1;
+ continue;
+ }
+ if (char === '"' && field.length === 0) {
+ quoted = true;
+ i += 1;
+ } else if (char === delimiter) {
+ record.push(field);
+ field = "";
+ i += 1;
+ } else if (char === "\r" || char === "\n") {
+ record.push(field);
+ records.push(record);
+ record = [];
+ field = "";
+ i += char === "\r" && text[i + 1] === "\n" ? 2 : 1;
+ } else {
+ field += char;
+ i += 1;
+ }
+ }
+ if (field.length > 0 || record.length > 0) {
+ record.push(field);
+ records.push(record);
+ }
+ return records;
+}
+
+const CURRENCY_SYMBOLS: Record = { "€": "EUR", $: "USD", "£": "GBP", "¥": "JPY" };
+
+/** Whole, non-negative count with optional thousands separators. */
+function parseCount(raw: string): number | null {
+ const value = raw.trim().replace(/[\s ']/g, "");
+ if (value === "") return 0;
+ if (/^\d+$/.test(value)) return Number(value);
+ if (/^\d{1,3}([.,]\d{3})+$/.test(value) && new Set(value.match(/[.,]/g)).size === 1) {
+ return Number(value.replace(/[.,]/g, ""));
+ }
+ return null;
+}
+
+/**
+ * Money in major units to cents. With both "," and "." the later one is the
+ * decimal separator; a single separator followed by one or two digits is
+ * decimal, by exactly three digits after 1–999 it groups thousands ("1,234"),
+ * otherwise it is decimal ("0.995"). Extra decimals round half up.
+ */
+export function parseMoneyToCents(raw: string): { cents: number; currency: string | null } | null {
+ let value = raw.trim();
+ let currency: string | null = null;
+ for (const [symbol, code] of Object.entries(CURRENCY_SYMBOLS)) {
+ if (value.includes(symbol)) {
+ currency = code;
+ value = value.replace(symbol, "");
+ }
+ }
+ const code = value.match(/[A-Za-z]{3}/)?.[0];
+ if (code) {
+ currency = code.toUpperCase();
+ value = value.replace(code, "");
+ }
+ value = value.replace(/[\s ']/g, "");
+ if (value === "") return { cents: 0, currency };
+ if (!/^\d[\d.,]*$/.test(value)) return null;
+ const lastComma = value.lastIndexOf(",");
+ const lastDot = value.lastIndexOf(".");
+ let integer = value;
+ let fraction = "";
+ if (lastComma >= 0 && lastDot >= 0) {
+ const decimal = Math.max(lastComma, lastDot);
+ integer = value.slice(0, decimal);
+ fraction = value.slice(decimal + 1);
+ } else if (lastComma >= 0 || lastDot >= 0) {
+ const separator = lastComma >= 0 ? "," : ".";
+ const parts = value.split(separator);
+ const tail = parts.at(-1)!;
+ const decimal =
+ parts.length === 2 &&
+ tail.length >= 1 &&
+ (tail.length <= 2 || !/^[1-9]\d{0,2}$/.test(parts[0]!));
+ if (decimal) {
+ integer = parts[0]!;
+ fraction = tail;
+ } else if (parts.slice(1).every((part) => part.length === 3)) {
+ integer = parts.join("");
+ } else {
+ return null;
+ }
+ }
+ integer = integer.replace(/[.,]/g, "");
+ if (!/^\d+$/.test(integer) || !/^\d*$/.test(fraction)) return null;
+ const twoDigits = `${fraction}00`.slice(0, 2);
+ const roundUp = fraction.length > 2 && Number(fraction[2]) >= 5 ? 1 : 0;
+ return { cents: Number(integer) * 100 + Number(twoDigits) + roundUp, currency };
+}
+
+/** ISO dates and times, YYYY/MM/DD, and DD.MM.YYYY. Ambiguous slash dates are rejected. */
+function parseDate(raw: string): Date | null {
+ const value = raw.trim();
+ let match = value.match(/^(\d{4})[-/](\d{2})[-/](\d{2})$/);
+ if (match) return utcDate(Number(match[1]), Number(match[2]), Number(match[3]));
+ match = value.match(/^(\d{1,2})\.(\d{1,2})\.(\d{4})$/);
+ if (match) return utcDate(Number(match[3]), Number(match[2]), Number(match[1]));
+ if (/^\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}/.test(value)) {
+ const date = new Date(value.replace(" ", "T"));
+ return Number.isNaN(date.getTime()) ? null : date;
+ }
+ return null;
+}
+
+function utcDate(year: number, month: number, day: number): Date | null {
+ const date = new Date(Date.UTC(year, month - 1, day));
+ return date.getUTCFullYear() === year &&
+ date.getUTCMonth() === month - 1 &&
+ date.getUTCDate() === day
+ ? date
+ : null;
+}
+
+/** RFC 4180 CSV parsing with the saved column mapping. Rows count from the header as row 1. */
export function parseMetricsCsv(
- _csv: string,
- _mapping: MetricCsvMapping,
+ csv: string,
+ mapping: MetricCsvMapping,
): { rows: ParsedMetricRow[]; skipped: Array<{ row: number; reason: string }> } {
- return notImplemented("parseMetricsCsv");
+ const records = parseCsvRecords(csv, mapping.delimiter ?? ",");
+ const header = records[0];
+ const rows: ParsedMetricRow[] = [];
+ const skipped: Array<{ row: number; reason: string }> = [];
+ if (!header) return { rows, skipped: [{ row: 1, reason: "file is empty" }] };
+ const names = header.map((name) => name.trim());
+ const column = (name: string | undefined): number => {
+ if (!name) return -1;
+ const exact = names.indexOf(name.trim());
+ if (exact >= 0) return exact;
+ const lower = name.trim().toLowerCase();
+ return names.findIndex((n) => n.toLowerCase() === lower);
+ };
+ const columns = mapping.columns;
+ const index = {
+ publication: column(columns.publication),
+ observedAt: column(columns.observedAt),
+ views: column(columns.views),
+ clicks: column(columns.clicks),
+ conversions: column(columns.conversions),
+ revenue: column(columns.revenue),
+ currency: column(columns.currency),
+ };
+ const missing = (Object.keys(index) as Array).filter(
+ (key) => columns[key] !== undefined && index[key] < 0,
+ );
+ if (missing.length > 0) {
+ return {
+ rows,
+ skipped: [
+ { row: 1, reason: `missing columns: ${missing.map((k) => columns[k]).join(", ")}` },
+ ],
+ };
+ }
+ const defaultCurrency = (mapping.defaultCurrency ?? "EUR").toUpperCase();
+
+ for (const [offset, record] of records.slice(1).entries()) {
+ const row = offset + 2;
+ if (record.every((field) => field.trim() === "")) continue;
+ const cell = (i: number) => (i >= 0 ? (record[i] ?? "") : "");
+ const publicationKey = cell(index.publication).trim();
+ if (!publicationKey) {
+ skipped.push({ row, reason: "publication is empty" });
+ continue;
+ }
+ const observedAt = parseDate(cell(index.observedAt));
+ if (!observedAt) {
+ skipped.push({ row, reason: "date is not recognized" });
+ continue;
+ }
+ const counts = {
+ views: parseCount(cell(index.views)),
+ clicks: parseCount(cell(index.clicks)),
+ conversions: parseCount(cell(index.conversions)),
+ };
+ const invalid = (Object.keys(counts) as Array).find(
+ (key) => counts[key] === null,
+ );
+ if (invalid) {
+ skipped.push({ row, reason: `${invalid} is not a whole number` });
+ continue;
+ }
+ let revenueCents = 0;
+ let currency: string | null = null;
+ const revenueRaw = cell(index.revenue);
+ if (revenueRaw.trim()) {
+ if (/^\s*-/.test(revenueRaw)) {
+ skipped.push({ row, reason: "revenue is negative" });
+ continue;
+ }
+ if (mapping.revenueUnit === "cents") {
+ const value = parseCount(revenueRaw);
+ if (value === null) {
+ skipped.push({ row, reason: "revenue is not a whole number" });
+ continue;
+ }
+ revenueCents = value;
+ } else {
+ const money = parseMoneyToCents(revenueRaw);
+ if (!money) {
+ skipped.push({ row, reason: "revenue is not a number" });
+ continue;
+ }
+ revenueCents = money.cents;
+ currency = money.currency;
+ }
+ }
+ const currencyCell = cell(index.currency).trim().toUpperCase();
+ if (currencyCell && !/^[A-Z]{3}$/.test(currencyCell)) {
+ skipped.push({ row, reason: "currency is not a 3-letter code" });
+ continue;
+ }
+ rows.push({
+ row,
+ publicationKey,
+ observedAt,
+ views: counts.views ?? 0,
+ clicks: counts.clicks ?? 0,
+ conversions: counts.conversions ?? 0,
+ revenueCents,
+ currency: currencyCell || currency || defaultCurrency,
+ });
+ }
+ return { rows, skipped };
}
-/** Whether the brand intro fits before the hook window; shorts put the hook first. */
-export function introFits(_profile: PlatformProfile, _introMs: number): boolean {
- return notImplemented("introFits");
+const SHORT_FORM_MAX_MS = 180_000;
+const MAX_INTRO_MS = 4000;
+
+/** Vertical profiles capped at three minutes: the hook must come before any branding. */
+export function isShortForm(profile: PlatformProfile): boolean {
+ return (
+ profile.video.aspectRatios.includes("9:16") && profile.duration.hardMaxMs <= SHORT_FORM_MAX_MS
+ );
}
-/** Deterministic intro proposals from the brand kit, tone, and available templates. */
+/**
+ * Whether the brand intro may open the video. Short-form vertical profiles
+ * always put the hook first (the brand appears as watermark or end card).
+ * Elsewhere the intro may take at most 4 s and a tenth of the maximum length.
+ */
+export function introFits(profile: PlatformProfile, introMs: number): boolean {
+ if (introMs <= 0) return true;
+ if (isShortForm(profile)) return false;
+ return introMs <= Math.min(MAX_INTRO_MS, profile.duration.hardMaxMs / 10);
+}
+
+const TONE_STYLES: Array<{ pattern: RegExp; keywords: string[] }> = [
+ {
+ pattern: /energ|bold|dynamic|loud|wild|sport|fast|laut|schnell|kraft/i,
+ keywords: ["glitch", "energy", "kinetic"],
+ },
+ {
+ pattern: /elegan|calm|luxur|soft|premium|ruhig|edel|sanft|warm/i,
+ keywords: ["elegant", "fade", "minimal"],
+ },
+ {
+ pattern: /minimal|clean|simple|schlicht|klar|modern/i,
+ keywords: ["minimal", "logo", "fade"],
+ },
+ { pattern: /playful|fun|witzig|verspielt|bunt/i, keywords: ["kinetic", "typo", "glitch"] },
+];
+
+const PARAM_SOURCES: Record string | undefined> = {
+ primarycolor: (kit) => kit.colors.primary,
+ secondarycolor: (kit) => kit.colors.secondary,
+ accentcolor: (kit) => kit.colors.accent,
+ textcolor: (kit) => kit.colors.text,
+ backgroundcolor: (kit) => kit.colors.background,
+ brandname: (kit) => kit.name,
+ title: (kit) => kit.name,
+ name: (kit) => kit.name,
+ logoartifactid: (kit) => kit.logoArtifactIds[0],
+ logo: (kit) => kit.logoArtifactIds[0],
+ fontfamily: (kit) => (kit.fonts.find((f) => f.role === "heading") ?? kit.fonts[0])?.family,
+ soundartifactid: (kit) => kit.musicBeds.find((bed) => bed.commercialUse)?.artifactId,
+ sound: (kit) => kit.musicBeds.find((bed) => bed.commercialUse)?.artifactId,
+};
+
+const VARIANT_DURATIONS = [2500, 2000, 3000, 3500, 1500, 4000];
+
+/**
+ * Deterministic intro proposals from the brand kit, tone, and available
+ * templates: intro templates are ranked by how well their id and name match
+ * the tone's style keywords (then by id), each proposal uses a distinct
+ * template while there are enough, and durations stay within 1.5–4 s and the
+ * template's own limits. Parameters are filled from the kit by name.
+ */
export function proposeIntroVariants(
- _kit: BrandKit,
- _templates: MotionTemplateInfo[],
- _count: number,
+ kit: BrandKit,
+ templates: MotionTemplateInfo[],
+ count: number,
): Array<{ template: MotionTemplateRef; rationale: string }> {
- return notImplemented("proposeIntroVariants");
+ const intros = templates
+ .filter((t) => t.kind === "intro")
+ .sort((a, b) => a.id.localeCompare(b.id));
+ if (intros.length === 0 || count <= 0) return [];
+ const tone = `${kit.tone ?? ""} ${kit.audience ?? ""}`;
+ const keywords = TONE_STYLES.filter((style) => style.pattern.test(tone)).flatMap(
+ (style) => style.keywords,
+ );
+ const affinity = (template: MotionTemplateInfo) => {
+ const haystack = `${template.id} ${template.name}`.toLowerCase();
+ return keywords.reduce(
+ (score, keyword, index) => score + (haystack.includes(keyword) ? keywords.length - index : 0),
+ 0,
+ );
+ };
+ const ranked = [...intros].sort((a, b) => affinity(b) - affinity(a) || a.id.localeCompare(b.id));
+
+ return Array.from({ length: count }, (_, index) => {
+ const template = ranked[index % ranked.length]!;
+ const round = Math.floor(index / ranked.length);
+ const wanted = VARIANT_DURATIONS[(index + round) % VARIANT_DURATIONS.length]!;
+ const durationMs = Math.min(
+ Math.max(wanted, 1500, template.minDurationMs),
+ Math.min(4000, template.maxDurationMs),
+ );
+ const params: Record = {};
+ for (const param of template.params) {
+ const source = PARAM_SOURCES[param.name.toLowerCase().replace(/[^a-z]/g, "")];
+ const value = source?.(kit);
+ if (value !== undefined) params[param.name] = value.slice(0, 200);
+ }
+ const matched = affinity(template) > 0 && kit.tone ? ` matches the tone "${kit.tone}"` : "";
+ return {
+ template: { templateId: template.id, params, durationMs: Math.max(0, durationMs) },
+ rationale: `${template.name}${matched}, ${(durationMs / 1000).toFixed(1)} s`,
+ };
+ });
}
+function luminance(hex: string): number {
+ const channel = (offset: number) => {
+ const value = Number.parseInt(hex.slice(offset, offset + 2), 16) / 255;
+ return value <= 0.03928 ? value / 12.92 : ((value + 0.055) / 1.055) ** 2.4;
+ };
+ return 0.2126 * channel(1) + 0.7152 * channel(3) + 0.0722 * channel(5);
+}
+
+/** WCAG contrast ratio of two #RRGGBB colours. */
+export function contrastRatio(a: string, b: string): number {
+ const [light, dark] = [luminance(a), luminance(b)].sort((x, y) => y - x) as [number, number];
+ return (light + 0.05) / (dark + 0.05);
+}
+
+const MIN_CONTRAST = 4.5;
+
/** Structural brand-kit checks beyond the schema (contrast, duplicate presets). */
-export function validateBrandKit(_kit: BrandKit): string[] {
- return notImplemented("validateBrandKit");
+export function validateBrandKit(kit: BrandKit): string[] {
+ const warnings: string[] = [];
+ const ratio = contrastRatio(kit.colors.text, kit.colors.background);
+ if (ratio < MIN_CONTRAST) {
+ warnings.push(`text on background has contrast ${ratio.toFixed(1)}:1, below ${MIN_CONTRAST}:1`);
+ }
+ const seen = new Set();
+ for (const preset of kit.captionPresets) {
+ if (seen.has(preset.id)) warnings.push(`caption preset id "${preset.id}" is used twice`);
+ seen.add(preset.id);
+ if (preset.backgroundColor) {
+ const presetRatio = contrastRatio(preset.textColor, preset.backgroundColor);
+ if (presetRatio < MIN_CONTRAST) {
+ warnings.push(
+ `caption preset "${preset.id}" has contrast ${presetRatio.toFixed(1)}:1, below ${MIN_CONTRAST}:1`,
+ );
+ }
+ }
+ }
+ for (const bed of kit.musicBeds) {
+ if (!bed.commercialUse)
+ warnings.push(`music bed "${bed.title}" is not licensed for commercial use`);
+ }
+ for (const font of kit.fonts) {
+ if (!font.license?.trim()) warnings.push(`font "${font.family}" has no license note`);
+ }
+ if (kit.watermark && !kit.watermark.artifactId) warnings.push("watermark has no image");
+ return warnings;
}
/** Upload checklist markdown for an export bundle. */
-export function uploadChecklist(_input: {
+export function uploadChecklist(input: {
profile: PlatformProfile;
aiAltered: boolean;
hasCaptions: boolean;
trackingUrl: string | null;
}): string {
- return notImplemented("uploadChecklist");
+ const { profile } = input;
+ const items: string[] = ["Upload the video file and choose the poster as cover image."];
+ if (input.aiAltered && profile.disclosure.aiAlteredLabelRequired) {
+ items.push(
+ "Set the platform's AI-generated or altered content label: this video contains AI alterations.",
+ );
+ }
+ if (profile.disclosure.ageRestrictedFlag) items.push("Mark the upload as age-restricted.");
+ if (profile.disclosure.paidPartnershipLabel) {
+ items.push("Turn on the paid partnership label if this post is sponsored.");
+ }
+ if (input.trackingUrl) {
+ const link = `<${input.trackingUrl}>`;
+ if (profile.text.linkPolicy === "allowed") items.push(`Add the tracking link: ${link}`);
+ else if (profile.text.linkPolicy === "bio_only") {
+ items.push(`Put the tracking link in the profile bio, not in the caption: ${link}`);
+ } else items.push("Do not add links: this platform does not allow them.");
+ } else if (profile.text.linkPolicy === "forbidden") {
+ items.push("Do not add links: this platform does not allow them.");
+ }
+ if (input.hasCaptions) {
+ items.push("Upload the SRT or VTT subtitles, or confirm the burned-in captions are readable.");
+ } else if (profile.captions.required) {
+ items.push("Captions are required on this platform: add them before publishing.");
+ }
+ const limits = [
+ profile.text.titleMaxChars ? `title up to ${profile.text.titleMaxChars} characters` : null,
+ `caption up to ${profile.text.captionMaxChars} characters`,
+ `at most ${profile.text.hashtagLimit} hashtags`,
+ ].filter(Boolean);
+ items.push(`Paste the caption from caption.txt (${limits.join(", ")}).`);
+ return `# Upload checklist: ${profile.name.replace(/[\r\n]+/g, " ")}\n\n${items
+ .map((item) => `- [ ] ${item}`)
+ .join("\n")}\n`;
}
diff --git a/packages/media/src/not-implemented.ts b/packages/media/src/not-implemented.ts
deleted file mode 100644
index 0c4ab30..0000000
--- a/packages/media/src/not-implemented.ts
+++ /dev/null
@@ -1,4 +0,0 @@
-/** Phase A scaffold marker. No call site may remain once Media Studio ships. */
-export function notImplemented(name: string): never {
- throw new Error(`not implemented: ${name}`);
-}
diff --git a/packages/media/src/teaser.test.ts b/packages/media/src/teaser.test.ts
new file mode 100644
index 0000000..e2f218c
--- /dev/null
+++ b/packages/media/src/teaser.test.ts
@@ -0,0 +1,334 @@
+import {
+ MediaProjectGoalSchema,
+ TEASER_FEATURES,
+ TEASER_LEARNING_MIN_SAMPLES,
+} from "@rakazo/contracts";
+import { describe, expect, it } from "vitest";
+import { validateEdl } from "./edl.js";
+import type { ScoredSegmentInput, SegmentFeatures } from "./teaser.js";
+import {
+ autoPlanEdl,
+ defaultTeaserWeights,
+ fitTeaserWeights,
+ rankThumbnailFrames,
+ scoreSegment,
+ selectTeaserSegments,
+ speechHookScore,
+} from "./teaser.js";
+import { testContext, testKit, testPaidProfile, testProfile } from "./test-fixtures.js";
+
+function segment(
+ id: string,
+ assetId: string,
+ startMs: number,
+ endMs: number,
+ features: SegmentFeatures,
+ extra: Partial = {},
+): ScoredSegmentInput {
+ return {
+ id,
+ assetId,
+ startMs,
+ endMs,
+ capturedAt: null,
+ features,
+ paidOnly: false,
+ transcript: null,
+ ...extra,
+ };
+}
+
+/** One 30 s asset building up to a climax at 20–24 s, plus a second asset. */
+const segments: ScoredSegmentInput[] = [
+ segment("s1", "asset-a", 0, 4000, { motion: 0.2, sharpness: 0.8 }),
+ segment(
+ "s2",
+ "asset-a",
+ 4000,
+ 8000,
+ { motion: 0.3, audioPeak: 0.3 },
+ { transcript: "Wait, you won't believe what happens next?" },
+ ),
+ segment("s3", "asset-a", 8000, 12_000, { motion: 0.5, faces: 0.6 }),
+ segment("s4", "asset-a", 12_000, 16_000, { motion: 0.6, audioPeak: 0.5 }),
+ segment("s5", "asset-a", 16_000, 20_000, { motion: 0.7, novelty: 0.6 }, { paidOnly: true }),
+ segment("s6", "asset-a", 20_000, 24_000, {
+ motion: 1,
+ audioPeak: 1,
+ faces: 1,
+ novelty: 1,
+ aesthetics: 1,
+ }),
+ segment("s7", "asset-a", 24_000, 28_000, { motion: 0.4 }),
+ segment("s8", "asset-b", 0, 3000, { motion: 0.5, aesthetics: 0.7 }),
+];
+
+describe("weights and scores", () => {
+ it("defines every feature for every goal", () => {
+ for (const goal of MediaProjectGoalSchema.options) {
+ const weights = defaultTeaserWeights(goal);
+ expect(Object.keys(weights).sort()).toEqual([...TEASER_FEATURES].sort());
+ expect(Object.values(weights).reduce((a, b) => a + b, 0)).toBeCloseTo(1, 6);
+ }
+ expect(defaultTeaserWeights("teaser").speechHook).toBeGreaterThan(
+ defaultTeaserWeights("highlight").speechHook,
+ );
+ });
+
+ it("scores as a weighted mean of clamped features", () => {
+ const weights = defaultTeaserWeights("teaser");
+ expect(scoreSegment({}, weights)).toBe(0);
+ const all = Object.fromEntries(TEASER_FEATURES.map((f) => [f, 1]));
+ expect(scoreSegment(all, weights)).toBeCloseTo(1, 6);
+ expect(scoreSegment({ motion: 5 }, weights)).toBeCloseTo(weights.motion, 6);
+ expect(scoreSegment({ motion: Number.NaN }, weights)).toBe(0);
+ expect(scoreSegment({ motion: 1 }, { ...weights, motion: 0 })).toBe(0);
+ });
+
+ it("detects German and English speech hooks", () => {
+ expect(speechHookScore("")).toBe(0);
+ expect(speechHookScore("the weather was fine")).toBe(0);
+ expect(speechHookScore("Wait, you won't believe this!", "en")).toBeGreaterThan(0.6);
+ expect(speechHookScore("Warum macht das niemand?", "de")).toBeGreaterThan(0.5);
+ expect(speechHookScore("Schau mal, 3 Tricks!", "de-DE")).toBeGreaterThan(0.6);
+ expect(speechHookScore("Here are 5 tips")).toBeGreaterThan(speechHookScore("here are tips"));
+ expect(speechHookScore("Wie geht das? Pass auf! 10 Sekunden, du!")).toBe(1);
+ });
+});
+
+describe("selectTeaserSegments", () => {
+ const profile = testProfile();
+ const select = (overrides: Partial[0]> = {}) =>
+ selectTeaserSegments({
+ segments,
+ profile,
+ goal: "teaser",
+ targetMs: 15_000,
+ weights: defaultTeaserWeights("teaser"),
+ endCardMs: 2000,
+ ...overrides,
+ });
+
+ it("opens with the hook inside the hook window", () => {
+ const selection = select();
+ expect(selection.hookSegmentId).toBe("s2");
+ expect(selection.clips[0]).toMatchObject({ segmentId: "s2", inMs: 4000 });
+ expect(selection.notes[0]).toContain("hook window");
+ });
+
+ it("never uses paid-only segments and ends before the payoff", () => {
+ const selection = select();
+ const ids = selection.clips.map((c) => c.segmentId);
+ expect(ids).not.toContain("s5");
+ expect(ids).not.toContain("s6");
+ expect(ids).not.toContain("s7");
+ const last = selection.clips.at(-1)!;
+ expect(last.assetId).toBe("asset-a");
+ expect(last.outMs).toBeLessThanOrEqual(20_000);
+ expect(selection.notes.some((n) => n.startsWith("open loop"))).toBe(true);
+ });
+
+ it("builds tension and fits the duration bounds minus the end card", () => {
+ const selection = select();
+ const total = selection.clips.reduce((sum, c) => sum + (c.outMs - c.inMs), 0);
+ expect(total).toBeLessThanOrEqual(15_000 - 2000);
+ expect(total).toBeGreaterThanOrEqual(profile.duration.hardMinMs - 2000);
+ for (const clip of selection.clips) expect(clip.outMs).toBeGreaterThan(clip.inMs);
+ const tight = select({ targetMs: 5000 });
+ expect(tight.clips.reduce((sum, c) => sum + (c.outMs - c.inMs), 0)).toBeLessThanOrEqual(3000);
+ });
+
+ it("returns nothing when only paid-only material exists", () => {
+ const paid = segments.map((s) => ({ ...s, paidOnly: true }));
+ expect(select({ segments: paid })).toEqual({
+ clips: [],
+ hookSegmentId: null,
+ notes: ["no usable segments"],
+ });
+ });
+
+ it("is deterministic", () => {
+ expect(select({ segments: [...segments].reverse() })).toEqual(select());
+ });
+});
+
+describe("fitTeaserWeights", () => {
+ const prior = defaultTeaserWeights("teaser");
+ const samples = (count: number) =>
+ Array.from({ length: count }, (_, i) => {
+ const hook = (i % 10) / 9;
+ const motion = ((i * 7) % 10) / 9;
+ return {
+ features: { speechHook: hook, motion },
+ views: 1000,
+ conversions: Math.round(1000 * (0.01 + 0.2 * hook)),
+ };
+ });
+
+ it("stays off below the minimum sample count", () => {
+ expect(fitTeaserWeights(samples(TEASER_LEARNING_MIN_SAMPLES - 1), prior)).toBeNull();
+ const withoutViews = [
+ ...samples(TEASER_LEARNING_MIN_SAMPLES - 1),
+ { features: {}, views: 0, conversions: 0 },
+ ];
+ expect(fitTeaserWeights(withoutViews, prior)).toBeNull();
+ });
+
+ it("shifts weight toward features that drive conversion, deterministically", () => {
+ const learned = fitTeaserWeights(samples(TEASER_LEARNING_MIN_SAMPLES), prior)!;
+ expect(learned).not.toBeNull();
+ expect(learned.speechHook).toBeGreaterThan(prior.speechHook);
+ expect(learned.motion).toBeLessThan(prior.motion);
+ expect(fitTeaserWeights(samples(TEASER_LEARNING_MIN_SAMPLES), prior)).toEqual(learned);
+ const more = fitTeaserWeights(samples(200), prior)!;
+ expect(more.speechHook).toBeGreaterThan(learned.speechHook);
+ });
+});
+
+describe("autoPlanEdl", () => {
+ const assets = new Map([
+ ["asset-a", { durationMs: 30_000, width: 1920, height: 1080, hasSpeech: true }],
+ ["asset-b", { durationMs: 8000, width: 1080, height: 1920, hasSpeech: false }],
+ ]);
+ const context = testContext({
+ assets: new Map([
+ ["asset-a", { kind: "video", durationMs: 30_000, width: 1920, height: 1080 }],
+ ["asset-b", { kind: "video", durationMs: 8000, width: 1080, height: 1920 }],
+ ]),
+ paidOnly: [{ assetId: "asset-a", startMs: 16_000, endMs: 20_000 }],
+ templateIds: new Set(["intro-minimal", "end-card-a"]),
+ goal: "teaser",
+ });
+
+ it("plans a valid teaser with hook first, captions, watermark, CTA and redactions", () => {
+ const kit = testKit({
+ intro: { templateId: "intro-minimal", params: {}, durationMs: 2000 },
+ endCard: { templateId: "end-card-a", params: { style: "a" }, durationMs: 2000 },
+ lutArtifactId: "lut-1",
+ });
+ const edl = autoPlanEdl({
+ goal: "teaser",
+ profile: testProfile(),
+ brandKit: kit,
+ segments,
+ weights: defaultTeaserWeights("teaser"),
+ assets,
+ people: [
+ { id: "person-1", anonymizeByDefault: "synthetic_identity" },
+ { id: "person-2", anonymizeByDefault: "none" },
+ ],
+ marks: [
+ { id: "mark-1", treatment: "inpaint", assetId: "asset-a", personId: null },
+ { id: "mark-2", treatment: "blur", assetId: "asset-z", personId: null },
+ { id: "mark-3", treatment: "none", assetId: "asset-a", personId: null },
+ ],
+ ctaText: "Full video: link in bio",
+ });
+ const result = validateEdl(edl, { ...context, brandKit: kit });
+ expect(result.ok ? [] : result.errors).toEqual([]);
+ // Short vertical profile: no intro, the hook comes first.
+ expect(edl.intro).toBeUndefined();
+ expect(edl.tracks.video[0]!.segmentId).toBe("s2");
+ expect(edl.tracks.video[0]!.reframe).toBe("auto");
+ expect(edl.tracks.video[0]!.color?.useBrandLut).toBe(true);
+ expect(edl.outro).toMatchObject({
+ templateId: "end-card-a",
+ params: { style: "a", cta: "Full video: link in bio" },
+ });
+ expect(edl.captions).toMatchObject({ mode: "both", presetId: "clean", maxCharsPerLine: 24 });
+ expect(edl.overlays.map((o) => o.kind)).toEqual(["watermark"]);
+ expect(edl.redactions.faces.map((f) => [f.target, f.mode])).toEqual([
+ [{ personId: "person-1" }, "synthetic_identity"],
+ [{ unassignedFaces: true }, "blur"],
+ ]);
+ expect(edl.redactions.marks).toEqual([{ markId: "mark-1", treatment: "inpaint" }]);
+ expect(edl.metadata).toMatchObject({
+ aiAltered: true,
+ aiAlterations: ["synthetic_identity", "inpaint"],
+ });
+ });
+
+ it("uses a CTA text card without an end-card template and keeps the intro on long-form profiles", () => {
+ const profile = testPaidProfile();
+ const kit = testKit({
+ intro: { templateId: "intro-minimal", params: {}, durationMs: 2000 },
+ watermark: undefined,
+ });
+ const teaser = autoPlanEdl({
+ goal: "teaser",
+ profile,
+ brandKit: kit,
+ segments,
+ weights: defaultTeaserWeights("teaser"),
+ assets,
+ people: [],
+ marks: [],
+ ctaText: "Watch the full video",
+ });
+ expect(teaser.intro?.templateId).toBe("intro-minimal");
+ expect(teaser.overlays).toEqual([
+ expect.objectContaining({ kind: "cta_end_card", text: "Watch the full video" }),
+ ]);
+ expect(teaser.redactions.faces).toEqual([]);
+ expect(teaser.metadata.aiAltered).toBe(false);
+ const result = validateEdl(teaser, { ...context, profile, brandKit: kit });
+ expect(result.ok ? [] : result.errors).toEqual([]);
+ });
+
+ it("plans a full release in capture order including paid-only material on paid profiles", () => {
+ const profile = testPaidProfile();
+ const edl = autoPlanEdl({
+ goal: "full_release",
+ profile,
+ brandKit: null,
+ segments,
+ weights: defaultTeaserWeights("full_release"),
+ assets,
+ people: [],
+ marks: [],
+ ctaText: null,
+ });
+ expect(edl.tracks.video.map((c) => c.segmentId)).toEqual([
+ "s1",
+ "s2",
+ "s3",
+ "s4",
+ "s5",
+ "s6",
+ "s7",
+ "s8",
+ ]);
+ expect(edl.tracks.video.at(-1)!.reframe).toBe("auto");
+ expect(edl.tracks.video[0]!.reframe).toBe("none");
+ const result = validateEdl(edl, { ...context, profile, brandKit: null, goal: "full_release" });
+ expect(result.ok ? [] : result.errors).toEqual([]);
+ expect(() =>
+ autoPlanEdl({
+ goal: "teaser",
+ profile,
+ brandKit: null,
+ segments: [],
+ weights: defaultTeaserWeights("teaser"),
+ assets,
+ people: [],
+ marks: [],
+ ctaText: null,
+ }),
+ ).toThrow(/no usable segments/);
+ });
+});
+
+describe("rankThumbnailFrames", () => {
+ it("returns the best frames spread over the video", () => {
+ const candidates = [
+ { tMs: 0, sharpness: 0.5, face: 0, thirds: 0.5, contrast: 0.5 },
+ { tMs: 1000, sharpness: 1, face: 1, thirds: 1, contrast: 1 },
+ { tMs: 1200, sharpness: 1, face: 1, thirds: 0.9, contrast: 1 },
+ { tMs: 5000, sharpness: 0.8, face: 0.5, thirds: 0.5, contrast: 0.5 },
+ { tMs: 9000, sharpness: 0.7, face: 0.5, thirds: 0.5, contrast: 0.5 },
+ ];
+ expect(rankThumbnailFrames(candidates, 3)).toEqual([1000, 5000, 9000]);
+ expect(rankThumbnailFrames(candidates.slice(1, 3), 3)).toEqual([1000, 1200]);
+ expect(rankThumbnailFrames(candidates, 0)).toEqual([]);
+ });
+});
diff --git a/packages/media/src/teaser.ts b/packages/media/src/teaser.ts
index d52418f..e8c7818 100644
--- a/packages/media/src/teaser.ts
+++ b/packages/media/src/teaser.ts
@@ -1,4 +1,6 @@
import type {
+ AiAlteration,
+ AnonymizeMode,
BrandKit,
Edl,
MediaProjectGoal,
@@ -6,12 +8,88 @@ import type {
TeaserFeature,
TeaserWeights,
} from "@rakazo/contracts";
-import { notImplemented } from "./not-implemented.js";
+import {
+ AnonymizeModeSchema,
+ EdlSchema,
+ TEASER_FEATURES,
+ TEASER_LEARNING_MIN_SAMPLES,
+} from "@rakazo/contracts";
+import { introFits } from "./marketing.js";
export type SegmentFeatures = Partial>;
-export function defaultTeaserWeights(_goal: MediaProjectGoal): TeaserWeights {
- return notImplemented("defaultTeaserWeights");
+const GOAL_WEIGHTS: Record> = {
+ teaser: {
+ motion: 0.14,
+ sharpness: 0.06,
+ faces: 0.08,
+ gaze: 0.08,
+ audioPeak: 0.14,
+ speechHook: 0.24,
+ novelty: 0.12,
+ beatProximity: 0.06,
+ aesthetics: 0.08,
+ },
+ trailer: {
+ motion: 0.16,
+ sharpness: 0.08,
+ faces: 0.08,
+ gaze: 0.06,
+ audioPeak: 0.14,
+ speechHook: 0.12,
+ novelty: 0.12,
+ beatProximity: 0.12,
+ aesthetics: 0.12,
+ },
+ highlight: {
+ motion: 0.2,
+ sharpness: 0.08,
+ faces: 0.12,
+ gaze: 0.06,
+ audioPeak: 0.18,
+ speechHook: 0.08,
+ novelty: 0.1,
+ beatProximity: 0.08,
+ aesthetics: 0.1,
+ },
+ story: {
+ motion: 0.08,
+ sharpness: 0.08,
+ faces: 0.16,
+ gaze: 0.12,
+ audioPeak: 0.06,
+ speechHook: 0.22,
+ novelty: 0.12,
+ beatProximity: 0.04,
+ aesthetics: 0.12,
+ },
+ full_release: {
+ motion: 0.1,
+ sharpness: 0.14,
+ faces: 0.1,
+ gaze: 0.08,
+ audioPeak: 0.08,
+ speechHook: 0.1,
+ novelty: 0.12,
+ beatProximity: 0.06,
+ aesthetics: 0.22,
+ },
+ custom: {
+ motion: 0.12,
+ sharpness: 0.1,
+ faces: 0.1,
+ gaze: 0.1,
+ audioPeak: 0.12,
+ speechHook: 0.14,
+ novelty: 0.12,
+ beatProximity: 0.08,
+ aesthetics: 0.12,
+ },
+};
+
+/** Default per-goal weights over every teaser feature; each set sums to 1. */
+export function defaultTeaserWeights(goal: MediaProjectGoal): TeaserWeights {
+ return { ...GOAL_WEIGHTS[goal] };
}
export interface ScoredSegmentInput {
@@ -25,13 +103,75 @@ export interface ScoredSegmentInput {
transcript: string | null;
}
-export function scoreSegment(_features: SegmentFeatures, _weights: TeaserWeights): number {
- return notImplemented("scoreSegment");
+const clamp01 = (value: number) => (Number.isFinite(value) ? Math.min(1, Math.max(0, value)) : 0);
+
+/** Weighted mean of 0..1 features; missing features count as 0. */
+export function scoreSegment(features: SegmentFeatures, weights: TeaserWeights): number {
+ let total = 0;
+ let norm = 0;
+ for (const feature of TEASER_FEATURES) {
+ const weight = weights[feature] ?? 0;
+ norm += Math.abs(weight);
+ total += weight * clamp01(features[feature] ?? 0);
+ }
+ return norm === 0 ? 0 : total / norm;
}
+const HOOK_LEXICON = {
+ en: {
+ questionWords:
+ /^(what|why|how|who|when|where|which|did|do|does|can|could|would|should|is|are|have|ever)\b/,
+ announcements:
+ /\b(wait|watch|look|listen|check this|you won'?t believe|here'?s|secret|how to|never|finally|the moment|let me show|guess|imagine|stop|nobody|everyone|biggest|best|worst|first time|new|today)\b/,
+ address: /\b(you|your)\b/,
+ numbers: /\b(one|two|three|five|ten|hundred|thousand|million|first|second|third|top)\b/,
+ },
+ de: {
+ questionWords:
+ /^(was|warum|wieso|weshalb|wie|wer|wann|wo|welche[rsmn]?|hast|habt|kannst|könnt|willst|würdest|weißt|ist|sind)\b/,
+ announcements:
+ /\b(warte|wartet|schau|schaut|guck|hör zu|pass auf|achtung|du wirst nicht glauben|geheimnis|so geht|niemals|endlich|gleich|hier ist|zeig ich dir|stell dir vor|rate mal|niemand|alle|größte|beste|schlimmste|zum ersten mal|neu|heute)\b/,
+ address: /\b(du|dich|dir|dein|deine|ihr|euch|euer)\b/,
+ numbers: /\b(eins|zwei|drei|fünf|zehn|hundert|tausend|million|erste[rsn]?|zweite[rsn]?|top)\b/,
+ },
+} as const;
+
/** 0..1 speech-hook strength from a transcript excerpt (questions, exclamations, numbers, announcements). */
-export function speechHookScore(_text: string, _language?: string): number {
- return notImplemented("speechHookScore");
+export function speechHookScore(text: string, language?: string): number {
+ const normalized = text.trim().toLocaleLowerCase(language);
+ if (!normalized) return 0;
+ const code = language?.slice(0, 2).toLowerCase();
+ const lexicons =
+ code === "de"
+ ? [HOOK_LEXICON.de]
+ : code === "en"
+ ? [HOOK_LEXICON.en]
+ : Object.values(HOOK_LEXICON);
+ let score = 0;
+ if (normalized.includes("?")) score += 0.35;
+ if (normalized.includes("!")) score += 0.25;
+ const hasNumber =
+ /\d/.test(normalized) || lexicons.some((lexicon) => lexicon.numbers.test(normalized));
+ if (hasNumber) score += 0.2;
+ if (lexicons.some((lexicon) => lexicon.announcements.test(normalized))) score += 0.3;
+ if (lexicons.some((lexicon) => lexicon.questionWords.test(normalized))) score += 0.15;
+ if (lexicons.some((lexicon) => lexicon.address.test(normalized))) score += 0.1;
+ return Math.min(1, Math.round(score * 1000) / 1000);
+}
+
+function segmentFeatures(segment: ScoredSegmentInput): SegmentFeatures {
+ if (segment.features.speechHook !== undefined || !segment.transcript) return segment.features;
+ return { ...segment.features, speechHook: speechHookScore(segment.transcript) };
+}
+
+/** Strength of a segment as an opener: speech hooks, audio peaks, motion, eye contact. */
+function hookStrength(features: SegmentFeatures): number {
+ return (
+ 0.45 * clamp01(features.speechHook ?? 0) +
+ 0.25 * clamp01(features.audioPeak ?? 0) +
+ 0.15 * clamp01(features.motion ?? 0) +
+ 0.15 * clamp01(features.gaze ?? 0)
+ );
}
export interface TeaserSelection {
@@ -41,12 +181,20 @@ export interface TeaserSelection {
notes: string[];
}
+const MIN_CLIP_MS = 1000;
+
/**
* Pick clips for a goal and profile: a hook inside hookWindowMs, rising
* tension, cut before the payoff (open loop), never paid-only segments,
* total within the profile's duration bounds.
+ *
+ * The primary asset is the one holding the best segment overall; its payoff
+ * is its best segment (paid-only included, since that is usually the payoff).
+ * Nothing from the primary asset at or after the payoff is used. The hook
+ * opens the teaser, the rest follows in ascending score, and the lead-in to
+ * the payoff closes it, leaving `endCardMs` for the end card.
*/
-export function selectTeaserSegments(_input: {
+export function selectTeaserSegments(input: {
segments: ScoredSegmentInput[];
profile: PlatformProfile;
goal: MediaProjectGoal;
@@ -54,19 +202,227 @@ export function selectTeaserSegments(_input: {
weights: TeaserWeights;
endCardMs: number;
}): TeaserSelection {
- return notImplemented("selectTeaserSegments");
+ const { profile } = input;
+ const notes: string[] = [];
+ const scored = input.segments
+ .filter((s) => s.endMs > s.startMs)
+ .map((segment) => {
+ const features = segmentFeatures(segment);
+ return { segment, features, score: scoreSegment(features, input.weights) };
+ })
+ .sort(
+ (a, b) =>
+ b.score - a.score ||
+ a.segment.assetId.localeCompare(b.segment.assetId) ||
+ a.segment.startMs - b.segment.startMs ||
+ a.segment.id.localeCompare(b.segment.id),
+ );
+ const maxMs = Math.max(
+ 0,
+ Math.min(input.targetMs, profile.duration.hardMaxMs) - Math.max(0, input.endCardMs),
+ );
+ const minMs = Math.max(0, profile.duration.hardMinMs - Math.max(0, input.endCardMs));
+ const usable = scored.filter((s) => !s.segment.paidOnly);
+ if (usable.length === 0 || maxMs < MIN_CLIP_MS) {
+ return { clips: [], hookSegmentId: null, notes: ["no usable segments"] };
+ }
+
+ const primaryAsset = scored[0]!.segment.assetId;
+ const payoff = scored.find((s) => s.segment.assetId === primaryAsset)!;
+ const pool = usable.filter(
+ (s) =>
+ s.segment.assetId !== primaryAsset ||
+ (s.segment.id !== payoff.segment.id && s.segment.endMs <= payoff.segment.startMs),
+ );
+ const openLoop = pool.length < usable.length || payoff.segment.paidOnly;
+ if (pool.length === 0) {
+ return { clips: [], hookSegmentId: null, notes: ["only the payoff is usable"] };
+ }
+
+ const hook = [...pool].sort(
+ (a, b) =>
+ hookStrength(b.features) - hookStrength(a.features) ||
+ b.score - a.score ||
+ a.segment.id.localeCompare(b.segment.id),
+ )[0]!;
+ const hookWindow = profile.duration.hookWindowMs;
+ const clipCap = Math.max(MIN_CLIP_MS, Math.min(5000, Math.round(maxMs / 4)));
+ const lengthOf = (s: ScoredSegmentInput, cap: number) => Math.min(s.endMs - s.startMs, cap);
+
+ // Lead-in: the last primary-asset segment before the payoff closes the teaser.
+ const leadIn = openLoop
+ ? pool
+ .filter((s) => s.segment.assetId === primaryAsset && s !== hook)
+ .sort((a, b) => b.segment.endMs - a.segment.endMs)[0]
+ : undefined;
+
+ const chosen: typeof pool = [hook];
+ let total = lengthOf(hook.segment, Math.max(clipCap, Math.min(hookWindow, maxMs)));
+ const reserve = leadIn ? lengthOf(leadIn.segment, clipCap) : 0;
+ for (const candidate of pool) {
+ if (candidate === hook || candidate === leadIn) continue;
+ const length = lengthOf(candidate.segment, clipCap);
+ if (length < Math.min(MIN_CLIP_MS, candidate.segment.endMs - candidate.segment.startMs))
+ continue;
+ if (total + length + reserve > maxMs) continue;
+ chosen.push(candidate);
+ total += length;
+ }
+ if (leadIn && total + reserve <= maxMs) {
+ chosen.push(leadIn);
+ total += reserve;
+ }
+
+ const middle = chosen
+ .slice(1)
+ .filter((s) => s !== leadIn)
+ .sort((a, b) => a.score - b.score || a.segment.id.localeCompare(b.segment.id));
+ const ordered = [hook, ...middle, ...(chosen.includes(leadIn!) ? [leadIn!] : [])];
+
+ // Lengths: start from the cap, then grow toward the minimum duration if needed.
+ const lengths = ordered.map((s, i) =>
+ lengthOf(s.segment, i === 0 ? Math.max(clipCap, Math.min(hookWindow, maxMs)) : clipCap),
+ );
+ let sum = lengths.reduce((a, b) => a + b, 0);
+ for (let i = 0; i < ordered.length && sum < minMs; i++) {
+ const full = ordered[i]!.segment.endMs - ordered[i]!.segment.startMs;
+ const grow = Math.min(full - lengths[i]!, minMs - sum, maxMs - sum);
+ if (grow > 0) {
+ lengths[i]! += grow;
+ sum += grow;
+ }
+ }
+ while (sum > maxMs && lengths.length > 0) {
+ const over = sum - maxMs;
+ const last = lengths.length - 1;
+ if (lengths[last]! - over >= MIN_CLIP_MS) {
+ lengths[last]! -= over;
+ sum -= over;
+ } else {
+ sum -= lengths.pop()!;
+ ordered.pop();
+ }
+ }
+
+ const clips = ordered.map((s, i) => {
+ const length = lengths[i]!;
+ // The lead-in ends where the payoff would begin; other clips open at their start.
+ const isLeadIn = s === leadIn;
+ const outMs = isLeadIn ? s.segment.endMs : s.segment.startMs + length;
+ return {
+ segmentId: s.segment.id,
+ assetId: s.segment.assetId,
+ inMs: outMs - length,
+ outMs,
+ };
+ });
+
+ notes.push(
+ `hook: segment ${hook.segment.id} opens inside the ${hookWindow} ms hook window`,
+ ...middle.map((s) => `build: segment ${s.segment.id} (score ${s.score.toFixed(2)})`),
+ );
+ if (leadIn && ordered.includes(leadIn)) {
+ notes.push(`open loop: segment ${leadIn.segment.id} cuts before payoff ${payoff.segment.id}`);
+ } else if (openLoop) {
+ notes.push(`open loop: payoff ${payoff.segment.id} is left out`);
+ }
+ if (input.segments.some((s) => s.paidOnly)) notes.push("paid-only segments excluded");
+ if (sum < minMs) notes.push(`only ${sum} ms of usable material; profile minimum is ${minMs} ms`);
+ return { clips, hookSegmentId: hook.segment.id, notes };
}
-/** Regularized logistic regression of conversion on features; null below TEASER_LEARNING_MIN_SAMPLES. */
+const LEARNING_RATE = 0.5;
+const LEARNING_ITERATIONS = 400;
+const L2 = 0.05;
+const SHRINKAGE = 50;
+
+/**
+ * Regularized logistic regression of conversion on features; null below
+ * TEASER_LEARNING_MIN_SAMPLES samples with views.
+ *
+ * Each sample contributes `conversions` successes out of `views` trials,
+ * weighted so every publication counts equally. Full-batch gradient descent
+ * from zero with a fixed iteration count keeps it deterministic. Positive
+ * coefficients of features that vary in the data redistribute those features'
+ * prior mass; features without variation keep their prior. The result is
+ * shrunk toward the prior by n / (n + 50) so a small sample cannot overturn it.
+ */
export function fitTeaserWeights(
- _samples: Array<{ features: SegmentFeatures; views: number; conversions: number }>,
- _prior: TeaserWeights,
+ samples: Array<{ features: SegmentFeatures; views: number; conversions: number }>,
+ prior: TeaserWeights,
): TeaserWeights | null {
- return notImplemented("fitTeaserWeights");
+ const usable = samples.filter((s) => s.views > 0 && s.conversions >= 0);
+ if (usable.length < TEASER_LEARNING_MIN_SAMPLES) return null;
+ const k = TEASER_FEATURES.length;
+ const xs = usable.map((s) => TEASER_FEATURES.map((f) => clamp01(s.features[f] ?? 0)));
+ const ys = usable.map((s) => Math.min(1, s.conversions / s.views));
+ const beta = new Array(k).fill(0);
+ let intercept = 0;
+ for (let iteration = 0; iteration < LEARNING_ITERATIONS; iteration++) {
+ const gradient = new Array(k).fill(0);
+ let gradientIntercept = 0;
+ for (let i = 0; i < xs.length; i++) {
+ const x = xs[i]!;
+ let z = intercept;
+ for (let j = 0; j < k; j++) z += beta[j]! * x[j]!;
+ const error = 1 / (1 + Math.exp(-z)) - ys[i]!;
+ gradientIntercept += error;
+ for (let j = 0; j < k; j++) gradient[j]! += error * x[j]!;
+ }
+ intercept -= (LEARNING_RATE * gradientIntercept) / xs.length;
+ for (let j = 0; j < k; j++) {
+ beta[j]! -= LEARNING_RATE * (gradient[j]! / xs.length + L2 * beta[j]!);
+ }
+ }
+ // Only features that vary in the data are re-weighted; they share their prior mass.
+ const varies = TEASER_FEATURES.map((_, j) => xs.some((x) => x[j] !== xs[0]![j]));
+ const mass = TEASER_FEATURES.reduce(
+ (sum, f, j) => sum + (varies[j] ? Math.max(0, prior[f] ?? 0) : 0),
+ 0,
+ );
+ const positive = beta.map((b, j) => (varies[j] ? Math.max(0, b) : 0));
+ const positiveTotal = positive.reduce((a, b) => a + b, 0);
+ const alpha = usable.length / (usable.length + SHRINKAGE);
+ return Object.fromEntries(
+ TEASER_FEATURES.map((feature, j) => {
+ const base = prior[feature] ?? 0;
+ const learned = varies[j] && positiveTotal > 0 ? (positive[j]! / positiveTotal) * mass : base;
+ const value = (1 - alpha) * base + alpha * learned;
+ return [feature, Math.round(value * 1e6) / 1e6];
+ }),
+ ) as TeaserWeights;
}
-/** Deterministic first-cut EDL for a project and profile; the agent refines it. */
-export function autoPlanEdl(_input: {
+const CTA_CARD_MS = 2500;
+
+function pickTarget(profile: PlatformProfile, goal: MediaProjectGoal, availableMs: number): number {
+ const { hardMinMs, hardMaxMs, recommended } = profile.duration;
+ const options = [...recommended]
+ .filter((r) => r >= hardMinMs && r <= hardMaxMs)
+ .sort((a, b) => a - b);
+ let target: number;
+ if (goal === "full_release" || goal === "custom") {
+ target = Math.min(hardMaxMs, availableMs);
+ } else if (options.length > 0) {
+ target = options[Math.floor((options.length - 1) / 2)]!;
+ } else {
+ target = Math.min(hardMaxMs, 30_000);
+ }
+ return Math.max(hardMinMs, Math.min(hardMaxMs, target));
+}
+
+/**
+ * Deterministic first-cut EDL for a project and profile; the agent refines it.
+ *
+ * Teaser-style goals use selectTeaserSegments; full releases and custom
+ * projects take segments in order (paid-only ones only for non-social
+ * profiles). Reframing is "auto" whenever the source aspect differs from the
+ * output, captions follow the profile when the material has speech, the
+ * brand intro only plays where introFits allows it, and a teaser ends on the
+ * brand end card (or a CTA text card). Person and mark defaults become
+ * redactions, and the AI-alteration metadata follows from them.
+ */
+export function autoPlanEdl(input: {
goal: MediaProjectGoal;
profile: PlatformProfile;
brandKit: BrandKit | null;
@@ -81,18 +437,223 @@ export function autoPlanEdl(_input: {
marks: Array<{ id: string; treatment: string; assetId: string | null; personId: string | null }>;
ctaText: string | null;
}): Edl {
- return notImplemented("autoPlanEdl");
+ const { goal, profile, brandKit: kit } = input;
+ const W = profile.video.width;
+ const H = profile.video.height;
+ const segments = input.segments.filter((s) => {
+ const asset = input.assets.get(s.assetId);
+ return (
+ asset && s.endMs > s.startMs && (asset.durationMs === null || s.endMs <= asset.durationMs)
+ );
+ });
+ const available = segments.reduce((sum, s) => sum + (s.endMs - s.startMs), 0);
+ const targetMs = pickTarget(profile, goal, available);
+
+ const intro = kit?.intro && introFits(profile, kit.intro.durationMs) ? kit.intro : undefined;
+ let outro = goal === "teaser" ? undefined : kit?.outro;
+ const ctaText = input.ctaText?.trim() || null;
+ let ctaCard = false;
+ if (goal === "teaser") {
+ if (kit?.endCard) {
+ outro = {
+ ...kit.endCard,
+ params: { ...kit.endCard.params, ...(ctaText ? { cta: ctaText.slice(0, 200) } : {}) },
+ };
+ } else if (ctaText) {
+ ctaCard = true;
+ }
+ }
+ const bookendsMs = (intro?.durationMs ?? 0) + (outro?.durationMs ?? 0);
+ const budget = Math.max(MIN_CLIP_MS, targetMs - bookendsMs);
+
+ let picks: Array<{ segmentId: string; assetId: string; inMs: number; outMs: number }>;
+ if (goal === "full_release" || goal === "custom") {
+ const allowPaid = profile.kind !== "free_social";
+ const ordered = segments
+ .filter((s) => allowPaid || !s.paidOnly)
+ .sort(
+ (a, b) =>
+ (a.capturedAt?.getTime() ?? Number.MAX_SAFE_INTEGER) -
+ (b.capturedAt?.getTime() ?? Number.MAX_SAFE_INTEGER) ||
+ a.assetId.localeCompare(b.assetId) ||
+ a.startMs - b.startMs,
+ );
+ picks = [];
+ let used = 0;
+ for (const s of ordered) {
+ const length = Math.min(s.endMs - s.startMs, budget - used);
+ if (length < MIN_CLIP_MS) break;
+ picks.push({
+ segmentId: s.id,
+ assetId: s.assetId,
+ inMs: s.startMs,
+ outMs: s.startMs + length,
+ });
+ used += length;
+ }
+ } else {
+ picks = selectTeaserSegments({
+ segments,
+ profile,
+ goal,
+ targetMs: budget,
+ weights: input.weights,
+ endCardMs: 0,
+ }).clips;
+ }
+ if (picks.length === 0) throw new Error("no usable segments for this plan");
+
+ let atMs = 0;
+ const lut = Boolean(kit?.lutArtifactId);
+ const video = picks.map((pick, index) => {
+ const asset = input.assets.get(pick.assetId)!;
+ const sameAspect =
+ !asset.width || !asset.height || Math.abs(asset.width / asset.height / (W / H) - 1) <= 0.01;
+ const clip = {
+ id: `c${index + 1}`,
+ assetId: pick.assetId,
+ inMs: pick.inMs,
+ outMs: pick.outMs,
+ atMs,
+ speed: 1,
+ reframe: sameAspect ? ("none" as const) : ("auto" as const),
+ ...(lut ? { color: { useBrandLut: true } } : {}),
+ segmentId: pick.segmentId,
+ };
+ atMs += pick.outMs - pick.inMs;
+ return clip;
+ });
+ const mainMs = atMs;
+ const introLength = intro?.durationMs ?? 0;
+ const totalMs = introLength + mainMs + (outro?.durationMs ?? 0);
+
+ const usedAssets = new Set(picks.map((p) => p.assetId));
+ const hasSpeech = [...usedAssets].some((id) => input.assets.get(id)?.hasSpeech);
+ const preset = kit?.captionPresets[0];
+ const captions =
+ (profile.captions.recommended || profile.captions.required) && hasSpeech
+ ? {
+ mode: profile.export.subtitles ? ("both" as const) : ("burned" as const),
+ ...(preset ? { presetId: preset.id } : {}),
+ maxCharsPerLine: W < H ? 24 : 32,
+ wordHighlight: preset?.template === "karaoke",
+ position: "lower" as const,
+ }
+ : undefined;
+
+ const zones = profile.safeZones;
+ const overlays: Array> = [];
+ if (kit?.watermark) {
+ overlays.push({ id: "watermark", kind: "watermark", atMs: 0, durationMs: totalMs });
+ }
+ if (ctaCard && ctaText) {
+ const cardMs = Math.min(CTA_CARD_MS, mainMs);
+ const left = zones.leftPct / 100 + 0.02;
+ const right = zones.rightPct / 100 + 0.02;
+ overlays.push({
+ id: "cta",
+ kind: "cta_end_card",
+ atMs: introLength + mainMs - cardMs,
+ durationMs: cardMs,
+ text: ctaText.slice(0, 200),
+ box: { x: left, y: 0.42, w: Math.max(0.1, 1 - left - right), h: 0.12 },
+ });
+ }
+
+ const protectedPeople = input.people.flatMap((person) => {
+ const mode = AnonymizeModeSchema.safeParse(person.anonymizeByDefault);
+ return mode.success && mode.data !== "none" ? [{ id: person.id, mode: mode.data }] : [];
+ });
+ const faces: Array<{
+ id: string;
+ target: { personId: string } | { unassignedFaces: true };
+ mode: Exclude;
+ }> = protectedPeople.map((person, index) => ({
+ id: `face-${index + 1}`,
+ target: { personId: person.id },
+ mode: person.mode,
+ }));
+ if (faces.length > 0) {
+ // Unknown faces next to a protected person are blurred too; the gate blocks them otherwise.
+ faces.push({ id: "face-unassigned", target: { unassignedFaces: true }, mode: "blur" });
+ }
+ const peopleIds = new Set(input.people.map((p) => p.id));
+ const marks = input.marks.flatMap((mark) => {
+ if (mark.treatment === "none") return [];
+ const relevant =
+ (mark.assetId !== null && usedAssets.has(mark.assetId)) ||
+ (mark.personId !== null && peopleIds.has(mark.personId)) ||
+ (mark.assetId === null && mark.personId === null);
+ if (!relevant) return [];
+ const treatment = mark.treatment as "inpaint" | "blur" | "cover" | "reframe_out";
+ return [{ markId: mark.id, treatment }];
+ });
+
+ const aiAlterations: AiAlteration[] = [];
+ if (faces.some((f) => f.mode === "synthetic_identity")) aiAlterations.push("synthetic_identity");
+ if (marks.some((m) => m.treatment === "inpaint")) aiAlterations.push("inpaint");
+
+ return EdlSchema.parse({
+ edlVersion: 1,
+ output: {
+ profileId: profile.id,
+ width: W,
+ height: H,
+ fps: profile.video.fps,
+ durationTargetMs: targetMs,
+ durationHardMinMs: profile.duration.hardMinMs,
+ durationHardMaxMs: profile.duration.hardMaxMs,
+ },
+ tracks: { video, audio: [] },
+ transitions: [],
+ ...(captions ? { captions } : {}),
+ overlays,
+ ...(intro ? { intro } : {}),
+ ...(outro ? { outro } : {}),
+ redactions: { faces, marks, regions: [] },
+ metadata: { tags: [], aiAltered: aiAlterations.length > 0, aiAlterations },
+ });
}
+const THUMBNAIL_WEIGHTS = { sharpness: 0.4, face: 0.25, thirds: 0.2, contrast: 0.15 };
+
+/**
+ * Best thumbnail frame times, spread out: candidates are ranked by sharpness,
+ * face presence, rule of thirds and contrast, and each pick must be at least
+ * a tenth of the candidate span (min 1 s) away from earlier picks while
+ * enough candidates remain.
+ */
export function rankThumbnailFrames(
- _candidates: Array<{
+ candidates: Array<{
tMs: number;
sharpness: number;
face: number;
thirds: number;
contrast: number;
}>,
- _count: number,
+ count: number,
): number[] {
- return notImplemented("rankThumbnailFrames");
+ if (count <= 0 || candidates.length === 0) return [];
+ const ranked = candidates
+ .map((c) => ({
+ tMs: c.tMs,
+ score:
+ THUMBNAIL_WEIGHTS.sharpness * clamp01(c.sharpness) +
+ THUMBNAIL_WEIGHTS.face * clamp01(c.face) +
+ THUMBNAIL_WEIGHTS.thirds * clamp01(c.thirds) +
+ THUMBNAIL_WEIGHTS.contrast * clamp01(c.contrast),
+ }))
+ .sort((a, b) => b.score - a.score || a.tMs - b.tMs);
+ const times = candidates.map((c) => c.tMs);
+ const spacing = Math.max(1000, (Math.max(...times) - Math.min(...times)) / 10);
+ const picked: number[] = [];
+ for (const candidate of ranked) {
+ if (picked.length >= count) break;
+ if (picked.every((t) => Math.abs(t - candidate.tMs) >= spacing)) picked.push(candidate.tMs);
+ }
+ for (const candidate of ranked) {
+ if (picked.length >= count) break;
+ if (!picked.includes(candidate.tMs)) picked.push(candidate.tMs);
+ }
+ return picked;
}
diff --git a/packages/media/src/test-fixtures.ts b/packages/media/src/test-fixtures.ts
new file mode 100644
index 0000000..105843f
--- /dev/null
+++ b/packages/media/src/test-fixtures.ts
@@ -0,0 +1,135 @@
+import type { BrandKit, Edl, PlatformProfile } from "@rakazo/contracts";
+import { BrandKitSchema, EdlSchema, PlatformProfileSchema } from "@rakazo/contracts";
+import type { EdlContext } from "./edl.js";
+
+/* Synthetic fixtures shared by the unit tests. No real people, places, or platforms. */
+
+export function testProfile(overrides: Record = {}): PlatformProfile {
+ return PlatformProfileSchema.parse({
+ id: "example-short-vertical",
+ name: "Example short vertical",
+ kind: "free_social",
+ reviewedAt: "2026-09-01",
+ video: {
+ aspectRatios: ["9:16"],
+ width: 1080,
+ height: 1920,
+ fps: 30,
+ codec: "h264",
+ maxBitrateKbps: 8000,
+ maxFileSizeMb: 500,
+ },
+ duration: { hardMinMs: 1000, hardMaxMs: 60_000, recommended: [7000, 15_000, 30_000] },
+ safeZones: { topPct: 10, bottomPct: 20, leftPct: 5, rightPct: 12 },
+ captions: { recommended: true, maxLines: 2 },
+ audio: {},
+ text: {
+ titleMaxChars: 100,
+ captionMaxChars: 150,
+ hashtagLimit: 3,
+ linkPolicy: "bio_only",
+ bannedPatterns: [{ pattern: "free\\s+money", reason: "misleading claim" }],
+ },
+ content: { thresholds: { explicit_nudity: 0.2, violence: 0.5 } },
+ disclosure: { aiAlteredLabelRequired: true },
+ consent: { requirePerformerConsent: false, requireAgeVerification: false },
+ export: {},
+ ...overrides,
+ });
+}
+
+export function testPaidProfile(): PlatformProfile {
+ return testProfile({
+ id: "example-paid-video",
+ name: "Example paid video",
+ kind: "paid_video",
+ video: {
+ aspectRatios: ["16:9"],
+ width: 1920,
+ height: 1080,
+ fps: 30,
+ codec: "h264",
+ maxBitrateKbps: 12_000,
+ maxFileSizeMb: 4000,
+ },
+ duration: { hardMinMs: 1000, hardMaxMs: 3_600_000, recommended: [] },
+ safeZones: { topPct: 0, bottomPct: 0, leftPct: 0, rightPct: 0 },
+ text: { captionMaxChars: 2000, hashtagLimit: 10, linkPolicy: "allowed" },
+ consent: { requirePerformerConsent: true, requireAgeVerification: true },
+ disclosure: { aiAlteredLabelRequired: true, ageRestrictedFlag: true },
+ });
+}
+
+export function testKit(overrides: Partial = {}): BrandKit {
+ return BrandKitSchema.parse({
+ name: "Studio Example",
+ colors: {
+ primary: "#112233",
+ secondary: "#445566",
+ accent: "#FFAA00",
+ text: "#FFFFFF",
+ background: "#000000",
+ },
+ fonts: [{ family: "Example Sans", artifactId: "font-1", role: "caption", license: "OFL" }],
+ watermark: { artifactId: "logo-1", position: "top_right", opacity: 0.6, scale: 0.15 },
+ captionPresets: [
+ {
+ id: "clean",
+ name: "Clean",
+ template: "clean",
+ fontFamily: "Example Sans",
+ sizePct: 5,
+ textColor: "#FFFFFF",
+ },
+ ],
+ musicBeds: [{ artifactId: "music-1", title: "Bed", license: "own", commercialUse: true }],
+ ...overrides,
+ });
+}
+
+/** Two 16:9 clips of a 10 s asset, 4 s total, on a vertical profile. */
+export function testEdl(overrides: Record = {}): Edl {
+ return EdlSchema.parse({
+ edlVersion: 1,
+ output: {
+ profileId: "example-short-vertical",
+ width: 1080,
+ height: 1920,
+ fps: 30,
+ durationTargetMs: 4000,
+ durationHardMinMs: 1000,
+ durationHardMaxMs: 60_000,
+ },
+ tracks: {
+ video: [
+ { id: "c1", assetId: "asset-a", inMs: 0, outMs: 2000, atMs: 0, reframe: "auto" },
+ { id: "c2", assetId: "asset-a", inMs: 5000, outMs: 7000, atMs: 2000, reframe: "auto" },
+ ],
+ },
+ metadata: {},
+ ...overrides,
+ });
+}
+
+export function testContext(overrides: Partial = {}): EdlContext {
+ return {
+ profile: testProfile(),
+ brandKit: testKit(),
+ assets: new Map([
+ ["asset-a", { kind: "video", durationMs: 10_000, width: 1920, height: 1080 }],
+ ["asset-b", { kind: "video", durationMs: 8000, width: 1920, height: 1080 }],
+ ["photo-1", { kind: "photo", durationMs: null, width: 4000, height: 3000 }],
+ ]),
+ artifactIds: new Set(["music-1", "music-2", "vo-1"]),
+ music: new Map([
+ ["music-1", { commercialUse: true }],
+ ["music-2", { commercialUse: false }],
+ ]),
+ markIds: new Set(["mark-1"]),
+ personIds: new Set(["person-1"]),
+ faceTrackIds: new Set(["track-1"]),
+ templateIds: new Set(["intro-minimal", "outro-simple", "lower-third-a"]),
+ paidOnly: [],
+ ...overrides,
+ };
+}