// Tests for the cut's edits made where it is joined (slice B2): a clip's // `muteFrom` (source seconds → the segment's clock, through the cut record // the build writes beside each segment) and `render.endFade` on the cut's // last segment. The chains as strings, unchanged without them; the mapping; // validation; and real ffmpeg runs showing the sound after `muteFrom` is // digital silence, the picture is untouched, the end fade reaches bg and // silence on the last frame, and the length and A/V sync do not move. // // Run with: pnpm test:scripts import assert from "node:assert/strict"; import { spawnSync } from "node:child_process"; import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import path from "node:path"; import test from "node:test"; import { concatListText, concatRecordText, cutJoins, cutRecordPath, endFadeAudioFilter, endFadeFrames, endFadeVideoFilter, hardCutFilterArgs, joinInputChain, muteAudioFilter, sameConcatList, withCutEdits, xfadeGraph, yuv601, } from "./build-video.mjs"; import { endFadeOf, MUTE_FADE, muteSegmentSeconds, playWindow, validateChrome, validateCutEdits, validateEndFade, validateMuteFrom, } 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, audioRate: 48000, audioChannels: 2, chrome: { engine: "hyperframes", layout: "deck", deck: {} }, }; const CLIP = { id: "c20", type: "clip", video: "B36", start: 24022.6, end: 24029.6 }; // ---- validation ------------------------------------------------------------- test("validateMuteFrom: a number of source seconds within the clip's extent; only on a clip", () => { assert.deepEqual(validateMuteFrom(CLIP), []); assert.deepEqual(validateMuteFrom({ ...CLIP, muteFrom: null }), []); assert.deepEqual(validateMuteFrom({ ...CLIP, muteFrom: 24029.3 }), []); assert.deepEqual(validateMuteFrom({ ...CLIP, muteFrom: 24022.6 }), [], "the start is inside"); assert.deepEqual(validateMuteFrom({ ...CLIP, muteFrom: 24029.6 }), [], "the end is inside"); assert.match(validateMuteFrom({ ...CLIP, muteFrom: 24029.7 })[0], /muteFrom 24029\.7 is outside the clip's 24022\.6–24029\.6/); assert.match(validateMuteFrom({ ...CLIP, muteFrom: 3 })[0], /outside/); assert.match(validateMuteFrom({ ...CLIP, muteFrom: "24029" })[0], /must be a number of source seconds/); assert.match(validateMuteFrom({ ...CLIP, muteFrom: NaN })[0], /must be a number/); assert.match(validateMuteFrom({ id: "t1", type: "card", muteFrom: 2 })[0], /only a clip has sound to mute/); }); test("validateEndFade and validateCutEdits: seconds from 0 to 10; every entry named by its place", () => { assert.deepEqual(validateEndFade({}), []); assert.deepEqual(validateEndFade({ endFade: 0 }), []); assert.deepEqual(validateEndFade({ endFade: 1.5 }), []); for (const bad of [-1, 11, "1", NaN]) assert.match(validateEndFade({ endFade: bad })[0], /render\.endFade must be from 0 to 10 seconds/); assert.equal(endFadeOf({}), 0); assert.equal(endFadeOf({ endFade: 1 }), 1); assert.equal(endFadeOf({ endFade: -1 }), 0); const errs = validateCutEdits({ render: { endFade: 20 }, timeline: [CLIP, { ...CLIP, id: "c21", muteFrom: 1 }], }); assert.equal(errs.length, 2); assert.match(errs[0], /^timeline\[1\] \(c21\)\.muteFrom 1 is outside/); assert.match(errs[1], /render\.endFade/); assert.deepEqual(validateCutEdits({ render: {}, timeline: [CLIP] }), []); // The deck's validator refuses a bad end fade too; a good one changes nothing. assert.ok(validateChrome(RENDER.chrome, { ...RENDER, endFade: 99 }).some((e) => /render\.endFade/.test(e))); assert.deepEqual(validateChrome(RENDER.chrome, { ...RENDER, endFade: 1 }), []); }); // ---- the mapping, source → segment ------------------------------------------- test("muteSegmentSeconds: from the cut record's snapped start when it matches the segment", () => { const record = { version: 1, id: "c20", video: "B36", start: 24022.5, end: 24029.5 }; assert.deepEqual( muteSegmentSeconds({ entry: { ...CLIP, muteFrom: 24029.3 }, record, render: RENDER, seconds: 7 }), { at: 6.8, source: "record" }, ); // A muteFrom before the segment's real start mutes it from its first sample. assert.equal(muteSegmentSeconds({ entry: { ...CLIP, muteFrom: 24022.6 }, record: { ...record, start: 24022.7, end: 24029.7 }, render: RENDER, seconds: 7 }).at, 0); }); test("muteSegmentSeconds: no record, a stale one or another video's -- the unsnapped start, and a note that says so", () => { const entry = { ...CLIP, muteFrom: 24029.3 }; const none = muteSegmentSeconds({ entry, record: null, render: RENDER, seconds: 7 }); assert.equal(none.at, 6.7); assert.equal(none.source, "window"); assert.match(none.note, /^c20: no cut record beside the segment — muteFrom measured from the unsnapped start 24022\.6; the real start may differ by up to 1\.6s/); const stale = muteSegmentSeconds({ entry, record: { video: "B36", start: 24020, end: 24030 }, render: RENDER, seconds: 7 }); assert.equal(stale.source, "window"); assert.match(stale.note, /the cut record does not match the segment/); assert.equal(muteSegmentSeconds({ entry, record: { video: "other", start: 24022.5, end: 24029.5 }, render: RENDER, seconds: 7 }).source, "window"); // Within two frames of the segment's length is a match. assert.equal(muteSegmentSeconds({ entry, record: { video: "B36", start: 24022.6, end: 24029.65 }, render: RENDER, seconds: 7 }).source, "record"); // A clip with a tight cut plays from cutStart less the lead-in. const cut = { ...CLIP, cutStart: 24025, cutEnd: 24029, muteFrom: 24028 }; assert.deepEqual(playWindow(cut, RENDER), { from: 24024.6, to: 24029 }); assert.equal(muteSegmentSeconds({ entry: cut, render: RENDER }).at, 3.4); }); // ---- the chains, as strings ------------------------------------------------- test("muteAudioFilter: afade out ENDING at the mute point, silent after; volume=0 from the first sample", () => { assert.equal(MUTE_FADE, 0.04); assert.equal(muteAudioFilter(6.7), "afade=t=out:st=6.66:d=0.04"); assert.equal(muteAudioFilter(0.02), "afade=t=out:st=0:d=0.02"); assert.equal(muteAudioFilter(0), "volume=0"); }); test("the end fade: a yuv blend toward bg from frame s = last − n, so the LAST frame is bg; silence at that frame's time", () => { assert.deepEqual(yuv601("#12101a"), { y: 31, u: 132, v: 128 }, "what pad wrote into the ferret segments"); assert.deepEqual(yuv601("#000000"), { y: 16, u: 128, v: 128 }); assert.deepEqual(yuv601("#ffffff"), { y: 235, u: 128, v: 128 }); const fade = { seconds: 1, lastFrame: 284 }; // 7 s + 2.5 s hold at 30 fps = 285 frames assert.equal(endFadeVideoFilter(fade, RENDER), "geq=lum='lum(X,Y)+(31-lum(X,Y))*clip((T-8.4667)/1,0,1)+0.5':cb='cb(X,Y)+(132-cb(X,Y))*clip((T-8.4667)/1,0,1)+0.5':cr='cr(X,Y)+(128-cr(X,Y))*clip((T-8.4667)/1,0,1)+0.5':enable='gte(t,8.4667)'"); assert.equal(endFadeAudioFilter(fade, RENDER), "afade=t=out:st=8.4667:d=1"); }); test("the end fade: longer than its segment, it is the segment -- frames and seconds clamped alike, so the last frame is bg", () => { // A 3 s segment at 30 fps (frames 0..89) under endFade 5: the fade spans // the segment, from frame 0 (weight 0) to frame 89 (weight 1). const fade = { seconds: 5, lastFrame: 89 }; assert.equal(endFadeFrames(fade, 30), 89); assert.equal(endFadeFrames({ seconds: 1, lastFrame: 89 }, 30), 30, "a shorter fade is its own length"); assert.equal(endFadeFrames({ seconds: 5, lastFrame: 0 }, 30), 1, "never zero frames"); assert.equal(endFadeVideoFilter(fade, RENDER), "geq=lum='lum(X,Y)+(31-lum(X,Y))*clip((T-0)/2.9667,0,1)+0.5':cb='cb(X,Y)+(132-cb(X,Y))*clip((T-0)/2.9667,0,1)+0.5':cr='cr(X,Y)+(128-cr(X,Y))*clip((T-0)/2.9667,0,1)+0.5':enable='gte(t,0)'"); assert.equal(endFadeAudioFilter(fade, RENDER), "afade=t=out:st=0:d=2.9667", "the sound over the same frames"); }); test("joinInputChain: a mute alone is a chain on the sound only; the picture is the input's own", () => { assert.deepEqual(joinInputChain(2, { hold: 0, move: null, mute: 6.7 }, RENDER), { parts: ["[2:a]afade=t=out:st=6.66:d=0.04[j2a]"], v: "[2:v]", a: "[j2a]", }); // Mute, then the hold's silence, then the end fade, in that order; the // picture holds, moves (none here) and fades. const fade = { seconds: 1, lastFrame: 284 }; assert.deepEqual(joinInputChain(0, { hold: 2.5, move: null, mute: 6.7, fade }, RENDER), { parts: [ "[0:v]tpad=stop_mode=clone:stop_duration=2.5,geq=lum='lum(X,Y)+(31-lum(X,Y))*clip((T-8.4667)/1,0,1)+0.5':cb='cb(X,Y)+(132-cb(X,Y))*clip((T-8.4667)/1,0,1)+0.5':cr='cr(X,Y)+(128-cr(X,Y))*clip((T-8.4667)/1,0,1)+0.5':enable='gte(t,8.4667)'[j0v]", "[0:a]afade=t=out:st=6.66:d=0.04,apad=pad_dur=2.5,afade=t=out:st=8.4667:d=1[j0a]", ], v: "[j0v]", a: "[j0a]", }); }); test("withCutEdits: nothing to add leaves the joins as they were (null stays null); edits land on their segments", () => { assert.equal(withCutEdits(null, 3), null); const joins = [null, { hold: 2.5, move: null }, null]; assert.equal(withCutEdits(joins, 3, {}), joins, "the same joins, not a copy"); const fade = { seconds: 1, lastFrame: 209 }; const out = withCutEdits(joins, 3, { mutes: new Map([[1, 4], [2, 6.7]]), fade }); assert.deepEqual(out, [ null, { hold: 2.5, move: null, mute: 4 }, { hold: 0, move: null, mute: 6.7, fade }, ]); assert.deepEqual(joins[1], { hold: 2.5, move: null }, "the schedule's joins are not written to"); assert.deepEqual(withCutEdits(null, 2, { fade }), [null, { hold: 0, move: null, fade }]); }); test("the graphs: unchanged without edits; the hard cut's record names a mute and a fade, so a changed one is never reused", () => { // No edits: the plain crossfade graph (each sound pinned to its picture). assert.deepEqual(xfadeGraph([10, 12], 0.5, withCutEdits(null, 2), RENDER).parts, [ "[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]", "[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]", ]); const segs = ["/s/a.mp4", "/s/b.mp4"]; const holdOnly = [null, { hold: 2.5, move: null }]; assert.equal(concatRecordText(segs, withCutEdits(null, 2)), concatListText(segs)); assert.equal( concatRecordText(segs, holdOnly), concatListText(segs) + '# join 1 {"hold":2.5,"move":null}\n', "a hold's record line is the one it always was", ); const fade = { seconds: 1, lastFrame: 359 }; const edited = withCutEdits(holdOnly, 2, { mutes: new Map([[0, 3]]), fade }); const rec = concatRecordText(segs, edited); assert.match(rec, /^# join 0 \{"hold":0,"move":null,"mute":3\}$/m); assert.match(rec, /^# join 1 \{"hold":2\.5,"move":null,"fade":\{"seconds":1,"lastFrame":359\}\}$/m); assert.equal(sameConcatList(rec, segs, edited), true); assert.equal(sameConcatList(rec, segs, holdOnly), false); assert.equal(sameConcatList(rec, segs, withCutEdits(holdOnly, 2, { mutes: new Map([[0, 3.5]]), fade })), false); assert.equal(sameConcatList(rec, segs, withCutEdits(holdOnly, 2, { mutes: new Map([[0, 3]]) })), false); const fc = hardCutFilterArgs(segs, edited, RENDER, "/o.mp4"); assert.equal(fc[fc.indexOf("-filter_complex") + 1], "[0:a]afade=t=out:st=2.96:d=0.04[j0a];" + "[1:v]tpad=stop_mode=clone:stop_duration=2.5,geq=lum='lum(X,Y)+(31-lum(X,Y))*clip((T-10.9667)/1,0,1)+0.5':cb='cb(X,Y)+(132-cb(X,Y))*clip((T-10.9667)/1,0,1)+0.5':cr='cr(X,Y)+(128-cr(X,Y))*clip((T-10.9667)/1,0,1)+0.5':enable='gte(t,10.9667)'[j1v];" + "[1:a]apad=pad_dur=2.5,afade=t=out:st=10.9667:d=1[j1a];" + "[0:v][j0a][j1v][j1a]concat=n=2:v=1:a=1[vc][ac]"); }); // ---- ffmpeg, for real ------------------------------------------------------- const haveFfmpeg = spawnSync("ffmpeg", ["-version"], { stdio: "ignore" }).status === 0; 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; }; 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, framed as the deck frames them, each with a tone. */ function segments(dir, ext = "mov") { const make = (name, src, hz) => { const f = path.join(dir, `${name}.${ext}`); const codec = ext === "mov" ? ["-c:v", "ffv1", "-c:a", "pcm_s16le"] : ["-c:v", "libx264", "-preset", "ultrafast", "-pix_fmt", "yuv420p", "-c:a", "aac"]; 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]", ...codec, f, ]); return f; }; return [make("a", "testsrc2", 440), make("b", "smptebars", 550), make("c", "rgbtestsrc", 660)]; } /** Run a graph to mono s16 PCM (the picture sunk), and to frame hashes. */ const pcmOf = (inputs, parts, v, a) => ff([...inputs, "-filter_complex", `${parts.join(";")};${v}nullsink`, "-map", a, "-f", "s16le", "-ac", "1", "-ar", "48000", "-"], { encoding: "buffer" }); const framesOf = (inputs, parts, v, a) => md5s(ff([...inputs, "-filter_complex", `${parts.join(";")};${a}anullsink`, "-map", v, "-f", "framemd5", "-"])); const sample = (pcm, i) => pcm.readInt16LE(i * 2); const peak = (pcm, a, b) => { let m = 0; for (let i = Math.round(a * 48000); i < Math.min(pcm.length / 2, Math.round(b * 48000)); i += 1) m = Math.max(m, Math.abs(sample(pcm, i))); return m; }; test("ffmpeg: after muteFrom the sound is digital silence; before its fade it is the clip's own; the picture is untouched", { skip: !haveFfmpeg }, () => { const dir = mkdtempSync(path.join(tmpdir(), "cut-mute-")); try { const [a, b, c] = segments(dir); const inputs = [a, b, c].flatMap((s) => ["-i", s]); const D = 0.5; const plain = xfadeGraph([2, 2, 2], D, null, R); const joins = withCutEdits(null, 3, { mutes: new Map([[1, 1.2]]) }); const muted = xfadeGraph([2, 2, 2], D, joins, R); // The picture: frame for frame the graph without the mute. assert.deepEqual(framesOf(inputs, muted.parts, muted.vlab, muted.alab), framesOf(inputs, plain.parts, plain.vlab, plain.alab)); const p0 = pcmOf(inputs, plain.parts, plain.vlab, plain.alab); const p1 = pcmOf(inputs, muted.parts, muted.vlab, muted.alab); assert.equal(p1.length, p0.length, "the sound is as long as it was"); // b plays from 1.5 s in the cut, so its mute point is 2.7 s; the dissolve // into c starts at 3.0 s. Before the fade (2.66 s), every sample is the // unmuted cut's -- nothing moved, so A/V sync is what it was. const fadeAt = Math.round((1.5 + 1.2 - MUTE_FADE) * 48000); assert.ok(p1.subarray(0, fadeAt * 2).equals(p0.subarray(0, fadeAt * 2)), "untouched before the fade"); assert.ok(peak(p0, 2.7, 3.0) > 1000, "b sounds there without the mute"); assert.equal(peak(p1, 2.7, 3.0), 0, "digital silence from the mute point to the dissolve"); assert.ok(peak(p1, 2.66, 2.7) > 0 && peak(p1, 2.66, 2.7) < peak(p0, 2.66, 2.7), "a fade, not a click"); // From the dissolve on, only c's sound: the cut after it is c's own. const cOnly = pcmOf(["-i", c], ["[0:a]anull[a]"], "[0:v]", "[a]"); const tail = p1.subarray(Math.round(3.5 * 48000) * 2, Math.round(5.5 * 48000) * 2); const own = cOnly.subarray(Math.round(0.5 * 48000) * 2, Math.round(2.5 * 48000) * 2); assert.ok(peak(p1, 3.6, 5.4) > 1000); assert.equal(tail.length, own.length); } finally { rmSync(dir, { recursive: true, force: true }); } }); test("ffmpeg: the end fade -- the last frame is bg and the sound silent there; with a hold and a hard cut, the length is unchanged", { skip: !haveFfmpeg }, () => { const dir = mkdtempSync(path.join(tmpdir(), "cut-fade-")); try { const segs = segments(dir); const inputs = segs.flatMap((s) => ["-i", s]); // c is 60 frames, held 0.5 s (15 frames): its last frame is 74. const base = [null, null, { hold: 0.5, move: null }]; const fade = { seconds: 1, lastFrame: 74 }; const joins = withCutEdits(base, 3, { fade }); const plainArgs = hardCutFilterArgs(segs, base, R, "-"); const fadeArgs = hardCutFilterArgs(segs, joins, R, "-"); const fc = (args) => [args[args.indexOf("-filter_complex") + 1]]; const f0 = framesOf(inputs, fc(plainArgs), "[vc]", "[ac]"); const f1 = framesOf(inputs, fc(fadeArgs), "[vc]", "[ac]"); assert.equal(f1.length, f0.length, "as many frames as without the fade"); assert.equal(f1.length, 60 + 60 + 75); assert.deepEqual(f1.slice(0, 120 + 44), f0.slice(0, 120 + 44), "every frame before the fade is the same"); assert.notDeepEqual(f1[120 + 50], f0[120 + 50], "fading"); // The last frame, decoded: bg everywhere. const last = ff([...inputs, "-filter_complex", `${fc(fadeArgs).join(";")};[ac]anullsink;[vc]select=eq(n\\,194)[o]`, "-map", "[o]", "-frames:v", "1", "-f", "rawvideo", "-pix_fmt", "rgb24", "-"], { encoding: "buffer" }); const bg = [0x12, 0x10, 0x1a]; let worst = 0; for (let i = 0; i < last.length; i += 1) worst = Math.max(worst, Math.abs(last[i] - bg[i % 3])); assert.ok(worst <= 2, `the last frame is bg (worst channel off by ${worst})`); // The sound: as long as without the fade, the same up to it, silent from the last frame's time. const p0 = pcmOf(inputs, fc(plainArgs), "[vc]", "[ac]"); const p1 = pcmOf(inputs, fc(fadeArgs), "[vc]", "[ac]"); assert.equal(p1.length, p0.length); // The hold is silent already; give c's own tone the fade instead. const toneJoins = withCutEdits([null, null, null], 3, { fade: { seconds: 1, lastFrame: 59 } }); const t0 = pcmOf(inputs, fc(hardCutFilterArgs(segs, [null, null, null], R, "-")), "[vc]", "[ac]"); const t1 = pcmOf(inputs, fc(hardCutFilterArgs(segs, toneJoins, R, "-")), "[vc]", "[ac]"); assert.equal(t1.length, t0.length); const lastAt = 4 + 59 / 30; // c starts at 4 s in the hard cut const fadeStart = Math.round((lastAt - 1) * 48000); assert.ok(t1.subarray(0, fadeStart * 2).equals(t0.subarray(0, fadeStart * 2)), "the same sound up to the fade"); assert.ok(peak(t1, lastAt - 0.9, lastAt - 0.8) < peak(t0, lastAt - 0.9, lastAt - 0.8), "fading"); assert.equal(peak(t1, lastAt, 6), 0, "silent from the last frame on"); assert.ok(peak(t0, lastAt, 6) > 1000); } finally { rmSync(dir, { recursive: true, force: true }); } }); test("ffmpeg: an end fade longer than the last segment -- a 3 s segment under endFade 5 still ends on bg and in silence", { skip: !haveFfmpeg }, () => { const dir = mkdtempSync(path.join(tmpdir(), "cut-longfade-")); try { const seg = path.join(dir, "t.mov"); ff([ "-f", "lavfi", "-i", "testsrc2=s=280x150:r=30:d=3", "-f", "lavfi", "-i", "sine=frequency=440:sample_rate=48000:duration=3", "-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", seg, ]); const joins = withCutEdits([null], 1, { fade: { seconds: 5, lastFrame: 89 } }); const { parts, v, a } = joinInputChain(0, joins[0], R); const inputs = ["-i", seg]; assert.equal(framesOf(inputs, parts, v, a).length, 90, "the length is unchanged"); const frame = (n) => ff([...inputs, "-filter_complex", `${parts.join(";")};${a}anullsink;${v}select=eq(n\\,${n})[o]`, "-map", "[o]", "-frames:v", "1", "-f", "rawvideo", "-pix_fmt", "rgb24", "-"], { encoding: "buffer" }); const bg = [0x12, 0x10, 0x1a]; const off = (buf) => { let worst = 0; for (let i = 0; i < buf.length; i += 1) worst = Math.max(worst, Math.abs(buf[i] - bg[i % 3])); return worst; }; assert.ok(off(frame(89)) <= 2, `the last frame is bg (worst channel off by ${off(frame(89))})`); assert.ok(off(frame(44)) > 20, "halfway, still fading"); const pcm = pcmOf(inputs, parts, v, a); const lastAt = 89 / 30; assert.equal(peak(pcm, lastAt, 3), 0, "silent from the last frame's time"); assert.ok(peak(pcm, 0.1, 0.2) > 1000, "sounding at the start"); } finally { rmSync(dir, { recursive: true, force: true }); } }); test("cutJoins: the record beside the segment places muteFrom; the end fade counts the last segment's frames and hold", { skip: !haveFfmpeg }, async () => { const dir = mkdtempSync(path.join(tmpdir(), "cut-joins-")); try { const segs = segments(dir, "mp4"); const entries = [ { id: "a", type: "clip", video: "va", start: 100, end: 102 }, { id: "b", type: "clip", video: "vb", start: 200, end: 203, muteFrom: 201.5 }, { id: "c", type: "clip", video: "vc", start: 300, end: 302 }, ]; // No record for b: the unsnapped start (200), 1.5 s in. assert.equal(await cutJoins({ entries: entries.slice(0, 1), segments: segs.slice(0, 1), render: R }), null, "nothing to join"); let j = await cutJoins({ entries, segments: segs, render: R }); assert.deepEqual(j, [null, { hold: 0, move: null, mute: 1.5 }, null]); // b's record says it was cut from 200.3: 1.2 s in. writeFileSync(cutRecordPath(segs[1]), JSON.stringify({ version: 1, id: "b", video: "vb", start: 200.3, end: 202.3 })); j = await cutJoins({ entries, segments: segs, render: R }); assert.equal(j[1].mute, 1.2); // The end fade on c, with the deck's hold on it: 60 + 15 frames. const schedule = { segments: [{ id: "a" }, { id: "b" }, { id: "c", hold: 0.5 }] }; j = await cutJoins({ schedule, entries, segments: segs, render: { ...R, endFade: 1 } }); assert.deepEqual(j[2], { hold: 0.5, move: null, fade: { seconds: 1, lastFrame: 74 } }); assert.equal(j[0], null); } finally { rmSync(dir, { recursive: true, force: true }); } }); test("cutJoins: a muteFrom past the cut's end says it will not be heard, not that the clip is muted from there", { skip: !haveFfmpeg }, async () => { const dir = mkdtempSync(path.join(tmpdir(), "cut-latemute-")); const said = []; const log = console.log; console.log = (line) => said.push(String(line)); try { const segs = segments(dir, "mp4"); // b's segment is 2 s, cut from 200; the clip's extent runs to 203, so a // mark at 202.5 validates but lies 2.5 s into a 2 s segment. writeFileSync(cutRecordPath(segs[1]), JSON.stringify({ version: 1, id: "b", video: "vb", start: 200, end: 202 })); const entries = [ { id: "a", type: "clip", video: "va", start: 100, end: 102, muteFrom: 101 }, { id: "b", type: "clip", video: "vb", start: 200, end: 203, cutEnd: 202, muteFrom: 202.5 }, ]; const j = await cutJoins({ entries, segments: segs.slice(0, 2), render: R }); assert.equal(j[1].mute, 2.5); const b = said.filter((l) => l.startsWith("b:")); assert.equal(b.length, 1, b.join("\n")); assert.match(b[0], /^b: muteFrom 202\.5 will not be heard -- it lies 2\.5s into a segment 2s long, past the cut's end/); assert.ok(said.some((l) => /^a: muted from 1s into its segment/.test(l)), said.join("\n")); } finally { console.log = log; rmSync(dir, { recursive: true, force: true }); } });