Archilyzer · Source

archilyzer

Archilyzer
git clone https://archilyzer.pages.dev/source/archilyzer.git
Log | Files | Refs | README | LICENSE

commit b160275125786b8e3db433a6d7b3d8315626ac34
parent 51cb39d0a00427331be8b56ed07a55e6108e370a
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date:   Thu,  1 Oct 2026 14:18:37 -0400

umtool: a clip's muteFrom and render.endFade, made where the cut is joined; the QR's host label runs the code's full height

muteFrom (source seconds, within the clip's start–end) silences the clip from
that point to its end, after a 40 ms fade that ends there, while the picture
plays on; any hold on it stays silent. render.endFade (seconds, 0 = off) fades
the cut's last segment, picture to palette.bg and sound to silence, over its
final seconds, the hold included, reaching both on the last frame. Both are
applied on that input's chain before the join, like the hold, so --chrome-only
changes them without rebuilding a segment, and the hard-cut record names them.

A clip build now writes <id>.cut.json beside its segment: the source seconds
it was really cut from after snapping. muteFrom is measured from that record;
a segment without one, or whose record does not match it, falls back to the
unsnapped start and says so.

The end fade blends each plane toward bg in yuv420p with geq: a coloured
fade takes RGB only, and the concat filter would then convert every segment
of the cut to rgb24 and back.

The host label beside the deck's QR is sized at load from the face's measured
ink so it runs exactly the code's height, bottom edge to top edge, for any
host.

Without muteFrom or endFade every graph, record and schedule is unchanged.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

Diffstat:
Mumtool/report-to-video/build-video.mjs | 186+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--------
Mumtool/report-to-video/chrome-deck.mjs | 41++++++++++++++++++++++++++++++++++++++---
Mumtool/report-to-video/chrome-deck.test.mjs | 37+++++++++++++++++++++++++++++++++++++
Aumtool/report-to-video/cut-edits.test.mjs | 326+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mumtool/report-to-video/deck.mjs | 109+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----
5 files changed, 673 insertions(+), 26 deletions(-)

diff --git a/umtool/report-to-video/build-video.mjs b/umtool/report-to-video/build-video.mjs @@ -80,8 +80,8 @@ import { createCueSource, siteOriginFromManifest } from "./cues.mjs"; // and live in deck.mjs. This file only frames segments into its box and writes // the schedule down -- it never has a copy of the arithmetic. import { - assertChrome, deckGeometry, deckOn, deckSchedule, frameCount, postsGeometry, postWindows, resolveDeck, - scheduleFrom, snapWindow, validatePosts, + assertChrome, deckGeometry, deckOn, deckSchedule, endFadeOf, frameCount, MUTE_FADE, muteSegmentSeconds, + playWindow, postsGeometry, postWindows, resolveDeck, scheduleFrom, snapWindow, validateCutEdits, validatePosts, } from "./deck.mjs"; // The per-platform yt-dlp args (Rumble's `--impersonate chrome`): the ONE table, // in common, plain JS so bare `node` can load it. @@ -713,10 +713,8 @@ async function buildClipSegment(entry, meta, render, dirs, opts, chrome, nodes, // The lead-in is a breath before the first word, clamped into the extent: // starting exactly on the quote's first syllable sounds like a dropped // frame. - const lead = render.leadIn ?? 0.4; const hasCut = Number.isFinite(entry.cutStart) && Number.isFinite(entry.cutEnd); - const playFrom = hasCut ? Math.max(entry.start, entry.cutStart - lead) : entry.start; - const playTo = hasCut ? Math.min(entry.end, entry.cutEnd) : entry.end; + const { from: playFrom, to: playTo } = playWindow(entry, render); if (hasCut) { EMIT("cut", { id: entry.id, @@ -749,6 +747,14 @@ async function buildClipSegment(entry, meta, render, dirs, opts, chrome, nodes, const cutA = Math.min(a.at, wantB - 1); const cutB = Math.max(b.at, cutA + 1); EMIT("snap", { id: entry.id, start: a.snapped, end: b.snapped, seconds: cutB - cutA }); + // Where in the SOURCE this segment really starts and ends, snapped: what a + // `muteFrom` (source seconds) is measured from at the join, long after this + // function is gone (`--chrome-only` rebuilds no segment). + const cutRecord = { + version: 1, id: entry.id, video: entry.video, + start: Number((fetchStart + cutA).toFixed(3)), end: Number((fetchStart + cutB).toFixed(3)), + snapped: { start: a.snapped, end: b.snapped }, + }; const quotePath = path.join(outDir, "segments", `${entry.id}.quote.txt`); const attribPath = path.join(outDir, "segments", `${entry.id}.attrib.txt`); @@ -788,6 +794,7 @@ async function buildClipSegment(entry, meta, render, dirs, opts, chrome, nodes, ], { maxBuffer: 1 << 24 }, ); + await writeCutRecord(seg, cutRecord); return seg; } @@ -918,9 +925,22 @@ async function buildClipSegment(entry, meta, render, dirs, opts, chrome, nodes, ], { maxBuffer: 1 << 24 }, ); + await writeCutRecord(seg, cutRecord); return seg; } +/** Beside `segments/<id>.mp4`: `<id>.cut.json`, the source seconds it was cut from. */ +export const cutRecordPath = (seg) => seg.replace(/\.mp4$/, ".cut.json"); + +async function writeCutRecord(seg, record) { + await writeFile(cutRecordPath(seg), JSON.stringify(record) + "\n", "utf8"); +} + +/** A segment's cut record, or null when there is none (a segment built before records existed). */ +export async function readCutRecord(seg) { + return readFile(cutRecordPath(seg), "utf8").then(JSON.parse, () => null); +} + // ---- QR provenance code -------------------------------------------------- // A compilation asks the viewer to take the edit on trust. The QR is the antidote: // it resolves to this clip's exact START in the archive's own viewer, so anyone can @@ -2189,6 +2209,64 @@ export const holdVideoFilter = (hold) => `tpad=stop_mode=clone:stop_duration=${e export const holdAudioFilter = (hold) => `apad=pad_dur=${exprNum(hold)}`; /** + * A `muteFrom` on a segment's sound: silent from `at` (segment seconds) to its + * end, after a MUTE_FADE that ENDS at `at`, so nothing of a sound that starts + * there gets through and there is no click. `afade` out writes digital silence + * (zeros) after its fade and copies every sample before it. At 0 the whole + * segment is silent. + */ +export const muteAudioFilter = (at) => { + if (!(at > 0)) return "volume=0"; + const st = Math.max(0, at - MUTE_FADE); + return `afade=t=out:st=${exprNum(st)}:d=${exprNum(at - st)}`; +}; + +/** + * A `#rrggbb` colour as 8-bit limited-range BT.601 Y′CbCr -- what `pad` and a + * `color` source write for it into the segments' yuv420p (`#12101a` is + * 31/132/128 in both, measured). + */ +export function yuv601(hex) { + const m = /^#?([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})$/i.exec(String(hex)); + if (!m) throw new Error(`not a #rrggbb colour: ${hex}`); + const [r, g, b] = [m[1], m[2], m[3]].map((h) => parseInt(h, 16) / 255); + return { + y: Math.round(16 + 65.481 * r + 128.553 * g + 24.966 * b), + u: Math.round(128 - 37.797 * r - 74.203 * g + 112 * b), + v: Math.round(128 + 112 * r - 93.786 * g - 18.214 * b), + }; +} + +/** + * The end fade on the cut's last segment, over its final `seconds`: the + * picture eased to `palette.bg`, so the LAST frame (`lastFrame`, 0-based, the + * hold's clones included) is exactly bg; the sound faded to silence at that + * frame's time. + * + * Not `fade=…:color=`: a coloured fade takes RGB only, so ffmpeg converts the + * segment to rgb24 -- and the concat filter then negotiates every OTHER + * segment to rgb24 too, a lossy round trip for the whole cut. `geq` blends + * each plane toward bg's Y′CbCr in the segment's own yuv420p, and only from + * the fade's first frame (`enable`): every frame before it passes untouched. + * Frame f's weight is (f − s)/n with s = lastFrame − n, so s is the last + * frame untouched and lastFrame is bg; `+0.5` rounds where geq truncates. + */ +export const endFadeVideoFilter = (fade, render) => { + const fps = render.fps; + const n = Math.max(1, Math.round(fade.seconds * fps)); + const st = exprNum(Math.max(0, fade.lastFrame - n) / fps); + const k = `clip((T-${st})/${exprNum(n / fps)},0,1)`; + const bg = yuv601(render.palette.bg); + const plane = (p, c) => `'${p}(X,Y)+(${c}-${p}(X,Y))*${k}+0.5'`; + return `geq=lum=${plane("lum", bg.y)}:cb=${plane("cb", bg.u)}:cr=${plane("cr", bg.v)}:enable='gte(t,${st})'`; +}; +export const endFadeAudioFilter = (fade, render) => { + const end = fade.lastFrame / render.fps; + const st = Math.max(0, end - fade.seconds); + return `afade=t=out:st=${exprNum(st)}:d=${exprNum(Math.max(1e-3, end - st))}`; +}; + +/** * Input `i`'s chains before the join. Without a join the labels are the * input's own (`[i:v]`, `[i:a]`) and there is no chain at all, so a cut * without posts writes the graph it always did. @@ -2204,16 +2282,74 @@ export const holdAudioFilter = (hold) => `apad=pad_dur=${exprNum(hold)}`; export function joinInputChain(i, join, render) { if (!join) return { parts: [], v: `[${i}:v]`, a: `[${i}:a]` }; const parts = []; - const vf = [join.hold > 0 ? holdVideoFilter(join.hold) : null, join.move ? moveFilter(join.move, render) : null] - .filter(Boolean); + // Picture: hold, move, end fade. Sound: mute, hold, end fade -- the mute is + // in the clip's own clock and the hold is silence anyway; the end fade is + // last on both, over the segment's final seconds as the cut plays them. + const vf = [ + join.hold > 0 ? holdVideoFilter(join.hold) : null, + join.move ? moveFilter(join.move, render) : null, + join.fade ? endFadeVideoFilter(join.fade, render) : null, + ].filter(Boolean); const v = vf.length ? `[j${i}v]` : `[${i}:v]`; if (vf.length) parts.push(`[${i}:v]${vf.join(",")}${v}`); - const a = join.hold > 0 ? `[j${i}a]` : `[${i}:a]`; - if (join.hold > 0) parts.push(`[${i}:a]${holdAudioFilter(join.hold)}${a}`); + const af = [ + join.mute != null ? muteAudioFilter(join.mute) : null, + join.hold > 0 ? holdAudioFilter(join.hold) : null, + join.fade ? endFadeAudioFilter(join.fade, render) : null, + ].filter(Boolean); + const a = af.length ? `[j${i}a]` : `[${i}:a]`; + if (af.length) parts.push(`[${i}:a]${af.join(",")}${a}`); return { parts, v, a }; } /** + * The joins with the cut's edits merged in: a `muteFrom` (`mutes`: segment + * index → segment seconds) and the end fade on the LAST segment + * (`fade`: `{ seconds, lastFrame }`). A join gains `mute` / `fade` only when it + * has one, so a cut without either keeps exactly the joins (and the graph, + * and the hard-cut record) it had; null when nothing is joined at all. + */ +export function withCutEdits(joins, n, { mutes = new Map(), fade = null } = {}) { + if (!mutes.size && !fade) return joins; + const out = Array.from({ length: n }, (_, i) => joins?.[i] ?? null); + for (const [i, at] of mutes) out[i] = { hold: 0, move: null, ...(out[i] ?? {}), mute: at }; + if (fade) out[n - 1] = { hold: 0, move: null, ...(out[n - 1] ?? {}), fade }; + return out.some(Boolean) ? out : null; +} + +/** + * Every join the cut makes: the deck's holds and moves (`segmentJoins` of its + * schedule; none without the deck), each clip's `muteFrom` mapped to its + * segment's clock through the segment's cut record, and `render.endFade` on + * the last segment. Reads the records and probes what it needs; null when + * nothing is joined, so every concat then runs as it always did. + */ +export async function cutJoins({ schedule = null, entries, segments, render }) { + const base = schedule ? segmentJoins(schedule) : null; + const mutes = new Map(); + for (let i = 0; i < entries.length; i += 1) { + const e = entries[i]; + if (e.type !== "clip" || e.muteFrom == null) continue; + const m = muteSegmentSeconds({ + entry: e, record: await readCutRecord(segments[i]), render, + seconds: await probeDuration(segments[i], render.fps), + }); + if (m.note) EMIT("note", { id: e.id, message: m.note }); + EMIT("note", { id: e.id, message: `${e.id}: muted from ${m.at}s into its segment (muteFrom ${e.muteFrom}, ${m.source === "record" ? "from its cut record" : "from the unsnapped start"})` }); + mutes.set(i, m.at); + } + const seconds = endFadeOf(render); + let fade = null; + if (seconds > 0 && segments.length) { + const last = segments.length - 1; + const frames = Math.round((await probeDuration(segments[last], render.fps)) * render.fps) + + Math.round((base?.[last]?.hold ?? 0) * render.fps); + fade = { seconds, lastFrame: frames - 1 }; + } + return withCutEdits(base, segments.length, { mutes, fade }); +} + +/** * The segments' lengths IN THE CUT: probed, plus each one's hold -- the sum * `deckSchedule` makes (it is handed the same probed lengths and adds the same * holds), so the xfade offsets, the chapters and a preview's window agree with @@ -2716,8 +2852,14 @@ export const concatListText = (segments) => export function concatRecordText(segments, joins = null) { const list = concatListText(segments); if (!joins) return list; + // `mute` and `fade` only when a join has them: a record made before they + // existed, of a cut without them, still matches. const lines = segments.flatMap((s, i) => (joins[i] - ? [`# join ${i} ${JSON.stringify({ hold: joins[i].hold, move: joins[i].move })}`] + ? [`# join ${i} ${JSON.stringify({ + hold: joins[i].hold, move: joins[i].move, + ...(joins[i].mute != null ? { mute: joins[i].mute } : {}), + ...(joins[i].fade ? { fade: joins[i].fade } : {}), + })}`] : [])); return list + lines.join("\n") + "\n"; } @@ -2786,6 +2928,12 @@ export async function buildVideo({ manifestPath, opts = {}, out, only, fetchOnly // A `render.chrome` that cannot be built is refused here, before a single // fetch is spent. Absent, validateChrome has nothing to say. if (render.chrome !== undefined && render.chrome !== null) assertChrome(render.chrome, render); + // A clip's `muteFrom` and `render.endFade`, checked against the WHOLE + // manifest, deck or not: both are made where the cut is joined. + { + const errors = validateCutEdits(whole); + if (errors.length) throw new Error(`manifest: ${errors.join("; ")}`); + } const deck = deckOn(render); // Posts are drawn only under the deck, so only the deck refuses bad ones -- // against the WHOLE timeline, where an `attachTo` has to name a clip. @@ -2929,8 +3077,9 @@ export async function buildVideo({ manifestPath, opts = {}, out, only, fetchOnly } if (!(await exists(prerail))) { EMIT("concat", { mode: D === 0 ? "hardcut" : "xfade", n: segs.length }); - if (D === 0) await concatHardCut(segs, outDir, prerail); - else await concatWithXfade(segs, render, prerail, null); + const joins = await cutJoins({ entries, segments: segs, render }); + if (D === 0) await concatHardCut(segs, outDir, prerail, { joins, render }); + else await concatWithXfade(segs, render, prerail, null, null, joins); } const railPlan = await buildRailPlan(manifest, render, entries, segs, D, outDir); await assertConcatLength(prerail, railPlan.total, render.fps, @@ -2967,8 +3116,9 @@ export async function buildVideo({ manifestPath, opts = {}, out, only, fetchOnly } const schedule = await writeChromeSchedule({ manifest, entries, segments: segs, D, outDir }); EMIT("chrome", { phase: "schedule", total: schedule.total, segments: schedule.segments.length }); - // The holds and the moves, joined on their inputs (null without posts). - const joins = segmentJoins(schedule); + // The holds, the moves, the mutes and the end fade, joined on their + // inputs (null without any). + const joins = await cutJoins({ schedule, entries, segments: segs, render }); const prerail = prerailPath(outDir, manifest.slug, D); if (opts.chromePreview) { @@ -3092,9 +3242,9 @@ export async function buildVideo({ manifestPath, opts = {}, out, only, fetchOnly schedule = await writeChromeSchedule({ manifest, entries, segments, D, outDir }); EMIT("chrome", { phase: "schedule", total: schedule.total, segments: schedule.segments.length }); } - // The deck's holds and moves, joined on their inputs; null without posts - // (and always without the deck), which leaves every concat as it was. - const joins = deck ? segmentJoins(schedule) : null; + // The deck's holds and moves, each clip's muteFrom and the end fade, joined + // on their inputs; null without any, which leaves every concat as it was. + const joins = await cutJoins({ schedule: deck ? schedule : null, entries, segments, render }); const railPlan = opts.noRail ? null : await buildRailPlan(manifest, render, entries, segments, D, outDir); @@ -3140,7 +3290,7 @@ export async function buildVideo({ manifestPath, opts = {}, out, only, fetchOnly // has to be a second pass here whether we like it or not. const prerail = prerailPath(outDir, manifest.slug, D); if (railPlan) { - await concatHardCut(segments, outDir, prerail); + await concatHardCut(segments, outDir, prerail, { joins, render }); await assertConcatLength(prerail, railPlan.total, render.fps, "hard-cut concat"); await applyRail(prerail, final, render, railPlan, null); } else if (chromePlan) { diff --git a/umtool/report-to-video/chrome-deck.mjs b/umtool/report-to-video/chrome-deck.mjs @@ -66,6 +66,9 @@ const esc = (s) => const r4 = (v) => Math.round(v * 10000) / 10000; +/** The tracking of the QR's host label, in em: part of the length `fitHost` fits to the code. */ +export const HOST_TRACKING = 0.1; + /** The host a QR resolves to -- the one thing on the tile a viewer cannot read off the code. */ export function hostOf(url) { try { @@ -371,6 +374,9 @@ export function deckHtml(schedule, render, opts = {}) { window: windowed ? { from, dur } : null, titleSize: t.titleSize, floor: Math.ceil(t.titleSize * 0.6), + // The QR's host label is fitted to run the code's full height. + hostLength: qr?.size ?? 0, + hostTracking: HOST_TRACKING, ids: schedule.segments.map((s) => s.id), init, cues: cues.map(({ why, ...c }) => c), @@ -455,10 +461,13 @@ export function deckHtml(schedule, render, opts = {}) { height: 4px; border-radius: 2px; background: ${pal.accent}; } .deck-qr { position: absolute; left: ${qr?.x ?? 0}px; top: ${qr?.y ?? 0}px; width: ${qr?.size ?? 0}px; height: ${qr?.size ?? 0}px; transform-style: preserve-3d; } + /* The QR's host, reading up its left side. fitHost sizes it at load so + its ink runs the code's full height, bottom edge to top edge; 11px is + only what shows before the face is in. */ .deck-qr-host { position: absolute; left: ${(qr?.x ?? 0) - 30}px; top: ${qr?.y ?? 0}px; width: 18px; height: ${qr?.size ?? 0}px; writing-mode: vertical-rl; transform: rotate(180deg); - text-align: left; white-space: nowrap; font-size: 11px; line-height: 18px; - letter-spacing: 0.1em; text-transform: uppercase; color: ${rgba(pal.muted, 0.85)}; } + text-align: start; white-space: nowrap; font-size: 11px; line-height: 18px; + letter-spacing: ${HOST_TRACKING}em; text-transform: uppercase; color: ${rgba(pal.muted, 0.85)}; } .deck-qr img { display: block; width: ${qr?.size ?? 0}px; height: ${qr?.size ?? 0}px; border-radius: 6px; image-rendering: pixelated; backface-visibility: hidden; box-shadow: 0 0 0 1px ${rgba(pal.fg, 0.25)}, 0 6px 18px rgba(0, 0, 0, 0.35); } @@ -526,10 +535,36 @@ export function deckHtml(schedule, render, opts = {}) { } } const fitAll = () => document.querySelectorAll(".deck-title").forEach(fitTitle); + + // The QR's host runs up beside the code, and its INK is exactly as long + // as the code is tall, whatever the host. Every length in it scales with + // the font size (the tracking is in em), so one measurement in the + // loaded face solves it: the string's ink at a reference size, plus the + // tracking between its letters (not after the last), scaled to the + // code's height. The first letter's side bearing is indented away, so + // the ink starts on the code's bottom edge and ends on its top. + const hostCanvas = document.createElement("canvas").getContext("2d"); + function fitHost(node) { + // Measured as drawn: the CSS uppercases it. + const text = node.textContent.toUpperCase(); + if (!text || !(D.hostLength > 0)) return; + const ref = 100; + hostCanvas.font = ref + "px DeckSans"; + const m = hostCanvas.measureText(text); + const ink = m.actualBoundingBoxLeft + m.actualBoundingBoxRight + D.hostTracking * ref * (text.length - 1); + if (!(ink > 0)) return; + const k = D.hostLength / ink; + node.style.fontSize = ref * k + "px"; + node.style.textIndent = m.actualBoundingBoxLeft * k + "px"; + } const ready = Promise.all([ document.fonts.load(D.titleSize + "px DeckSansBold"), document.fonts.load("26px DeckSans"), - ]).catch(() => {}).then(() => { fitAll(); document.documentElement.dataset.fit = "1"; }); + ]).catch(() => {}).then(() => { + fitAll(); + document.querySelectorAll(".deck-qr-host").forEach(fitHost); + document.documentElement.dataset.fit = "1"; + }); const params = new URLSearchParams(location.search); // The review still: a seek and nothing else -- the same seek the renderer diff --git a/umtool/report-to-video/chrome-deck.test.mjs b/umtool/report-to-video/chrome-deck.test.mjs @@ -307,3 +307,40 @@ appendFileSync(${JSON.stringify(path.join(dir, "runs.log"))}, a.join(" ") + "\\n rmSync(dir, { recursive: true, force: true }); } }); + +const haveChromium = haveTools && spawnSync(process.env.CHROME ?? "/usr/bin/chromium", ["--version"], { stdio: "ignore" }).status === 0; + +test("the QR's host label: its ink runs the code's full height, top edge to bottom edge, for a long host and a short one", + { skip: !haveChromium }, async () => { + const { composeChrome } = await import("./compose-chrome.mjs"); + const dir = mkdtempSync(path.join(tmpdir(), "deck-host-")); + try { + const manifest = { + slug: "t", title: "t", provenance: {}, + render: { ...RENDER, fontRegular: path.join(HERE, "fonts", "IBMPlexMono-Regular.ttf"), fontBold: path.join(HERE, "fonts", "IBMPlexMono-Bold.ttf") }, + timeline: [], + }; + const manifestPath = path.join(dir, "video.manifest.json"); + writeFileSync(manifestPath, JSON.stringify(manifest)); + const qr = deckLayout(RENDER).qr; + // c01's code goes to jasolyzer.pages.dev, c03's to youtube.com. + for (const [t, host] of [[9, "jasolyzer.pages.dev"], [28, "youtube.com"]]) { + const png = path.join(dir, `still-${t}.png`); + await composeChrome({ manifestPath, region: "deck", schedule: schedule(), still: t, png }); + // The column beside the code, as 8-bit grey rows, against the plate's own shade a little left of it. + const cols = { x: qr.x - 40, w: 36 }; + const raw = spawnSync("magick", [png, "-crop", `${cols.w}x190+${cols.x}+0`, "+repage", "-colorspace", "gray", "-depth", "8", "gray:-"], { maxBuffer: 1 << 24 }).stdout; + const rows = []; + for (let y = 0; y < 190; y += 1) { + const ref = raw[y * cols.w]; + for (let x = 4; x < cols.w; x += 1) if (raw[y * cols.w + x] - ref > 25) { rows.push(y); break; } + } + assert.ok(rows.length, `${host}: no label found`); + const top = Math.min(...rows), bottom = Math.max(...rows); + assert.ok(Math.abs(top - qr.y) <= 1, `${host}: ink starts at ${top}, the code at ${qr.y}`); + assert.ok(Math.abs(bottom - (qr.y + qr.size - 1)) <= 1, `${host}: ink ends at ${bottom}, the code at ${qr.y + qr.size - 1}`); + } + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); diff --git a/umtool/report-to-video/cut-edits.test.mjs b/umtool/report-to-video/cut-edits.test.mjs @@ -0,0 +1,326 @@ +// 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, 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("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 crossfade graph the build always wrote. + assert.deepEqual(xfadeGraph([10, 12], 0.5, withCutEdits(null, 2), RENDER).parts, [ + "[0:v][1:v]xfade=transition=fade:duration=0.5:offset=9.500[v1]", + "[0:a][1:a]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("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 }); + } + }); diff --git a/umtool/report-to-video/deck.mjs b/umtool/report-to-video/deck.mjs @@ -114,6 +114,9 @@ export function validateChrome(chrome, render = {}) { if (render.chromeEngine !== undefined) { errors.push("render.chrome replaces render.chromeEngine — remove chromeEngine"); } + // The end fade is a render key the deck's cut is finished with; checked + // here too, so a writer that validates the deck refuses a bad one. + errors.push(...validateEndFade(render)); const d = chrome.deck ?? {}; if (!isObj(d)) return [...errors, "render.chrome.deck must be an object"]; const w = "render.chrome.deck"; @@ -391,16 +394,30 @@ export function scheduleFrom(durs, D) { */ export function estimatedDuration(entry, render = {}) { if (entry.type === "clip") { - const hasCut = Number.isFinite(entry.cutStart) && Number.isFinite(entry.cutEnd); - const lead = render.leadIn ?? 0.4; - const a = hasCut ? Math.max(entry.start, entry.cutStart - lead) : entry.start; - const b = hasCut ? Math.min(entry.end, entry.cutEnd) : entry.end; - return Math.max(1, b - a); + const { from, to } = playWindow(entry, render); + return Math.max(1, to - from); } if (entry.type === "image") return Number(entry.seconds ?? 4); return Number(entry.seconds ?? 5); } +/** + * The SOURCE seconds a clip asks to play, before silence snapping: the cut + * (`cutStart`/`cutEnd`) with the lead-in breath before it, clamped into the + * extent, when there is one; else the extent (`start`/`end`). The build + * snaps each end to a nearby silence, so the segment's true start is this + * `from` moved by up to `snapWindow` -- which is why a build records the real + * one beside the segment (`<id>.cut.json`). + */ +export function playWindow(entry, render = {}) { + const hasCut = Number.isFinite(entry.cutStart) && Number.isFinite(entry.cutEnd); + const lead = render.leadIn ?? 0.4; + return { + from: hasCut ? Math.max(entry.start, entry.cutStart - lead) : entry.start, + to: hasCut ? Math.min(entry.end, entry.cutEnd) : entry.end, + }; +} + /** The crossfade a build of this render block will use. */ export function transitionOf(render, { noXfade = false } = {}) { const t = render?.transition ?? 0.5; @@ -873,6 +890,88 @@ export function footageMoves({ posts, segments, render }) { } // --------------------------------------------------------------------------- +// Cut edits made where the cut is joined, like the hold: a clip's `muteFrom` +// and the cut's `render.endFade`. Neither touches a segment file, so +// `--chrome-only` changes either without rebuilding a clip. Pure here: the +// validators (umtool's writers and the build share them) and the arithmetic. +// --------------------------------------------------------------------------- + +/** The fade into a `muteFrom`'s silence, in seconds: long enough not to click, short enough to keep the next word out. */ +export const MUTE_FADE = 0.04; + +/** `render.endFade`'s upper bound, in seconds. */ +export const END_FADE_MAX = 10; + +/** + * Why one timeline entry's `muteFrom` cannot be built, as sentences. It is in + * SOURCE seconds, like `start`/`end`/`cutEnd`: a number within the clip's + * extent. Absent (or null) is fine. + */ +export function validateMuteFrom(entry, where = `timeline entry ${entry?.id ?? "?"}`) { + const v = entry?.muteFrom; + if (v === undefined || v === null) return []; + if (entry.type !== "clip") return [`${where}.muteFrom: only a clip has sound to mute`]; + if (typeof v !== "number" || !Number.isFinite(v)) return [`${where}.muteFrom must be a number of source seconds`]; + if (v < entry.start || v > entry.end) { + return [`${where}.muteFrom ${v} is outside the clip's ${entry.start}–${entry.end}`]; + } + return []; +} + +/** Why `render.endFade` cannot be built, as sentences: seconds from 0 (off) to END_FADE_MAX. */ +export function validateEndFade(render) { + const v = render?.endFade; + if (v === undefined || v === null) return []; + if (!numIn(v, 0, END_FADE_MAX)) return [`render.endFade must be from 0 to ${END_FADE_MAX} seconds`]; + return []; +} + +/** Every `muteFrom` in the timeline and `render.endFade`, checked: the build refuses with these before it fetches. */ +export function validateCutEdits(manifest) { + const errors = []; + (manifest?.timeline ?? []).forEach((e, i) => errors.push(...validateMuteFrom(e, `timeline[${i}] (${e?.id ?? "?"})`))); + errors.push(...validateEndFade(manifest?.render)); + return errors; +} + +/** The end fade a render block asks for, in seconds (0 = none). */ +export const endFadeOf = (render) => (numIn(render?.endFade, 0, END_FADE_MAX) ? render.endFade : 0); + +/** + * Where a clip's `muteFrom` falls in its SEGMENT's clock, from the source + * second the segment really starts at. + * + * The build cuts a segment from the snapped start, and records that start + * beside it (`<id>.cut.json`: `{ video, start, end }`, source seconds). A + * record is believed when it names this clip's video and is as long as the + * segment (`seconds`, its probed length) to within two frames; otherwise -- + * a segment built before records existed, or one copied without its record -- + * the start is the unsnapped `playWindow` start, and `note` says so: snapping + * may have moved the true start by up to `snapWindow` seconds. + * + * @returns {{ at: number, source: "record" | "window", note?: string }} + * `at` in segment seconds, never below 0 (a muteFrom before the segment's + * start mutes it from its first sample). + */ +export function muteSegmentSeconds({ entry, record = null, render = {}, seconds = null }) { + const fps = render.fps ?? 30; + const ok = record && record.video === entry.video && + Number.isFinite(record.start) && Number.isFinite(record.end) && + (seconds == null || Math.abs(record.end - record.start - seconds) <= 2 / fps); + const at = (from) => Math.max(0, Math.round((entry.muteFrom - from) * 1000) / 1000); + if (ok) return { at: at(record.start), source: "record" }; + const { from } = playWindow(entry, render); + return { + at: at(from), + source: "window", + note: + `${entry.id}: ${record ? "the cut record does not match the segment" : "no cut record beside the segment"} — ` + + `muteFrom measured from the unsnapped start ${from}; the real start may differ by up to ` + + `${render.snapWindow ?? 1.6}s. Rebuild the clip to record it.`, + }; +} + +// --------------------------------------------------------------------------- // The render cache and the renderer command. // ---------------------------------------------------------------------------