// Tests for "room for posts" in the build (slice R1): the hold and the footage // move on a carrying clip's input chain, where the cut is joined; the // hard-cut concat that hosts them; the record a cached concat keeps of them; // the cut's lengths with holds; and real ffmpeg runs showing a held segment // freezes for exactly hold·fps frames in silence while every other input's // frames pass through untouched. // // Run with: pnpm test:scripts import assert from "node:assert/strict"; import { spawnSync } from "node:child_process"; import { mkdtempSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import path from "node:path"; import test from "node:test"; import { concatListText, concatRecordText, hardCutFilterArgs, holdAudioFilter, holdVideoFilter, joinInputChain, moveFilter, previewFromSegmentsArgs, sameConcatList, segmentJoins, windowSegments, xfadeGraph, } from "./build-video.mjs"; import { deckGeometry, deckSchedule, scheduleFrom, shiftedFootage } from "./deck.mjs"; const PALETTE = { bg: "#12101a", fg: "#f4f1ea", muted: "#9a93ad", accent: "#a97bff", amber: "#ffc860" }; const RENDER = { width: 1920, height: 1080, fps: 30, transition: 0.5, palette: PALETTE, crf: 21, preset: "slow", audioRate: 48000, audioChannels: 2, chrome: { engine: "hyperframes", layout: "deck", deck: {} }, }; const PROV = { siteOrigin: "https://example.test", channelSlug: "chan" }; const CLIPS = [ { id: "c1", type: "clip", video: "v1", start: 0, end: 10, date: "2024-09-05" }, { id: "c2", type: "clip", video: "v2", start: 0, end: 12, date: "2024-10-01" }, { id: "c3", type: "clip", video: "v3", start: 0, end: 4, date: "2025-06-01" }, ]; const POST = (id, date) => ({ id, platform: "bluesky", date, text: `post ${id}`, url: `https://bsky.app/profile/a/post/${id}`, }); const MOVE = (segmentAt, extra = {}) => ({ segment: "c2", at: 9.5 + segmentAt, segmentAt, seconds: 0.6, from: deckGeometry(RENDER).footage, to: shiftedFootage(RENDER), ...extra, }); // ---- the join plan ---------------------------------------------------------- test("segmentJoins: null without posts; a hold and a move on each carrying clip, aligned with the segments", () => { const durs = [10, 12, 4]; const plain = deckSchedule({ entries: CLIPS, durs, D: 0.5, render: RENDER, provenance: PROV }); assert.equal(segmentJoins(plain), null); assert.equal(segmentJoins(null), null); const s = deckSchedule({ entries: CLIPS, durs, D: 0.5, render: RENDER, provenance: PROV, posts: [POST("a", "2024-10-19")] }); const joins = segmentJoins(s); assert.equal(joins.length, 3); assert.equal(joins[0], null); assert.equal(joins[2], null); assert.equal(joins[1].hold, 2.5); assert.deepEqual(joins[1].move, s.moves[0]); // A hold alone (shift off) and a move alone (hold 0) are each a join. const noShift = { ...RENDER, chrome: { ...RENDER.chrome, deck: { posts: { shift: false } } } }; const h = segmentJoins(deckSchedule({ entries: CLIPS, durs, D: 0.5, render: noShift, provenance: PROV, posts: [POST("a", "2024-10-19")] })); assert.deepEqual(h[1], { hold: 2.5, move: null }); const noHold = { ...RENDER, chrome: { ...RENDER.chrome, deck: { posts: { hold: 0 } } } }; const m = segmentJoins(deckSchedule({ entries: CLIPS, durs, D: 0.5, render: noHold, provenance: PROV, posts: [POST("a", "2024-10-19")] })); assert.equal(m[1].hold, 0); assert.ok(m[1].move); // Neither: no joins at all. const neither = { ...RENDER, chrome: { ...RENDER.chrome, deck: { posts: { hold: 0, shift: false } } } }; assert.equal(segmentJoins(deckSchedule({ entries: CLIPS, durs, D: 0.5, render: neither, provenance: PROV, posts: [POST("a", "2024-10-19")] })), null); }); test("the cut's lengths: the schedule's starts are the probed lengths plus the holds, as the joins carry them", () => { const durs = [10, 12, 4]; const s = deckSchedule({ entries: CLIPS, durs, D: 0.5, render: RENDER, provenance: PROV, posts: [POST("a", "2024-10-19")] }); const joins = segmentJoins(s); // cutOffsets' sum, done by hand: probed + hold, then scheduleFrom. const cut = scheduleFrom(durs.map((d, i) => d + (joins[i]?.hold ?? 0)), 0.5); assert.deepEqual(cut.starts, s.segments.map((x) => x.start)); assert.equal(cut.total, s.total); assert.equal(s.total, 10 + 12 + 4 - 1 + 2.5); }); test("a first post inside the hold: the move's segmentAt is past the clip's own last frame, in the held clock", () => { // c2 is 12 s on disk; one post at 2 s with the 2.5 s hold appears 12.0 s // into the segment -- inside the hold. The move runs after the hold, so // that is a moment its clock reaches. const two = { ...RENDER, chrome: { ...RENDER.chrome, deck: { posts: { seconds: 2 } } } }; const s = deckSchedule({ entries: CLIPS, durs: [10, 12, 4], D: 0.5, render: two, provenance: PROV, posts: [POST("a", "2024-10-19")] }); const [m] = s.moves; assert.equal(m.segment, "c2"); assert.equal(m.segmentAt, 12); assert.equal(s.segments[1].duration, 14.5); assert.ok(m.segmentAt + m.seconds <= s.segments[1].duration - 0.5, "the glide lands before the dissolve out"); const c = joinInputChain(1, segmentJoins(s)[1], two); assert.ok(c.parts[0].indexOf("tpad=") < c.parts[0].indexOf("perspective="), "hold, then move"); }); test("holds are whole frames: 2.5 s at 25 fps is 63 frames (2.52 s), and the cut's length counts that", () => { const r25 = { ...RENDER, fps: 25 }; const s = deckSchedule({ entries: CLIPS, durs: [10, 12, 4], D: 0, render: r25, provenance: PROV, posts: [POST("a", "2024-10-19"), POST("b", "2025-07-01")], }); assert.equal(s.segments[1].hold, 2.52); assert.equal(s.segments[2].hold, 2.52); assert.equal(s.total, 10 + 12 + 4 + 2 * 2.52); assert.equal(holdVideoFilter(segmentJoins(s)[1].hold), "tpad=stop_mode=clone:stop_duration=2.52"); // At 30 fps 2.5 s is already 75 frames: unchanged. const s30 = deckSchedule({ entries: CLIPS, durs: [10, 12, 4], D: 0, render: RENDER, provenance: PROV, posts: [POST("a", "2024-10-19")] }); assert.equal(s30.segments[1].hold, 2.5); }); test("freezeSamples: between the hold's start (or the move's end) and the dissolve; skipped when under three frames", async () => { const { freezeSamples } = await import("./verify-build.mjs"); const seg = { id: "c2", start: 9.5, end: 24, hold: 2.5 }; const close = (a, b) => assert.ok(Math.abs(a - b) < 1e-9, `${a} != ${b}`); const a = freezeSamples(seg, { fps: 30, D: 0.5 }); close(a.at[0], 21.5 + 0.05); close(a.at[1], 23.5 - 0.05); // A move that ends inside the hold: the still span starts where it lands. close(freezeSamples(seg, { fps: 30, D: 0.5, moveEnd: 22.1 }).at[0], 22.1 + 0.05); // Hold 0.5 under a 0.5 s crossfade: all of it is the dissolve. assert.match(freezeSamples({ ...seg, hold: 0.5 }, { fps: 30, D: 0.5 }).skip, /^0\.000s of still picture/); // Hard cut: up to the end. The last segment: up to its end fade. close(freezeSamples({ ...seg, hold: 0.5 }, { fps: 30, D: 0 }).at[1], 24 - 0.05); close(freezeSamples(seg, { fps: 30, D: 0.5, last: true, endFade: 1 }).at[1], 23 - 0.05); assert.ok(freezeSamples({ ...seg, hold: 1 }, { fps: 30, D: 0.5, last: true, endFade: 1 }).skip); }); // ---- the chains, as strings ------------------------------------------------- test("joinInputChain: no join, no chain -- the input's own labels", () => { assert.deepEqual(joinInputChain(3, null, RENDER), { parts: [], v: "[3:v]", a: "[3:a]" }); }); test("joinInputChain: a hold is tpad clone on the picture and apad silence on the sound", () => { assert.equal(holdVideoFilter(2.5), "tpad=stop_mode=clone:stop_duration=2.5"); assert.equal(holdAudioFilter(2.5), "apad=pad_dur=2.5"); assert.deepEqual(joinInputChain(1, { hold: 2.5, move: null }, RENDER), { parts: ["[1:v]tpad=stop_mode=clone:stop_duration=2.5[j1v]", "[1:a]apad=pad_dur=2.5[j1a]"], v: "[j1v]", a: "[j1a]", }); }); test("joinInputChain: the move goes after the hold, so its clock counts the held frames; a move alone leaves the sound alone", () => { const both = joinInputChain(1, { hold: 2.5, move: MOVE(6) }, RENDER); assert.equal(both.parts.length, 2); assert.equal(both.parts[0], `[1:v]tpad=stop_mode=clone:stop_duration=2.5,${moveFilter(MOVE(6), RENDER)}[j1v]`); assert.equal(both.parts[1], "[1:a]apad=pad_dur=2.5[j1a]"); const move = joinInputChain(1, { hold: 0, move: MOVE(6) }, RENDER); assert.deepEqual(move, { parts: [`[1:v]${moveFilter(MOVE(6), RENDER)}[j1v]`], v: "[j1v]", a: "[1:a]" }); }); test("moveFilter: perspective places the frame's corners so the footage box eases from `from` to `to`", () => { const f = moveFilter(MOVE(6), RENDER); const [fill, persp] = f.split(/,(?=perspective=)/); assert.equal(fill, "fillborders=left=2:right=2:top=2:bottom=2:mode=fixed:color=#12101a"); assert.match(persp, /:interpolation=linear:sense=destination:eval=frame$/); // 1574×886 at (173,2) → 1354×762 at (24,64): the input frame's corners at e = 1. const e = "st(0,clip(((in-1)/30-6)/0.6,0,1))"; const ease = "*ld(0)*ld(0)*(3-2*ld(0))"; assert.equal( persp, "perspective=" + [ `x0='${e};0+(-124.8196)${ease}'`, `y0='${e};0+(62.2799)${ease}'`, `x1='${e};W+(-393.1804)${ease}'`, `y1='${e};0+(62.2799)${ease}'`, `x2='${e};0+(-124.8196)${ease}'`, `y2='${e};H+(-88.8713)${ease}'`, `x3='${e};W+(-393.1804)${ease}'`, `y3='${e};H+(-88.8713)${ease}'`, ].join(":") + ":interpolation=linear:sense=destination:eval=frame", ); // The corner offsets ARE the box map: the from box's corners land on the to box's. const F = deckGeometry(RENDER).footage; const T = shiftedFootage(RENDER); const X = (u) => -124.8196 + (u * (1920 - 393.1804 - -124.8196)) / 1920; const Y = (v) => 62.2799 + (v * (1080 - 88.8713 - 62.2799)) / 1080; near(X(F.x), T.x, "left"); near(X(F.x + F.width), T.x + T.width, "right"); near(Y(F.y), T.y, "top"); near(Y(F.y + F.height), T.y + T.height, "bottom"); // No easing time: a cut. assert.match(moveFilter(MOVE(6, { seconds: 0 }), RENDER), /x0='st\(0,gte\(\(in-1\)\/30,6\)\);0\+/); }); function near(a, b, msg) { assert.ok(Math.abs(a - b) < 0.01, `${msg}: ${a} != ${b}`); } test("xfadeGraph: without joins, the graph the crossfade concat always wrote", () => { const g = xfadeGraph([10, 12, 4], 0.5, null, RENDER); assert.deepEqual(g.parts, [ // Each input's sound pinned to its picture's length before the crossfade. "[0:a]apad=whole_dur=10.000000,atrim=end=10.000000,asetpts=PTS-STARTPTS[p0a]", "[1:a]apad=whole_dur=12.000000,atrim=end=12.000000,asetpts=PTS-STARTPTS[p1a]", "[2:a]apad=whole_dur=4.000000,atrim=end=4.000000,asetpts=PTS-STARTPTS[p2a]", "[0:v][1:v]xfade=transition=fade:duration=0.5:offset=9.500[v1]", "[p0a][p1a]acrossfade=d=0.5:c1=tri:c2=tri[a1]", "[v1][2:v]xfade=transition=fade:duration=0.5:offset=21.000[v2]", "[a1][p2a]acrossfade=d=0.5:c1=tri:c2=tri[a2]", ]); assert.equal(g.vlab, "[v2]"); assert.equal(g.alab, "[a2]"); // An all-null join list is the same graph. assert.deepEqual(xfadeGraph([10, 12, 4], 0.5, [null, null, null], RENDER), g); }); test("xfadeGraph: a held input joins through its chain, and the offsets after it move by the hold", () => { const joins = [null, { hold: 2.5, move: MOVE(6) }, null]; const g = xfadeGraph([10, 14.5, 4], 0.5, joins, RENDER); const chain = joinInputChain(1, joins[1], RENDER).parts; assert.deepEqual(g.parts, [ "[0:a]apad=whole_dur=10.000000,atrim=end=10.000000,asetpts=PTS-STARTPTS[p0a]", ...chain, // The held input's sound is pinned to its length WITH the hold. "[j1a]apad=whole_dur=14.500000,atrim=end=14.500000,asetpts=PTS-STARTPTS[p1a]", "[2:a]apad=whole_dur=4.000000,atrim=end=4.000000,asetpts=PTS-STARTPTS[p2a]", "[0:v][j1v]xfade=transition=fade:duration=0.5:offset=9.500[v1]", "[p0a][p1a]acrossfade=d=0.5:c1=tri:c2=tri[a1]", "[v1][2:v]xfade=transition=fade:duration=0.5:offset=23.500[v2]", "[a1][p2a]acrossfade=d=0.5:c1=tri:c2=tri[a2]", ]); }); test("hardCutFilterArgs: the concat filter over each input's chain, one encode", () => { const joins = [null, { hold: 2.5, move: null }, null]; const args = hardCutFilterArgs(["/s/a.mp4", "/s/b.mp4", "/s/c.mp4"], joins, RENDER, "/o/x.prerail-hardcut.mp4"); assert.deepEqual(args.filter((_, i) => args[i - 1] === "-i"), ["/s/a.mp4", "/s/b.mp4", "/s/c.mp4"]); assert.equal( args[args.indexOf("-filter_complex") + 1], "[1:v]tpad=stop_mode=clone:stop_duration=2.5[j1v];[1:a]apad=pad_dur=2.5[j1a];" + "[0:v][0:a][j1v][j1a][2:v][2:a]concat=n=3:v=1:a=1[vc][ac]", ); assert.deepEqual(args.slice(args.indexOf("-map"), args.indexOf("-map") + 4), ["-map", "[vc]", "-map", "[ac]"]); assert.equal(args[args.indexOf("-c:v") + 1], "libx264"); assert.equal(args[args.indexOf("-c:a") + 1], "aac"); assert.equal(args.at(-1), "/o/x.prerail-hardcut.mp4"); }); test("the hard-cut record: the list alone without joins; the joins with them, so a changed hold is never reused", () => { const segs = ["/s/a.mp4", "/s/b.mp4", "/s/c.mp4"]; assert.equal(concatRecordText(segs, null), concatListText(segs)); const joins = [null, { hold: 2.5, move: MOVE(6) }, null]; const rec = concatRecordText(segs, joins); assert.ok(rec.startsWith(concatListText(segs))); assert.match(rec, /^# join 1 \{"hold":2\.5,"move":\{"segment":"c2"/m); assert.equal(sameConcatList(rec, segs, joins), true); // The list a deck without posts records is not the record of a joined one, either way round. assert.equal(sameConcatList(rec, segs), false); assert.equal(sameConcatList(concatListText(segs), segs, joins), false); // A changed hold, a changed move, a hold moved to another clip: all stale. assert.equal(sameConcatList(rec, segs, [null, { hold: 3, move: MOVE(6) }, null]), false); assert.equal(sameConcatList(rec, segs, [null, { hold: 2.5, move: MOVE(6.5) }, null]), false); assert.equal(sameConcatList(rec, segs, [{ hold: 2.5, move: MOVE(6) }, null, null]), false); }); // ---- window maths with holds ------------------------------------------------ test("a preview window over a held clip: picked by the cut's lengths, its inputs joined as the full concat's", () => { const joins = [null, { hold: 2.5, move: MOVE(6) }, null]; const durs = [10, 14.5, 4]; // c2 is 12 s on disk, 14.5 in the cut const { starts } = scheduleFrom(durs, 0.5); assert.deepEqual(starts, [0, 9.5, 23.5]); // 21.5–23.0 is c2's hold: by the files' own lengths it would reach into c3. assert.deepEqual(windowSegments(starts, durs, 21.5, 1.5), { first: 1, last: 1, offset: 12 }); const plan = { regions: [], outLabel: "[hfout]" }; const args = previewFromSegmentsArgs({ segments: ["/s/a.mp4", "/s/b.mp4", "/s/c.mp4"], durs, starts, D: 0.5, at: 20, dur: 6, render: RENDER, chromePlan: plan, outPath: "/p.mp4", joins, }); assert.deepEqual(args.filter((_, i) => args[i - 1] === "-i"), ["/s/b.mp4", "/s/c.mp4"]); const fc = args[args.indexOf("-filter_complex") + 1]; // b is input 0 here, joined as input 1 is in the full concat. assert.ok(fc.startsWith(joinInputChain(0, joins[1], RENDER).parts.join(";") + ";")); assert.match(fc, /\[j0v\]\[1:v\]xfade=transition=fade:duration=0\.5:offset=14\.000\[v1\]/); assert.match(fc, /\[v1\]trim=start=10\.500:duration=6\.000/); // One held segment alone: its chain, then the trim. const one = previewFromSegmentsArgs({ segments: ["/s/a.mp4", "/s/b.mp4", "/s/c.mp4"], durs, starts, D: 0.5, at: 21.5, dur: 1.5, render: RENDER, chromePlan: plan, outPath: "/p.mp4", joins, }); assert.match(one[one.indexOf("-filter_complex") + 1], /\[j0v\]trim=start=12\.000:duration=1\.500,setpts=PTS-STARTPTS\[vw\];\[j0a\]atrim=/); const plain = previewFromSegmentsArgs({ segments: ["/s/a.mp4", "/s/b.mp4"], durs: [10, 10], starts: [0, 9.5], D: 0.5, at: 8, dur: 4, render: RENDER, chromePlan: plan, outPath: "/p.mp4", }); // Without joins, the window's graph is the crossfade concat's: each sound // pinned to its picture, then the fades. assert.match(plain[plain.indexOf("-filter_complex") + 1], /^\[0:a\]apad=whole_dur=10\.000000,atrim=end=10\.000000,asetpts=PTS-STARTPTS\[p0a\];\[1:a\]apad=whole_dur=10\.000000[^;]*\[p1a\];\[0:v\]\[1:v\]xfade=transition=fade:duration=0\.5:offset=9\.500\[v1\];\[p0a\]\[p1a\]acrossfade/); }); // ---- ffmpeg, for real ------------------------------------------------------- const have = (b, a) => spawnSync(b, a, { stdio: "ignore" }).status === 0; const haveFfmpeg = have("ffmpeg", ["-version"]); const R = { width: 320, height: 180, fps: 30, palette: PALETTE, crf: 21, preset: "veryfast", audioRate: 48000, audioChannels: 2, }; const ff = (args, opts = {}) => { const r = spawnSync("ffmpeg", ["-nostdin", "-v", "error", "-y", ...args], { maxBuffer: 1 << 28, ...opts }); assert.equal(r.status, 0, String(r.stderr)); return r.stdout; }; /** framemd5's hashes for one output stream (the picture is stream 0). */ const md5s = (out, stream = 0) => String(out).split("\n").filter((l) => l && !l.startsWith("#")) .map((l) => l.split(",")).filter((f) => Number(f[0]) === stream).map((f) => f.at(-1).trim()); /** Three 2 s segments as the deck frames them: footage in a box over bg, a tone under it. */ function segments(dir) { const make = (name, src, hz) => { const f = path.join(dir, `${name}.mov`); ff([ "-f", "lavfi", "-i", `${src}=s=280x150:r=30:d=2`, "-f", "lavfi", "-i", `sine=frequency=${hz}:sample_rate=48000:duration=2`, "-filter_complex", `[0:v]pad=320:180:20:10:color=${PALETTE.bg},format=yuv420p[v];[1:a]aformat=channel_layouts=stereo[a]`, "-map", "[v]", "-map", "[a]", "-c:v", "ffv1", "-c:a", "pcm_s16le", f, ]); return f; }; return [make("a", "testsrc2", 440), make("b", "smptebars", 550), make("c", "rgbtestsrc", 660)]; } test("ffmpeg: a held segment's last frame repeats for exactly hold·fps frames, in silence; the other inputs pass through untouched", { skip: !haveFfmpeg }, () => { const dir = mkdtempSync(path.join(tmpdir(), "deck-room-")); try { const segs = segments(dir); const own = segs.map((s) => md5s(ff(["-i", s, "-map", "0:v", "-f", "framemd5", "-"]))); own.forEach((m) => assert.equal(m.length, 60)); const joins = [null, { hold: 1, move: null }, null]; // The hard cut's graph, run to frame hashes instead of an encode. const args = hardCutFilterArgs(segs, joins, R, "-"); const fc = args[args.indexOf("-filter_complex") + 1]; const inputs = segs.flatMap((s) => ["-i", s]); const out = md5s(ff([...inputs, "-filter_complex", fc, "-map", "[vc]", "-map", "[ac]", "-f", "framemd5", "-"])); assert.equal(out.length, 60 + 60 + 30 + 60, "30 held frames and not one more"); // a and c: their own frames, bit for bit. assert.deepEqual(out.slice(0, 60), own[0]); assert.deepEqual(out.slice(150), own[2]); // b, then its last frame 15 more times. assert.deepEqual(out.slice(60, 120), own[1]); assert.deepEqual(out.slice(119, 150), Array(31).fill(own[1][59])); // The sound under the hold is silence; around it, the tones. const pcm = ff([...inputs, "-filter_complex", `${fc};[vc]nullsink`, "-map", "[ac]", "-f", "s16le", "-ac", "1", "-"], { encoding: "buffer" }); const at = (s) => pcm.readInt16LE(Math.round(s * 48000) * 2); const peak = (a, b) => { let m = 0; for (let i = Math.round(a * 48000); i < Math.round(b * 48000); i += 1) m = Math.max(m, Math.abs(pcm.readInt16LE(i * 2))); return m; }; assert.equal(pcm.length / 2, 5 * 48000 + 96000, "the sound is held as long as the picture"); assert.equal(peak(4.0, 5.0), 0, "silence under the hold"); assert.ok(peak(3.5, 4.0) > 1000, "b's tone before it"); assert.ok(peak(5.0, 5.5) > 1000, "c's tone after it"); assert.ok(Number.isFinite(at(0))); // The crossfade concat (xfade hands on its own pixel format, so frames // are compared with the graph the build ran before there were joins): // a and c come out exactly as they did -- c 30 frames later -- and b's // last frame is held up to the next dissolve. const D = 0.5; const legacy = md5s(ff([...inputs, "-filter_complex", [ "[0:v][1:v]xfade=transition=fade:duration=0.5:offset=1.500[v1]", "[0:a][1:a]acrossfade=d=0.5:c1=tri:c2=tri[a1]", "[v1][2:v]xfade=transition=fade:duration=0.5:offset=3.000[v2]", "[a1][2:a]acrossfade=d=0.5:c1=tri:c2=tri[a2]", ].join(";"), "-map", "[v2]", "-map", "[a2]", "-f", "framemd5", "-"])); assert.equal(legacy.length, 150); const xg = xfadeGraph([2, 3, 2], D, joins, R); const xo = md5s(ff([...inputs, "-filter_complex", xg.parts.join(";"), "-map", xg.vlab, "-map", xg.alab, "-f", "framemd5", "-"])); assert.equal(xo.length, 60 + 90 + 60 - 30); assert.deepEqual(xo.slice(0, 60), legacy.slice(0, 60), "a and the dissolve into b"); assert.deepEqual(xo.slice(60, 90), legacy.slice(60, 90), "b, to where the old cut dissolved"); assert.deepEqual(xo.slice(104, 120), Array(16).fill(xo[104]), "b's last frame, held to the next dissolve"); assert.deepEqual(xo.slice(135), legacy.slice(105), "c after its dissolve, 30 frames later"); // Without joins the graph is exactly the one above. const plain = xfadeGraph([2, 2, 2], D, null, R); assert.deepEqual( md5s(ff([...inputs, "-filter_complex", plain.parts.join(";"), "-map", plain.vlab, "-map", plain.alab, "-f", "framemd5", "-"])), legacy, ); } finally { rmSync(dir, { recursive: true, force: true }); } }); test("ffmpeg: the move is the identity before it starts, lands on the target box, and leaves the uncovered ground bg", { skip: !haveFfmpeg }, () => { const dir = mkdtempSync(path.join(tmpdir(), "deck-room-mv-")); try { const [, b] = segments(dir); const move = { segment: "b", at: 0, segmentAt: 0.5, seconds: 0.6, from: { x: 20, y: 10, width: 280, height: 150 }, to: { x: 8, y: 20, width: 240, height: 128 } }; const c = joinInputChain(0, { hold: 0, move }, R); const rgb = (n) => ff(["-i", b, "-filter_complex", `${c.parts.join(";")};${c.v}select=eq(n\\,${n})[o]`, "-map", "[o]", "-frames:v", "1", "-f", "rawvideo", "-pix_fmt", "rgb24", "-"], { encoding: "buffer" }); const src = (n) => ff(["-i", b, "-vf", `select=eq(n\\,${n})`, "-frames:v", "1", "-f", "rawvideo", "-pix_fmt", "rgb24", "-"], { encoding: "buffer" }); const px = (buf, x, y) => [...buf.subarray((y * 320 + x) * 3, (y * 320 + x) * 3 + 3)]; // Before the move, inside the 2 px border, the frame is the input's. const pre = rgb(10), pin = src(10); for (const [x, y] of [[20, 10], [160, 90], [299, 159], [100, 40]]) assert.deepEqual(px(pre, x, y), px(pin, x, y), `pre ${x},${y}`); // After it (0.5 + 0.6 s → frame 33 on), the footage's top-left corner is // at (8,20) and everything right of and below the target box is ground. const post = rgb(45); const bg = [0x12, 0x10, 0x1a]; const close = (p, q, tol = 3) => p.every((v, i) => Math.abs(v - q[i]) <= tol); for (const [x, y] of [[300, 10], [310, 170], [4, 4], [160, 160], [260, 100], [100, 15]]) { assert.ok(close(px(post, x, y), bg), `ground at ${x},${y}: ${px(post, x, y)}`); } // The footage's own first column/row (smptebars' left edge) now starts at the target corner. assert.ok(!close(px(post, 10, 22), bg), "footage at the target box"); assert.ok(!close(px(post, 245, 145), bg), "footage to the target box's far corner"); } finally { rmSync(dir, { recursive: true, force: true }); } }); test("ffmpeg: a move that starts inside the hold glides the frozen frame to the target box", { skip: !haveFfmpeg }, () => { const dir = mkdtempSync(path.join(tmpdir(), "deck-room-mh-")); try { const [, b] = segments(dir); // b is 2 s (60 frames), held 1 s; the move starts at 2.1 s -- inside the hold. const move = { segment: "b", at: 0, segmentAt: 2.1, seconds: 0.6, from: { x: 20, y: 10, width: 280, height: 150 }, to: { x: 8, y: 20, width: 240, height: 128 } }; const c = joinInputChain(0, { hold: 1, move }, R); c.parts.push(`${c.a}anullsink`); const all = md5s(ff(["-i", b, "-filter_complex", c.parts.join(";"), "-map", c.v, "-f", "framemd5", "-"])); assert.equal(all.length, 90, "the hold's 30 frames are all there"); const rgb = (n) => ff(["-i", b, "-filter_complex", `${c.parts.join(";")};${c.v}select=eq(n\\,${n})[o]`, "-map", "[o]", "-frames:v", "1", "-f", "rawvideo", "-pix_fmt", "rgb24", "-"], { encoding: "buffer" }); const px = (buf, x, y) => [...buf.subarray((y * 320 + x) * 3, (y * 320 + x) * 3 + 3)]; const bg = [0x12, 0x10, 0x1a]; const close = (p, q, tol = 3) => p.every((v, i) => Math.abs(v - q[i]) <= tol); // Frozen and not yet moved at 2.0 s; moving from 2.1 s; landed by 2.7 s. assert.equal(all[60], all[62], "held, before the move"); assert.ok(!close(px(rgb(62), 290, 150), bg), "footage still at the from box"); assert.notEqual(all[66], all[62], "the held frame moves"); const post = rgb(85); for (const [x, y] of [[300, 10], [310, 170], [260, 100]]) assert.ok(close(px(post, x, y), bg), `ground at ${x},${y}`); assert.ok(!close(px(post, 245, 145), bg), "footage to the target box's far corner"); assert.deepEqual(all.slice(82), Array(8).fill(all[82]), "landed, then still"); } finally { rmSync(dir, { recursive: true, force: true }); } }); test("verify-build: a held clip's freeze is found in the file, and a cut that dropped it is refused", { skip: !haveFfmpeg }, async () => { const { verifyHolds } = await import("./verify-build.mjs"); const dir = mkdtempSync(path.join(tmpdir(), "deck-room-vb-")); try { const render = { ...R, width: 640, height: 360, chrome: { engine: "hyperframes", layout: "deck", deck: { height: 120, posts: { width: 320 } } } }; const held = path.join(dir, "held.mp4"); const moving = path.join(dir, "moving.mp4"); const enc = ["-pix_fmt", "yuv420p", "-c:v", "libx264", "-preset", "ultrafast", "-crf", "18"]; ff(["-f", "lavfi", "-i", "testsrc2=s=640x360:r=30:d=2", "-vf", "tpad=stop_mode=clone:stop_duration=1", ...enc, held]); ff(["-f", "lavfi", "-i", "testsrc2=s=640x360:r=30:d=3", ...enc, moving]); const schedule = { fps: 30, total: 3, segments: [{ id: "c1", start: 0, duration: 3, end: 3, hold: 1 }] }; const ok = []; const got = await verifyHolds(held, schedule, render, ok); assert.deepEqual(ok, []); assert.equal(got.length, 1); assert.ok(got[0].diff <= 1.5, `diff ${got[0].diff}`); const bad = []; await verifyHolds(moving, schedule, render, bad); assert.equal(bad.length, 1); assert.match(bad[0], /c1 is held 1s but its picture moves during the hold/); // Nothing held, nothing to check. assert.deepEqual(await verifyHolds(moving, { ...schedule, segments: [{ id: "c1", start: 0, duration: 3, end: 3 }] }, render, bad), []); } finally { rmSync(dir, { recursive: true, force: true }); } });