#!/usr/bin/env node // build-video.mjs — render a cited sweep report into a narrated-by-text video. // // Takes a video manifest (see README.md next to this file) and produces one mp4: // text cards state the findings, clips let the source say it in their own voice, // and every clip carries a burned-in quote plus its attribution. // // Pipeline, per manifest entry: // card -> still PNG (render-cards.mjs) -> N seconds of video + silent audio // clip -> yt-dlp --download-sections (WIDE) -> silence-snap -> trim + burn // then the segments are crossfaded together into the finished file. // // Three things worth knowing about how clips are cut: // // 1. Windows come from the manifest as absolute [start, end] seconds, derived // from transcript.cues.json (which carries an END per cue). A sweep report // only ever records a single start second, so windows cannot be recovered // from the report alone. // 2. Those windows are widened to sentence boundaries by resolve-windows.mjs, // so a clip carries the run-up that makes the quote make sense. // 3. A cue boundary is still not a *speech* boundary — cutting there clips // words in half. So we fetch wider than needed and snap the real cut to a // silence found in the audio. That is what makes clips start and end // between words rather than through them. // // Fetched clips are cached by (video, start, end); re-running is cheap and only // changed entries re-download. Delete out/clips-raw to force a refetch. // // Before any fetch, a clip's source is looked for ON DISK (sources.mjs): this // build's out/clips-raw, then the corpus's channels//data//clips/ // windows the editor fetched, then a saved whole source -- and, by // sources.mjs's audioUse rule, the recording's sound alone, which plays // under a poster. Only a miss in all of them goes to the network, and // --no-network refuses the build up front if any clip would. A clip with its // own `src` (a file beside the manifest, local-media.mjs) is its own source. // // In the app: not used. On the CLI: // node umtool/report-to-video/build-video.mjs [options] // // Options: // --out Output root (default: manifest dir + /out) // --variant Which cut to build (sourced | full; default sourced) // --skip-fetch Fail instead of downloading anything not already cached // --no-network Find every clip's source on disk first (sources.mjs: the // raw cache, the corpus's clip windows, a saved source) and // refuse the build, listing each clip, if any needs a fetch. // A clip that should have a picture and has only its sound // on disk is one that needs a fetch. // --audio-fallback With --no-network or --skip-fetch: play such a clip from // its sound under a poster instead (logged audio-fallback) // --only Build a single entry's segment and stop (for iterating) // --no-xfade Hard cuts instead of crossfades (much faster; concat copy) // --progress ndjson One JSON event per line instead of prose (for umtool) // --continue-on-error Record a failed entry and carry on, instead of aborting // --fetch-only Fetch one clip's window into clips-raw and stop // --pad Override render.fetchPad (the clip bench fetches wide) // --pad-before / --pad-after One side only; each defaults to --pad // --site-origin Archive to read cue windows from when there is no local // corpus (defaults to the manifest's provenance.siteOrigin) // --resolve-site-ids On a published-id miss, find the record by scanning the // channel's shards. Slow; see cues.mjs. // --cue-source auto (default) | local | http. The two can disagree // once a corpus moves past its last publish — see cues.mjs. // --no-rail Skip the claim rail even when the manifest configures one // --rail-only Re-run just the rail over out/.prerail.mp4 // --preview Rail-only, over a -second window starting at // --thumbnail A brand preset's thumbnail (manifest.thumbnail) and stop // // The deck (`render.chrome`, see plans/onscreen-deck.md): a full build writes // out//schedule.json, composes and renders the deck (compose-chrome, // cached by key) and overlays it in the concat -- one command. // --chrome-only Re-lay the deck over the segments already on disk: re-probe, // rewrite the schedule, recompose (re-render only when the key // changed), re-concat with the overlay, re-mux the chapters. // No segment is rebuilt, nothing is fetched. // --no-chrome The deck's framing without the overlay (a fast picture check) // --chrome-preview Render only that window of the deck and write // out//.preview.mp4 of it, from the cached // concat when there is one, else from the segments it touches // // Requires: yt-dlp, ffmpeg/ffprobe, ImageMagick with Pango. import { execFile } from "node:child_process"; import { createHash } from "node:crypto"; import { promisify } from "node:util"; import { mkdir, writeFile, readFile, access, readdir, rename, rm, stat } from "node:fs/promises"; import path from "node:path"; import { renderCard, renderFooterAssets, renderRailAssets, renderScrollCard, renderChartCard, renderLedgerCard, ledgerRevealAt, ledgerSeconds, cardWidth, contentWidth, reservedFooterHeight, } from "./render-cards.mjs"; import { DEFAULT_CHANNELS_DIR, createCueSource, siteOriginFromManifest } from "./cues.mjs"; import { cutArgs } from "yt-dlp-transcript-common/lib/evidenceClip.mjs"; // Where a clip's media is ALREADY on disk -- the build's raw cache, the // editor's corpus windows, the saved source -- asked before anything fetches. import { SHADOW_CHANNELS, WIN_EPS as SRC_EPS, audioUse, channelsDirFor, findContainingWindow, rawWindowName, resolveLocalSource, } from "./sources.mjs"; // A clip whose media is a file beside the manifest (`src`, `cues`). import { clipLabel, createLocalMedia, hasLocalMedia, validateLocalMedia } from "./local-media.mjs"; import { createPostChannelResolver, resolvePostLinks } from "./post-links.mjs"; import { ensureWriteDir } from "../lib/report/storage.mjs"; // The deck (`render.chrome`): its geometry, validation and schedule are pure // 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, endFadeOf, feedGeometry, feedOn, frameCount, hidesDeck, MUTE_FADE, dipOf, muteSegmentSeconds, playWindow, postsGeometry, postWindows, resolveDeck, scheduleFrom, snapWindow, stampGeometry, teaserHits, teaserSeconds, teaserTitle, threadsGeometry, validateCutEdits, validatePosts, validateTeasers, } from "./deck.mjs"; import { validateClaims } from "./factcheck.mjs"; import { validateThreadEntries } from "./threads.mjs"; import { validateFlipEntries } from "./flips.mjs"; // The per-platform yt-dlp args (Rumble's `--impersonate chrome`): the ONE table, // in common, plain JS so bare `node` can load it. import { platformArgsForUrl } from "yt-dlp-transcript-common/ytdlp/platformArgs.mjs"; // The header line, the clock in it and the title cleaner live in one module // the clip bench imports too -- a preview that shows a line this renderer // would never draw is worse than no preview. import { attributionLine, attributionParts, channelName, hms, imageAttributionLine, uploadDateToIso, } from "./attribution.mjs"; // An opt-in brand preset (`render.brand`). Every hook below branches on the // brand BEFORE building an argument, so a manifest without one renders exactly // as it did -- see brand.mjs. import { brandHeaderGeometry, brandManifest } from "./brand.mjs"; import { markPng, renderThumbnail, THUMB_MAX_BYTES } from "./brand-cards.mjs"; const execFileP = promisify(execFile); const YTDLP = process.env.YTDLP_BIN ?? "yt-dlp"; const FFMPEG = process.env.FFMPEG_BIN ?? "ffmpeg"; const FFPROBE = process.env.FFPROBE_BIN ?? "ffprobe"; const QRENCODE = process.env.QRENCODE_BIN ?? "qrencode"; // The binaries, under the names the rest of umtool calls them by. A second // module reading FFMPEG_BIN for itself would be a second place a fixture's // stub has to be wired in, and the one that forgot would shell out to the real // ffmpeg in the middle of a test. export { FFMPEG as FFMPEG_BIN, FFPROBE as FFPROBE_BIN }; // Cue windows and per-video metadata come from a local corpus when there is one // and from the published archive otherwise, so this runs in a clone with no // `transcripts/` directory. Built once main() has the manifest (it carries the // archive origin); see cues.mjs. let CUES = null; // The manifest's own media (`src` clips): set by buildVideo() beside CUES. let LOCAL = null; const exists = (p) => access(p).then(() => true, () => false); // ---- variants ------------------------------------------------------------ // ONE manifest, two cuts, one filter, applied once. // // The question the two variants answer differently is what to do with a claim // the sweep found but no clip covers. `sourced` refuses to put it on screen at // all -- every row the viewer sees has footage behind it. `full` gives each one // a slot on a stacked ledger card, so nothing is dropped and the arithmetic of // each layer is shown rather than asserted. // // Both end on the same three numbers. That is the point of shipping both: if // the totals moved when the unsourced rows came off, the thesis would rest on // rows nobody can check. // // The filter runs IMMEDIATELY after the manifest is read, and nothing // downstream learns about variants. `ledgerTotals`, the rail, the chart band, // `scheduleClaims`, the chapters and the scroll already take the ledger and the // timeline as inputs, so selecting is the whole of the mechanism. export const VARIANTS = ["sourced", "full"]; /** The cut a caller means when it does not say. `out/.mp4`. */ export const DEFAULT_VARIANT = "sourced"; /** * The manifest as one variant sees it. * * Three things happen, in this order: * * 1. A timeline entry tagged `variant` survives only in that variant. (The * stacked ledger cards are `variant: "full"`.) * 2. A claim survives only if the entry it is pinned to survived. That single * rule is what makes `sourced` a sourced-only ledger: in `full` every claim * is pinned -- to a clip or to a ledger card -- so nothing is dropped. * 3. `card.variants[]` field overrides are merged in. The title and * sources cards have to state their own scope honestly, and "50 dated * claims" is simply false in `sourced`. */ export function selectVariant(manifest, variant = DEFAULT_VARIANT) { if (!VARIANTS.includes(variant)) { throw new Error(`unknown variant \`${variant}\` — one of ${VARIANTS.join(", ")}`); } const timeline = (manifest.timeline ?? []) .filter((e) => !e.variant || e.variant === variant) .map((e) => { if (!e.variants) return e; const { variants, ...rest } = e; return { ...rest, ...(variants[variant] ?? {}) }; }); const kept = new Set(timeline.map((e) => e.id)); const ledger = (manifest.ledger ?? []).filter((c) => c.entryId && kept.has(c.entryId)); // A post may be in one cut only, as an entry may. const posts = Array.isArray(manifest.posts) ? manifest.posts.filter((p) => !p?.variant || p.variant === variant) : manifest.posts; // A brand preset resolves here, at the one door every reader of a cut goes // through, so the build, verify-build, compose-chrome and umtool's export // all see the same render block and the same appended end card. No brand: // the object as built above. return brandManifest({ ...manifest, variant, timeline, ledger, ...(posts !== undefined ? { posts } : {}) }); } /** * The citation header's filters, for a clip or a still. * * Unbranded: the accent tick and one drawtext line in `render.fontRegular`, * exactly as they always were. Branded: no tick -- the mark leads the line, as * an overlay the caller adds (`brandHeaderGeometry` says where) -- and the line * in the preset's mono face; `channelPath`, when given, is drawn over the * line's head in the foreground colour, the same glyphs at the same origin, so * the channel reads brighter than the rest without measuring any text. */ export function headerFilters(render, attribPath, channelPath = null) { const pal = render.palette; const HH = render.headerHeight ?? 56; if (!render.brand) { return [ `drawbox=x=90:y=${Math.round((HH - 24) / 2)}:w=4:h=24:color=${pal.accent}:t=fill`, [ `drawtext=textfile='${attribPath}'`, `fontfile='${render.fontRegular}'`, "fontsize=22", `fontcolor=${pal.muted}`, "x=118", `y=${Math.round((HH - 26) / 2)}`, ].join(":"), ]; } const g = brandHeaderGeometry(render); const text = (file, color) => [ `drawtext=textfile='${file}'`, `fontfile='${render.fontRegular}'`, `fontsize=${g.fontSize}`, `fontcolor=${color}`, `x=${g.textX}`, `y=${g.textY}`, ].join(":"); return [text(attribPath, pal.muted), ...(channelPath ? [text(channelPath, pal.fg)] : [])]; } /** * The deck's framing, as two runs of filters. * * `fit` scales a picture into the footage box (`deckGeometry().footage`), * keeping its aspect, and pads it to the box in the palette background. `place` * pads the box out to the whole frame at the box's own origin, so the bottom * `deck.height` rows -- where the composition is overlaid -- are plain ground, * then ends the way every segment ends: `setsar=1` and the cut's fps. xfade * refuses a link whose parameters differ from its neighbour's, and that would * surface only at concat time, after every fetch has been paid for. * * Two runs, not one string, because a still lays itself out INTO the box (a * row of panels, a crawl) and only needs the second half. * * Pure, and exported, so the numbers can be tested without an encoder. */ export function deckFraming(render, { feed = false } = {}) { const { W, H, footage: deckBox } = deckGeometry(render); // The posts feed's footage box stands beside its column (feedGeometry); // every footage segment of a feed cut is framed there, for the whole cut. const f = feed ? feedGeometry(render).footage : deckBox; const bg = render.palette.bg; return { box: f, fit: [ `scale=${f.width}:${f.height}:force_original_aspect_ratio=decrease`, `pad=${f.width}:${f.height}:(ow-iw)/2:(oh-ih)/2:color=${bg}`, ], place: [`pad=${W}:${H}:${f.x}:${f.y}:color=${bg}`, "setsar=1", `fps=${render.fps}`], }; } /** `deckFraming`'s two runs joined: a whole picture into the box, then the frame. */ export function deckFramingFilter(render, opts = {}) { const { fit, place } = deckFraming(render, opts); return [...fit, ...place].join(","); } /** * The framing a cut's footage segments are built with under the deck -- the * layout and its box -- recorded in each one's `.cut.json` (`framing`) so * that `--chrome-only`, which rebuilds no segment, can refuse segments framed * for another layout. `feed` is feedOn's answer for the cut's WHOLE timeline. */ export function segmentFraming(render, feed = false) { return { layout: feed ? "feed" : "deck", box: deckFraming(render, { feed }).box }; } /** Is this entry's segment framed into the footage box under the deck (rather than full frame)? */ export function framedUnderDeck(entry, render) { if (entry.type === "clip" || entry.type === "image") return true; return entry.type === "card" && !hidesDeck(entry, resolveDeck(render)); } /** A framing layout as a refusal names it. */ const layoutName = (layout) => (layout === "feed" ? "posts feed" : "deck"); /** * Why segments on disk cannot carry this cut's chrome, as sentences: each * framed segment's record names the box it was framed into, and it is not the * one this cut frames footage into (`want`, segmentFraming's). A segment with * no framing record was built before records named it -- framed for the * deck's box -- so it passes for the deck and fails for the feed. * * @param {{ entries: object[], records: Array, render: object, want: { layout: string, box: object } }} args * @returns {string[]} */ export function framingProblems({ entries, records, render, want }) { const deckBox = deckGeometry(render).footage; const same = (a, b) => a && b && a.x === b.x && a.y === b.y && a.width === b.width && a.height === b.height; const where = (b) => `${b.width}×${b.height} at (${b.x}, ${b.y})`; const wrong = []; entries.forEach((e, i) => { if (!framedUnderDeck(e, render)) return; const got = records[i]?.framing ?? null; const box = got?.box ?? deckBox; if (same(box, want.box)) return; const why = got ? `framed for the ${got.layout}, ${where(box)}` : `no framing record: built for the deck's ${where(box)}`; wrong.push(`${e.id} (${why})`); }); if (!wrong.length) return []; return [ `${wrong.length} segment(s) are framed for another layout than this cut's ` + `${layoutName(want.layout)} (footage ${where(want.box)}): ${wrong.join(", ")}. ` + "Reframing rebuilds them -- run a normal build (with --skip-fetch it re-cuts from the cached windows), not --chrome-only.", ]; } /** * Where a variant's own working files live. * * `clips-raw` stays at the ROOT and is shared: it holds the only expensive * thing in the build (network fetches), and `sourced`'s clips are a subset of * `full`'s, so a shared cache means no clip is ever fetched twice. Everything * else is per-variant, because every one of them differs between the two cuts. * * `sourced` writes `out/.mp4` -- the path umtool's build probe already * looks for -- and `full` writes `out/-full.mp4` beside it. */ export function variantPaths(outRoot, slug, variant) { return { root: outRoot, dir: path.join(outRoot, variant), rawDir: path.join(outRoot, "clips-raw"), final: path.join(outRoot, variant === "sourced" ? `${slug}.mp4` : `${slug}-${variant}.mp4`), }; } // ---- progress protocol --------------------------------------------------- // This has two audiences: a human watching a terminal, and umtool's build driver // reading the pipe. Rather than have the driver scrape prose (which would make // every wording change a breaking change), `--progress ndjson` switches every // line to one JSON object. The event set is exactly what was already being // printed -- this is a formatting switch, not new instrumentation. // // Events: start, card, clip, fetch, snap, segment, entry-failed, concat, // chapters, chrome, note, done. const HUMAN = { start: (e) => `${e.title} — ${e.entries} entr(ies)`, card: (e) => `card ${e.id}`, clip: (e) => `clip ${e.id} (${e.video}) §${e.section}${e.sectionEnter ? " ⟶" : ""}`, // One line per clip, naming where its source came from (`source`: a // sources.mjs kind, or "network"). fetch: (e) => e.cached ? ` source ${e.id}: ${e.source ?? "raw-cache"} ${e.reuse ?? "(exact window)"} covers ` + `${hms(e.from)}–${hms(e.to)} — no download` + (e.height ? ` (${e.height}p)` : "") + (e.source === "audio" ? " — audio only, under a poster" : "") + (e.source === "audio-fallback" ? " — AUDIO-FALLBACK: the picture is not on disk; a poster plays" : "") : ` fetch ${e.id}: ${e.video} ${hms(e.from)}–${hms(e.to)} (network)`, snap: (e) => ` snap ${e.id}: ${e.start ? "start✓" : "start–"} ${e.end ? "end✓" : "end–"} ` + `(${Number(e.seconds).toFixed(1)}s)`, segment: () => null, "entry-failed": (e) => ` ** ${e.id} failed: ${e.message}`, concat: (e) => `${e.mode === "xfade" ? "crossfading" : "hard-cutting"} ${e.n} segments…`, chapters: (e) => `chapters: ${e.n} marker(s) -> ${e.file}`, // The deck's steps. `phase` is schedule | compose | render | cached | overlay. chrome: (e) => `chrome ${e.phase}` + (e.region ? ` ${e.region}${e.segment ? ` ${e.segment}` : ""}` : "") + (e.segments !== undefined ? `: ${e.segments} segment(s)` : "") + (e.total !== undefined ? `, ${Number(e.total).toFixed(3)}s` : "") + (e.duration !== undefined ? ` window ${e.from}s +${e.duration}s` : "") + (e.frames !== undefined ? `: ${e.frames} frame(s)` : "") + (e.seconds !== undefined ? ` in ${e.seconds}s` : "") + (e.key ? ` (key ${String(e.key).slice(0, 12)})` : "") + (e.base ? ` over ${e.base}` : ""), note: (e) => e.message, done: (e) => // A run that produced no file still emits `done` -- a consumer of the // ndjson needs its terminator either way -- but "built null" is not a // sentence, and the note before it already said what happened. e.out == null ? null : e.duration === undefined ? `built ${e.out}` : `\n${e.out}\nduration=${e.duration}\nsize=${e.size}`, }; let EMIT = (ev, fields = {}) => { const line = HUMAN[ev]?.({ ev, ...fields }); if (line) console.log(line); }; export function setProgressMode(mode) { EMIT = mode === "ndjson" ? (ev, fields = {}) => process.stdout.write(JSON.stringify({ ev, ...fields }) + "\n") : (ev, fields = {}) => { const line = HUMAN[ev]?.({ ev, ...fields }); if (line) console.log(line); }; } // drawtext does not wrap. Break to a character budget, write to a file, and use // textfile= so nothing needs shell or filter escaping. function wrap(text, cols) { const words = text.split(/\s+/); const lines = []; let line = ""; for (const w of words) { if (line && (line + " " + w).length > cols) { lines.push(line); line = w; } else { line = line ? line + " " + w : w; } } if (line) lines.push(line); return lines.join("\n"); } // The published shard record carries the same fields as a local cue file, so this // reads identically whichever source answered. // // Exported for the deck's schedule writer and for umtool; it reads through the // cue source buildVideo() builds, so it answers only inside a build. export async function videoMeta(videoId, channelSlug, hints = {}) { if (!CUES) throw new Error("videoMeta: no cue source — it is set up by buildVideo()"); const d = await CUES.load(channelSlug, videoId, hints); // `channel` is the uploader's DISPLAY name, and it heads the attribution // line. It costs nothing to carry: both sources -- a local // transcript.cues.json and a published shard record -- already hold it, and // this record is loaded for the title and the upload date anyway. return { title: d.title, uploadDate: d.uploadDate, webpageUrl: d.webpageUrl, duration: d.duration, channel: d.channel, platform: d.platform ?? null, }; } /** * A clip's metadata, wherever it lives: a `src` clip's from its own cue file * (local-media.mjs), every other clip's from its corpus record. Every reader * of a clip's title, date and channel asks this, so a `src` clip is named the * same in its header, its chapter and the deck. */ export async function clipMeta(entry, provenance = {}) { if (hasLocalMedia(entry)) { if (!LOCAL) throw new Error("clipMeta: no local media reader — it is set up by buildVideo()"); return LOCAL.meta(entry); } return videoMeta(entry.video, entry.channel ?? provenance?.channelSlug, { siteChannel: entry.siteChannel, siteVideo: entry.siteVideo, }); } // The CONTAINER's duration is max(video, audio), and the audio is longer: the // AAC encoder pads the front with ~21 ms of decoder delay, and a video duration // is rarely an exact multiple of the frame interval. Either way the excess is // small — and it ACCUMULATES through segmentOffsets, which subtracts one // transition per segment and hands the result to xfade, the chapter marks and // (now) the rail. A few hundred ms of drift by segment 20 is enough to land a // rail row-change on the wrong side of a cut. // // The video stream's frame COUNT is the number the timeline actually runs on, // so derive the duration from it. nb_frames is absent on some demuxers; fall // back to the container rather than failing a build over a probe. export async function probeDuration(file, fps) { if (fps) { const { stdout } = await execFileP(FFPROBE, [ "-v", "error", "-select_streams", "v:0", "-show_entries", "stream=nb_frames", "-of", "default=nw=1:nk=1", file, ]); const n = Number(stdout.trim()); if (Number.isFinite(n) && n > 0) return n / fps; } const { stdout } = await execFileP(FFPROBE, [ "-v", "error", "-show_entries", "format=duration", "-of", "default=nw=1:nk=1", file, ]); return Number(stdout.trim()); } /** * What a clip's source file holds: its duration, and whether it has a PICTURE * -- a video stream that is not a cover image (an mp3's attached picture is * one frame of album art, not footage). * * @returns {Promise<{ hasVideo: boolean, duration: number|null }>} */ export async function probeMedia(file) { const { stdout } = await execFileP(FFPROBE, [ "-v", "error", "-show_entries", "stream=codec_type:stream_disposition=attached_pic:format=duration", "-of", "json", file, ]); const doc = JSON.parse(stdout); const hasVideo = (doc.streams ?? []).some( (st) => st.codec_type === "video" && !Number(st.disposition?.attached_pic), ); const duration = Number(doc.format?.duration); return { hasVideo, duration: Number.isFinite(duration) && duration > 0 ? duration : null }; } // ---- the audio poster ------------------------------------------------------ // A clip whose source has no picture -- a podcast episode, a channel that kept // only its sound (sources.mjs's audio tier), a `src` that is an mp3 -- plays // under a POSTER where the footage would be: the clip's own card (channel, // title, date: attributionParts, the header's resolver) drawn by renderCard at // exactly the picture box's size, with the sound's waveform moving along its // foot (`render.audioPoster.waveform`, default on). // // It stands in for `[0:v]` and nothing else: every filter after it -- the // letterbox, the header, the footer, the deck's framing -- runs on it as on // footage, and the encode is the same, so the segment's size, fps, pixel // format, SAR and audio layout are every other segment's and the concat // cannot tell it from one. /** Where the waveform sits in a WxH poster: along the foot, inside the card's margins. */ export function posterWaveGeometry(W, H) { const even = (n) => Math.max(2, 2 * Math.round(n / 2)); const x = Math.round(W * 0.09); const width = even(W - 2 * x); const height = even(H * 0.16); return { x, y: H - height - Math.round(H * 0.07), width, height }; } /** * The filter parts that make the poster's picture, labelled `[pic]`: the * still, then the waveform of input 0's sound over it. * * @param {{ posterIdx: number, box: { width: number, height: number }, render: any }} args */ export function audioPosterParts({ posterIdx, box, render }) { if (render.audioPoster?.waveform === false) return [`[${posterIdx}:v]null[pic]`]; const g = posterWaveGeometry(box.width, box.height); return [ `[0:a]aformat=channel_layouts=mono,` + `showwaves=s=${g.width}x${g.height}:mode=cline:draw=full:rate=${render.fps}:colors=${render.palette.accent}[wave]`, // `eof_action=pass`: the still runs on bare if the waveform ends a frame // early. The default (repeat) hands the cut's `fps` a frame stamped far in // the future, and it fills the gap -- an hour of poster behind 2 s of sound. `[${posterIdx}:v][wave]overlay=x=${g.x}:y=${g.y}:eof_action=pass[pic]`, ]; } /** * Render the poster still and return its ffmpeg input: a `seconds`-long loop * at the cut's fps, so the picture ends where the sound does. */ async function audioPosterInput({ entry, meta, provenance, render, outDir, box, seconds }) { const parts = attributionParts(entry, meta ?? {}, provenance ?? {}); const card = { id: `${entry.id}.poster`, style: "chapter", ...(parts.channel ? { kicker: parts.channel } : {}), heading: parts.title || clipLabel(entry), ...(parts.date ? { sub: parts.date } : {}), }; // At the box's size, and with no rail: the card IS the picture box. const png = await renderCard(card, { ...render, width: box.width, height: box.height, rail: undefined }, outDir, null); return ["-loop", "1", "-framerate", String(render.fps), "-t", seconds.toFixed(3), "-i", png]; } // yt-dlp exits 101 on a clean early stop (break-on-existing / max-downloads). // The repo treats that as success everywhere else; do the same here. const ytdlpOk = (err) => err?.code === 101; // ---- the clip cache ------------------------------------------------------ // A raw clip's window is IN ITS NAME, which makes the file immutable and the // cache content-addressed. The original lookup was for the exact name, so any // change to a window -- a hand edit, a widen, a nudge in the clip bench -- was a // fresh download of material already on disk. Measured on ferret-rescue: 31 // files for 10 clips, one source fetched four times over overlapping windows. // // So: satisfy a request from ANY cached file that contains it. The TIGHTEST // container wins, because detectSilence decodes the whole file and a 40s file // costs more than the 14s one that would also have done. The clip bench fetches // deliberately wide, and this is what makes that generous fetch become the // build's cache rather than a second one. // The cache's naming and the containment predicate live in sources.mjs now -- // beside the corpus tiers the build also reads before it fetches -- and are // re-exported here under the names umtool has always imported them by. The // question is asked FOUR times per page (the build, the bench, the project // page's pill and the walk), so it is one predicate in one place. export { WIN_EPS, cachedWindowsFor, findContainingWindow, listRawNames, rawWindowName, tightestContaining, windowContains, windowsFromNames, } from "./sources.mjs"; // The yt-dlp argv for one clip window. The platform's fixed args come from // common's table (`platformArgsForUrl`) -- the same copy every editor spawn // uses, so a Rumble fetch carries `--impersonate chrome` here too instead of // 403ing at Cloudflare. They go BEFORE `extra`, so a retry's own flags win. export function clipFetchArgs({ url, from, to, fmt, dest, extra = [] }) { return [ // The operator's own yt-dlp config redirects output and attaches thumbnail // and metadata post-processors; without this the clips land elsewhere. "--ignore-config", "--no-playlist", "--download-sections", `*${from.toFixed(2)}-${to.toFixed(2)}`, // Without this the cut snaps to the nearest preceding keyframe, which can be // seconds early — fine for scrubbing, not fine when the clip IS the citation. "--force-keyframes-at-cuts", ...platformArgsForUrl(url), ...extra, // Pin H.264/AAC in mp4. Left alone yt-dlp picks VP9+Opus at these heights, // and since --force-keyframes-at-cuts re-encodes, that means libvpx-vp9 — // 27s to cut a 5s clip. It also writes .webm and appends that to -o. "-f", fmt, "--merge-output-format", "mp4", "-o", dest, "--", url, ]; } /** * WHERE A CLIP IS CUT OUT OF A CACHED WINDOW, as ffmpeg input arguments. * * `-ss`/`-to` BEFORE `-i`, and both matter. Before the input, ffmpeg seeks the * demuxer rather than decoding and discarding, which is the difference between * seconds and minutes on a 45-minute window; `-to` before the input is then * measured on the same clock as the `-ss`, i.e. in the SOURCE FILE's seconds, * which is what an offset into a cached window is. * * Exported because two things cut a clip out of a window now -- the render's * segment pass and the bench's "cut confirmed clips from cache" -- and a second * spelling of this is a second set of seconds that can drift from the first. * * @param {string} raw the cached window file * @param {number} a seconds INTO that file where the clip starts * @param {number} b seconds into it where the clip ends */ // Common's (lib/evidenceClip.mjs) since a report site cuts its evidence clips // with the same arguments: re-exported, never re-spelled. export { cutArgs }; /** * The span a clip's source is needed over: its extent, widened by the fetch * pad on each side. * * Deliberately over-fetched: the snapping pass needs room on both sides to * find a silence, and a clip that has no slack can only be cut where the cue * happened to break -- which is what put words in half in the first place. * ONE EDGE AT A TIME. Extending a window is nearly always one-sided -- you * want the sentence that follows, not another twenty seconds of the lead-in * you already heard -- and a symmetric pad makes every "20 more" fetch twice * what was asked for. `--pad` stays the shorthand that sets both. */ export function fetchSpan(entry, render, opts = {}) { const pad = opts.pad ?? render.fetchPad ?? 3.0; const padBefore = opts.padBefore ?? pad; const padAfter = opts.padAfter ?? pad; return { from: Math.max(0, entry.start - padBefore), to: entry.end + padAfter }; } /** * Where this build looks for media already on disk: the raw cache, the * channels tree and the clip's channel. One per build; `resolve` is memoised, * so `--no-network`'s up-front pass and the clip's own fetch probe a saved * container once between them. */ export function localSources({ rawDir, channelsDir, channelSlug = null, probe } = {}) { const memo = new Map(); const slugOf = (entry) => entry.channel ?? channelSlug ?? null; return { rawDir, channelsDir, slugOf, // `audio` admits the audio tier: sources.mjs `audioUse` decides it. async resolve(entry, span, { exact = false, audio = false } = {}) { const slug = slugOf(entry); const key = `${slug}/${entry.video}/${span.from}/${span.to}/${exact}/${audio}`; if (memo.has(key)) return memo.get(key); const hit = await resolveLocalSource( { video: entry.video, slug, from: span.from, to: span.to }, { rawDir, channelsDir, exact, probe, audio }, ); // Hits only: a miss is about to be fetched into the raw cache, and the // next clip asking for the same span must find that file. if (hit) memo.set(key, hit); return hit; }, }; } /** May this build reach the network at all? */ const networkOn = (opts) => !opts.noNetwork && !opts.skipFetch; /** * Where each clip of `entries` would come from without the network: `missing` * are the ones no local source serves -- `--no-network` refuses the build on * any of them before a frame is rendered -- `audio` the ones with no picture * to fetch that their sound serves, and `audioFallback` the ones that SHOULD * have a picture and play from their sound only because `--audio-fallback` * said so. Both of the last two play under a poster. `index` is the entry's * position in the manifest's timeline. A `src` clip is its own source and is * in none of them. * * `metaOf(entry)` is the clip's record, or null (the platform and page decide * audio-only; without it, only the entry and render can). */ export async function planLocalSources(entries, timeline, render, opts, local, metaOf = null) { const missing = []; const audio = []; const audioFallback = []; for (const entry of entries) { if (!isClipEntry(entry) || hasLocalMedia(entry)) continue; const span = fetchSpan(entry, render, opts); const exact = !!opts.noReuse; const ask = (meta) => audioUse({ entry, meta, render, network: networkOn(opts), fallback: !!opts.audioFallback }); let use = ask(null); let hit = await local.resolve(entry, span, { exact, audio: use !== null }); const row = { // By id: a variant's view may be a copy of the manifest's entry. index: timeline.findIndex((e) => e === entry || (entry.id != null && e?.id === entry.id)), id: entry.id, slug: local.slugOf(entry), video: entry.video, from: span.from, to: span.to, }; // Only the sound is here, and nothing has called the clip audio-only yet: // its record may (a feed, or no page to fetch from). Read only for such a // clip -- a lookup per clip would be a lookup per clip for nothing. if (metaOf && use !== "audio-only" && (!hit || hit.kind === "audio")) { const sound = hit ?? (await local.resolve(entry, span, { exact, audio: true })); if (sound?.kind === "audio") { if (ask(await metaOf(entry)) === "audio-only") { use = "audio-only"; hit = sound; } else if (!hit && !networkOn(opts)) { // Its picture is not here but its sound is: --audio-fallback would play it. row.soundOnDisk = sound.name; } } } else if (!metaOf && !hit && !networkOn(opts)) { const sound = await local.resolve(entry, span, { exact, audio: true }); if (sound?.kind === "audio") row.soundOnDisk = sound.name; } if (!hit) { missing.push(row); } else if (hit.kind === "audio") { (use === "audio-fallback" ? audioFallback : audio).push({ ...row, local: hit.name }); } } return { missing, audio, audioFallback }; } /** planLocalSources' `missing`: the clips that would need a fetch. */ export async function clipsNeedingFetch(entries, timeline, render, opts, local) { return (await planLocalSources(entries, timeline, render, opts, local)).missing; } /** One line per clip that plays from its sound alone, for the log. */ export function audioOnlyMessage(audio, { fallback = false } = {}) { return ( (fallback ? `--audio-fallback: ${audio.length} clip(s) whose picture is not on disk play from their sound (audio-fallback, a poster):\n` : `${audio.length} clip(s) play from audio only (a poster where the picture would be):\n`) + audio .map((m) => ` timeline[${m.index}] ${m.id} ${m.slug ?? "(no channel)"}/${m.video} ${m.local}`) .join("\n") ); } /** One line per clip the network would have to serve, for the refusal. */ export function needsFetchMessage(missing) { return ( `--no-network: ${missing.length} clip(s) have no local source and would need a fetch:\n` + missing .map((m) => ` timeline[${m.index}] ${m.id} ${m.slug ?? "(no channel)"}/${m.video} ` + `${m.from.toFixed(2)}–${m.to.toFixed(2)}` + (m.soundOnDisk ? ` (only its sound, ${m.soundOnDisk}, is on disk: --audio-fallback plays it under a poster)` : "")) .join("\n") ); } // The timeline's vocabulary is OPEN; the build loop treats everything it has // no other branch for as a clip, and so does this. const NON_CLIP_TYPES = new Set(["card", "teaser", "image", "scroll", "chart", "ledger"]); const isClipEntry = (e) => !NON_CLIP_TYPES.has(e?.type); async function fetchClip(entry, meta, render, local, opts) { const { rawDir } = local; const { from, to } = fetchSpan(entry, render, opts); // A `src` clip IS its source: the whole file, [0, duration], beside the // manifest. Nothing is looked up and nothing is fetched. if (hasLocalMedia(entry)) { const file = LOCAL.file(entry); const duration = entry.srcDuration ?? (await probeMedia(file)).duration; EMIT("fetch", { id: entry.id, video: clipLabel(entry), from, to: Number.isFinite(duration) ? Math.min(to, duration) : to, cached: true, source: "src", reuse: path.basename(file), local: file, window: [0, duration], }); return { path: file, fetchStart: 0, cached: true, source: "src", scan: { from: Math.max(0, from), to: Number.isFinite(duration) ? Math.min(to, duration) : to }, }; } // Shared across variants, and deliberately so: this is the only expensive // thing in a build, and the two cuts overlap almost entirely. const name = rawWindowName(entry.video, from, to); const dest = path.join(rawDir, name); // ON DISK FIRST. The raw cache, then the editor's corpus windows, then a // saved whole source (sources.mjs). `--no-reuse` takes only the raw file // named for exactly this span. fetchStart is the SOURCE's start, not the // requested one -- every cut downstream is relative to it, so where the // bytes came from is transparent. // The audio tier only by audioUse's rule: before the network for a clip // with no picture to fetch; for one that should have a picture, only when // there is no network to ask AND --audio-fallback said so. const use = audioUse({ entry, meta, render, network: networkOn(opts), fallback: !!opts.audioFallback }); const hit = await local.resolve(entry, { from, to }, { exact: !!opts.noReuse, audio: use !== null }); // What the log calls it: a fallback is not the same fact as an audio-only clip. const kind = hit?.kind === "audio" && use === "audio-fallback" ? "audio-fallback" : hit?.kind; if (hit) { EMIT("fetch", { id: entry.id, video: entry.video, from, to, cached: true, source: kind, ...(hit.kind !== "raw-cache" || hit.name !== name ? { reuse: hit.name } : {}), local: hit.path, window: [hit.windowStart, hit.windowEnd], ...(hit.height ? { height: hit.height } : {}), }); return { path: hit.path, fetchStart: hit.windowStart, cached: true, source: kind, // A whole container is hours long and silence detection decodes what it // is given: give it the span a fetch would have produced, no more. ...(hit.kind === "raw-cache" ? {} : { scan: { from: from - hit.windowStart, to: to - hit.windowStart } }), }; } if (opts.noNetwork) throw new Error(`--no-network set and no local source covers ${name}`); if (opts.skipFetch) throw new Error(`--skip-fetch set and no cached window covers ${name}`); const maxH = render.maxHeightSource; const fmt = [ `bv*[vcodec^=avc1][height<=${maxH}]+ba[acodec^=mp4a]`, `bv*[ext=mp4][height<=${maxH}]+ba[ext=m4a]`, `b[ext=mp4][height<=${maxH}]`, `b[height<=${maxH}]`, ].join("/"); const argsWith = (extra) => clipFetchArgs({ url: meta.webpageUrl, from, to, fmt, dest, extra }); const attempt = async (extra) => { try { await execFileP(YTDLP, argsWith(extra), { maxBuffer: 1 << 26 }); return null; } catch (err) { return ytdlpOk(err) ? null : err; } }; EMIT("fetch", { id: entry.id, video: entry.video, from, to, cached: false, source: "network" }); let err = await attempt([]); // Rumble delivers HLS whose segments are named `.tar`, and ffmpeg 8's picky // extension check rejects those outright — "URL ... is not in // allowed_segment_extensions" — killing the fetch with exit 183. Rumble ships // no progressive format to fall back to, so without this every Rumble-sourced // clip is unbuildable. // // It has to be a RETRY, not a default: -extension_picky lives on the HLS // demuxer, so passing it against a progressive URL (YouTube's googlevideo mp4) // makes ffmpeg abort with "Option extension_picky not found" — i.e. adding it // unconditionally trades a Rumble failure for a YouTube one. if (err && /allowed_segment_extensions|allowed_extensions/.test(String(err.stderr ?? err.message ?? ""))) { EMIT("note", { id: entry.id, message: ` ${entry.id}: HLS segment extension rejected, retrying with -extension_picky 0` }); err = await attempt(["--downloader-args", "ffmpeg_i:-extension_picky 0"]); } if (err) { throw new Error(`yt-dlp failed for ${entry.id} (${entry.video}): ${err.stderr ?? err.message}`); } if (!(await exists(dest))) { // If a fallback format still forced another container, yt-dlp writes // ".". Adopt it rather than failing the run. const dir = path.dirname(dest); const base = path.basename(dest); const stray = (await readdir(dir)).find((f) => f.startsWith(base + ".")); if (!stray) throw new Error(`yt-dlp reported success but produced no file for ${entry.id}`); await rename(path.join(dir, stray), dest); } return { path: dest, fetchStart: from, cached: false, source: "network" }; } // Parse ffmpeg's silencedetect output into [{s, e}] intervals, in seconds // relative to the start of the given file. async function detectSilence(file, render, scan = null) { const minDur = render.silenceMinDur ?? 0.09; // A source wider than the fetch would have been (a corpus window, a whole // saved container) is measured over just that span: seeked, audio only, and // the silences shifted back onto the file's clock. A raw-cache window is // measured whole, as it always was, so no cached cut moves. const input = scan ? ["-ss", scan.from.toFixed(3), "-to", scan.to.toFixed(3), "-i", file, "-vn"] : ["-i", file]; const shift = scan ? scan.from : 0; // The threshold has to be RELATIVE to the clip, not absolute. These are game // streams: the gaps between words are full of game audio and music, so they // are quiet but nowhere near silent. A fixed -32 dB sits below the noise floor // of a typical clip here and finds literally zero silences (measured: mean // volume -21 dB, 0 hits at -32 dB, 25 hits at -26 dB). Measure the clip first // and cut a few dB under its own mean instead. const { stderr: volLog } = await execFileP( FFMPEG, ["-nostdin", ...input, "-af", "volumedetect", "-f", "null", "-"], { maxBuffer: 1 << 26 }, ).catch((e) => ({ stderr: e.stderr ?? "" })); const meanMatch = (volLog ?? "").match(/mean_volume:\s*(-?[\d.]+) dB/); const mean = meanMatch ? Number(meanMatch[1]) : -24; const noise = Math.max(-45, Math.min(-18, mean - (render.silenceRelDb ?? 6))); // ffmpeg exits 0 here, so stderr comes back on the resolved result. const { stderr } = await execFileP( FFMPEG, ["-nostdin", ...input, "-af", `silencedetect=noise=${noise.toFixed(1)}dB:d=${minDur}`, "-f", "null", "-"], { maxBuffer: 1 << 26 }, ).catch((e) => ({ stderr: e.stderr ?? "" })); const log = stderr ?? ""; const out = []; let open = null; for (const line of log.split("\n")) { const s = line.match(/silence_start:\s*(-?[\d.]+)/); if (s) open = Number(s[1]) + shift; const e = line.match(/silence_end:\s*(-?[\d.]+)/); if (e && open !== null) { out.push({ s: open, e: Number(e[1]) + shift }); open = null; } } return out; } // Snap a desired cut to the nearest silence, so the clip begins and ends between // words instead of through one. Returns the desired point unchanged when no // silence is close enough — better a tight cut than a cut in the wrong place. // // How much of the pause is kept: 0.10 s before speech resumes, 0.18 s after it // stops, unless the render says `snapLead` / `snapTail` -- and then never more // than the pause holds, so a long one keeps its breath (room for the // crossfade to fade over silence, not the last word) without reaching the // next word. Exported for the tests. export function snap(desired, intervals, kind, window, render = {}) { let best = null; for (const iv of intervals) { // Starting: we want to resume just before speech does -> the silence's END. // Ending: we want to stop just after speech does -> the silence's START. const point = kind === "start" ? iv.e : iv.s; const d = Math.abs(point - desired); if (d > window) continue; if (!best || d < best.d) best = { d, point, iv }; } if (!best) return { at: desired, snapped: false }; const set = kind === "start" ? render.snapLead : render.snapTail; let lead = kind === "start" ? -0.10 : 0.18; if (Number.isFinite(set) && set >= 0) { const room = best.iv.e - best.iv.s; lead = (kind === "start" ? -1 : 1) * Math.min(set, room); } return { at: Math.max(0, best.point + lead), snapped: true }; } // Encoder quality is manifest-driven so a cut can trade size for fidelity without // editing this file. Defaults reproduce the original hardcoded settings exactly. const encodeArgs = (render) => [ "-c:v", "libx264", "-preset", render.preset ?? "medium", "-crf", String(render.crf ?? 20), "-pix_fmt", "yuv420p", "-r", String(render.fps), "-c:a", "aac", "-b:a", render.audioBitrate ?? "160k", "-ar", String(render.audioRate), "-ac", String(render.audioChannels), "-movflags", "+faststart", ]; // The rail pass runs over an ALREADY ENCODED file, so its audio is already the // finished AAC. Re-encoding it would cost a whole generation for nothing — and // would make "the rail does not touch the audio" untrue. const encodeArgsVideoOnly = (render) => [ "-c:v", "libx264", "-preset", render.preset ?? "medium", "-crf", String(render.crf ?? 20), "-pix_fmt", "yuv420p", "-r", String(render.fps), "-c:a", "copy", "-movflags", "+faststart", ]; async function buildClipSegment(entry, meta, render, dirs, opts, chrome, nodes, provenance, framing = null) { const outDir = dirs.dir; const { path: raw, fetchStart, scan } = await fetchClip(entry, meta, render, dirs.local, opts); const seg = path.join(outDir, "segments", `${entry.id}.mp4`); const pal = render.palette; const { width, height } = render; // EXTENT vs CUT. // // `start`/`end` is the reviewed extent -- how much of this recording is worth // having, decided once by somebody watching it. `cutStart`/`cutEnd`, when // they are there, is the tight cut derived from where the quote actually is // (resolve-windows --cut-to-quote), and it is what plays. The extent still // decides what gets FETCHED, because a cut is always inside it and a // re-cut must never need another download. // // 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 hasCut = Number.isFinite(entry.cutStart) && Number.isFinite(entry.cutEnd); const { from: playFrom, to: playTo } = playWindow(entry, render); if (hasCut) { EMIT("cut", { id: entry.id, extent: [entry.start, entry.end], cut: [playFrom, playTo], seconds: Number((playTo - playFrom).toFixed(2)), }); // The header prints a second, and it has to be a second you can hear in // the clip that plays. Kept rather than moved: a cite is a citation, and // silently re-pointing one is worse than saying it is off. const at = entry.cite ?? entry.start; if (at < playFrom - 0.05 || at > playTo + 0.05) { EMIT("note", { message: `${entry.id}: cite ${at} is outside the cut ${playFrom.toFixed(2)}–${playTo.toFixed(2)} ` + `— kept as written; move it or widen the cut`, }); } } // Desired cut points, expressed relative to the over-fetched file. const wantA = playFrom - fetchStart; const wantB = playTo - fetchStart; const win = render.snapWindow ?? 1.6; const sil = await detectSilence(raw, render, scan); const a = snap(wantA, sil, "start", win, render); const b = snap(wantB, sil, "end", win, render); // Never let snapping invert or collapse the window. 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`); // Written for reference/diffing only — the quote is no longer drawn on screen. await writeFile(quotePath, wrap(`“${entry.quote}”`, 92), "utf8"); // WHO, what, when, where in it. The channel heads the line because a // compilation routinely spans a streamer's own channel and two VOD mirrors, // and nothing else on screen says which one is playing. // // The header otherwise prints the upload date of the ARCHIVED copy, which for // a VOD mirror is often years after the stream. A clip may carry // `channelTitle`, `date` (the stream's own YYYY-MM-DD) and `title` (a display // title) to override the record's. await writeFile(attribPath, attributionLine(entry, meta, provenance), "utf8"); // A brand draws the line's head -- the channel -- in the foreground colour. const who = render.brand ? channelName(entry, meta, provenance) : ""; const channelPath = who ? path.join(outDir, "segments", `${entry.id}.channel.txt`) : null; if (channelPath) await writeFile(channelPath, who, "utf8"); // THE DECK. The citation header, the corner QR and the section footer are // all things the deck says instead -- the source and date in its subtitle, // the code in its QR, the position in its pips -- so none of them is drawn, // and the segment is the picture alone, framed into the box above the deck. // The composition is overlaid on the whole concat later; nothing here knows // about it. Same cut, same audio map, same encode as every other segment. // No picture in the source: a poster stands in for `[0:v]` (see above). const media = await probeMedia(raw); const posterSeconds = Math.max(1 / render.fps, (media.duration != null ? Math.min(cutB, media.duration) : cutB) - cutA); if (!media.hasVideo) EMIT("note", { message: `${entry.id}: no picture in ${path.basename(raw)} — a poster plays under its sound` }); const poster = (box, posterIdx) => media.hasVideo ? null : audioPosterInput({ entry, meta, provenance, render, outDir, box, seconds: posterSeconds }) .then((input) => ({ input, parts: audioPosterParts({ posterIdx, box, render }) })); if (deckOn(render)) { const fr = framing ?? segmentFraming(render); const pst = await poster(fr.box, 1); await execFileP( FFMPEG, [ "-nostdin", "-v", "error", "-y", ...cutArgs(raw, cutA, cutB), ...(pst ? pst.input : []), "-filter_complex", [ ...(pst ? pst.parts : []), `${pst ? "[pic]" : "[0:v]"}${deckFramingFilter(render, { feed: fr.layout === "feed" })}[v]`, ].join(";"), "-map", "[v]", "-map", "0:a", ...encodeArgs(render), seg, ], { maxBuffer: 1 << 24 }, ); // The box it was framed into, so --chrome-only can refuse another layout's. await writeCutRecord(seg, { ...cutRecord, framing: fr }); return seg; } // The picture is the point. Nothing is drawn over it: the video is letterboxed // between a thin citation header and a thin timeline footer, so the source // material plays unobstructed and the additions stay subtle. const HH = render.headerHeight ?? 56; // The picture lives left of the rail column; the rail's own pixels are painted // by the rail chain at concat time, over ground this pad leaves for it. const VW = contentWidth(render); const FH = chrome.footerHeight; const hasFooter = FH > 0 && chrome.footer; // headerHeight:0 drops the citation line too, leaving the clips alone on screen. // Worth having: a cut whose sources are listed elsewhere does not need to carry // its own attribution burnt into every frame. const hasHeader = HH > 0; const VH = height - HH - FH; const trackAbsY = height - FH + chrome.trackY; // Where the progress marker travels this clip. Only the first clip of a // section moves it; the rest hold it in place. const T = render.slideSeconds ?? 0.9; const xTo = chrome.xs[entry.section]; const xFrom = entry.sectionEnter ? chrome.xs[Math.max(0, entry.section - 1)] : xTo; // Commas inside a filter option have to survive filtergraph parsing; single // quotes around the expression is what protects them. const ramp = (a, b) => a === b ? String(b) : `'if(lt(t,${T}),${a}+(${b}-${a})*t/${T},${b})'`; const markX = ramp(xFrom - chrome.markerRadius, xTo - chrome.markerRadius); // The fill bar CANNOT be a drawbox with a `t`-dependent width. drawbox has no // time variable at all: its `t` is the box THICKNESS, and with `t=fill` that // is effectively INT_MAX, so the old `if(lt(t,0.9),…)` was always false and // the bar was always drawn at its final width. (Proof: `drawbox=w='t*10'` and // `drawbox=w=20` produce an identical YAVG.) Only the amber marker ever moved. // // So do it the way the rail does: a 2*LEN-wide strip, accent on the left half // and transparent on the right, translated under a fixed-width crop. crop's // x IS per-frame in `t`, and it clamps, so the ends are self-parking. const fillA = xFrom - chrome.x0; const fillB = xTo - chrome.x0; const fillExpr = fillA === fillB ? String(fillB) : `${fillA}+(${fillB - fillA})*clip(t/${T},0,1)`; const base = [ `scale=${VW}:${VH}:force_original_aspect_ratio=decrease`, `pad=${VW}:${VH}:(ow-iw)/2:(oh-ih)/2:color=${pal.bg}`, // Widen back to the full frame, leaving the rail column (if any) as ground. `pad=${width}:${VH}:0:0:color=${pal.bg}`, `pad=${width}:${height}:0:${HH}:color=${pal.bg}`, "setsar=1", `fps=${render.fps}`, ...(hasHeader ? headerFilters(render, attribPath, channelPath) : []), ].join(","); // A manifest with a RAIL draws the code in the rail's foot instead, as one // more strip: bottom-right of the frame, bordered, one per clip. It used to // float over the bottom-right of the picture — the one part of the frame this // cut promises never to draw on. Manifests with no rail keep the old overlay, // byte for byte. const qr = render.qr === false || render.rail ? null : await qrForEntry(entry, provenance, render, outDir); const qrM = render.qr?.margin ?? 28; // Bound the bar strip SHORTER than the clip. An overlay secondary that outruns // the main extends the output, and the fix for that (shortest=1) would instead // truncate the clip to the strip. Ending early is free: overlay's default // eof_action=repeat holds the strip's last frame, which is the parked bar. const barT = Math.max(0.2, cutB - cutA - 0.25); const inputs = cutArgs(raw, cutA, cutB); let nextIdx = 1; let footerIdx, markerIdx, barIdx, qrIdx; if (hasFooter) { footerIdx = nextIdx++; inputs.push("-i", chrome.footer); markerIdx = nextIdx++; inputs.push("-i", chrome.marker); // A PNG fed with a plain -i through an ANIMATED crop is frozen: the crop // sees one frame at t=0 and repeatlast repeats the already-cropped result. // -loop 1 -framerate is what makes the strip a video the crop can walk. barIdx = nextIdx++; inputs.push( "-loop", "1", "-framerate", String(render.fps), "-t", barT.toFixed(3), "-i", chrome.bar, ); } if (qr) { qrIdx = nextIdx++; inputs.push("-i", qr.png); } // The brand's mark, leading the header: the LAST input, so every index above // is the one an unbranded build uses. const brandMark = hasHeader && render.brand ? brandHeaderGeometry(render) : null; let markIdx; if (brandMark) { markIdx = nextIdx++; inputs.push("-i", await markPng(render, brandMark.mark, path.join(outDir, "cards"))); } // The poster, when the source has no picture: after everything else. const pst = await poster({ width: VW, height: VH }, nextIdx); if (pst) { nextIdx += 1; inputs.push(...pst.input); } const pic = pst ? "[pic]" : "[0:v]"; const parts = hasFooter ? [ ...(pst ? pst.parts : []), `${pic}${base}[b]`, `[b][${footerIdx}:v]overlay=0:${height - FH}[f]`, `[${barIdx}:v]crop=w=${chrome.trackLen}:h=3:x='${chrome.trackLen}-(${fillExpr})':y=0[bar]`, `[f][bar]overlay=x=${chrome.x0}:y=${trackAbsY - 1}[g]`, `[g][${markerIdx}:v]overlay=x=${markX}:y=${trackAbsY - chrome.markerRadius}[q]`, ] : [...(pst ? pst.parts : []), `${pic}${base}[q]`]; let q = "q"; if (brandMark) { parts.push(`[q][${markIdx}:v]overlay=x=${brandMark.markX}:y=${brandMark.markY}[qm]`); q = "qm"; } // Sit above the footer when there is one, so the code never straddles the chrome. parts.push( qr ? `[${q}][${qrIdx}:v]overlay=x=${VW}-w-${qrM}:y=H-h-${FH + qrM}[v]` : `[${q}]null[v]`, ); await execFileP( FFMPEG, [ "-nostdin", "-v", "error", "-y", ...inputs, "-filter_complex", parts.join(";"), "-map", "[v]", "-map", "0:a", ...encodeArgs(render), seg, ], { maxBuffer: 1 << 24 }, ); await writeCutRecord(seg, cutRecord); return seg; } /** Beside `segments/.mp4`: `.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 // pull up the surrounding hour and check that the cut is fair. Per clip, because a // single code for the whole video would send everyone to the first citation. // // Two rules learned the hard way: it must be FULLY OPAQUE (a translucent QR will // not scan) and it must keep its quiet zone (the white border is part of the // symbol, not decoration). async function qrForEntry(entry, provenance, render, outDir) { // A `src` clip's file has no page on the archive: only its own citeUrl. if (hasLocalMedia(entry) && !entry.citeUrl) return null; const q = render.qr ?? {}; // A mirror's LOCAL slug is not the id the site serves, and a clip taken from a // copy whose archived transcript is broken should point at the copy that reads — // so an explicit per-clip citeUrl always wins over the derived one. const url = entry.citeUrl ?? `${provenance.siteOrigin}/?v=${encodeURIComponent( `${entry.channel ?? provenance.channelSlug}/${entry.video}`, )}&t=${Math.floor(entry.start)}`; const png = path.join(outDir, "qr", `${entry.id}.png`); await execFileP(QRENCODE, [ "-o", png, "-s", String(q.scale ?? 4), "-m", String(q.quiet ?? 3), "-l", q.ecc ?? "M", // `--`: a URL is data, never an option, whatever it starts with. "--", url, ]); return { png, url }; } async function buildCardSegment(card, render, outDir, nodes, framing = null) { const png = await renderCard(card, render, outDir, nodes); const seg = path.join(outDir, "segments", `${card.id}.mp4`); const dur = String(card.seconds); // Under the deck a card is either full frame -- `overCards: "hide"`, the // deck slides away over it, and the card encodes exactly as it always has -- // or framed into the footage box like a clip, so the deck can stay up over // it without covering its bottom rows. const framed = deckOn(render) && resolveDeck(render).overCards === "show"; const fr = framed ? framing ?? segmentFraming(render) : null; const vf = framed ? deckFramingFilter(render, { feed: fr.layout === "feed" }) : `fps=${render.fps},setsar=1`; await execFileP( FFMPEG, [ "-nostdin", "-v", "error", "-y", // Without -framerate the image demuxer runs at its 25 fps default and the // `-vf fps=30` below DUPLICATES a frame — at the segment's first frame, // which is exactly where the next xfade seam lands. "-loop", "1", "-framerate", String(render.fps), "-t", dur, "-i", png, "-f", "lavfi", "-t", dur, "-i", `anullsrc=channel_layout=stereo:sample_rate=${render.audioRate}`, "-vf", vf, ...encodeArgs(render), "-shortest", seg, ], { maxBuffer: 1 << 24 }, ); if (fr) await writeCutRecord(seg, { version: 1, id: card.id, framing: fr }); return seg; } // ---- the teaser ----------------------------------------------------------- // A `teaser` entry is a full-frame graphic card -- a season teaser's "coming // soon" screen -- drawn by a HyperFrames composition from the entry's words // (chrome-teaser.mjs) and rendered once, cached by the page's content key // (compose-chrome). This encodes those frames into the segment at the // parameters every segment shares, with a sound track: a trailer hit under // each pop and a swell under the tail (`teaserHits`, deck.mjs -- the same // times the composition's cues land on), or digital silence with `hits: false`. // // The segment is re-encoded only when its key changes: the frames' key and the // sound's graph, recorded beside it (`.teaser.json`). So a teaser whose // words changed is re-rendered and re-encoded by any build that reaches it -- // `--chrome-only` included, which builds teaser segments (they are chrome: // graphics made from the manifest, nothing fetched) -- and an unchanged one is // neither. /** The record beside a teaser's segment: the key it was encoded from. */ export const teaserRecordPath = (seg) => seg.replace(/\.mp4$/, ".teaser.json"); /** The teaser sound's level and ceiling: `limit` is −6 dBFS; `level` is set against the ferret cut's loudness (README). */ export const TEASER_AUDIO = Object.freeze({ level: 1.4, limit: 0.5 }); /** A number for an aevalsrc expression: 6 decimals, no trailing zeros. */ const ev6 = (v) => { const s = (Math.round(Number(v) * 1e6) / 1e6).toFixed(6).replace(/\.?0+$/, ""); return s === "-0" ? "0" : s; }; /** * The teaser's sound, as one filtergraph fragment from no inputs to `[ta]`: * `hits` (`teaserHits`) synthesised in ffmpeg, no samples. * * A hit is three layers, each summed over every hit in one `aevalsrc` and * gated to its own span, so each starts on the sample its time names: * - the boom: a sine whose pitch drops from f0 to f1 (most of the way in * ~0.4 s), with its octave for body, on an exponential decay (`decay` is * the time constant; it is inaudible by ~7×) after a 3 ms attack; * - the punch: a burst of noise (50 ms time constant), band-passed (180 Hz–3.2 kHz); * - the tail: a low noise decay (low-passed at 260 Hz) under it. * A swell is the boom's sine rising f0 → f1 under an envelope that peaks * three quarters of the way through `dur` and settles, with a breath of the * low noise. A riser (a dip's, under the black) is a sub whose pitch climbs * f0 → f1 over `dur` and a noise swell -- its own layer, band-passed * 400 Hz–6.5 kHz, present only when there is a riser -- both rising (the sub * squared, the noise cubed) to their peak at the first hit and released over * `decay`. The noise is a hash of the sample number, not `random()`, so it * is the same whatever else is in the graph. The sum takes a short low-passed * echo, then `level`, then a limiter at −6 dBFS (`limit`, no auto-level, * latency compensated) so hits that overlap still sum cleanly; trimmed and * padded to exactly `seconds`. No hits: digital silence. */ export function teaserAudioGraph(hits, { seconds, render, level = TEASER_AUDIO.level, limit = TEASER_AUDIO.limit }) { const rate = render.audioRate; const layout = render.audioChannels === 1 ? "mono" : "stereo"; const len = ev6(seconds); const tail = `atrim=end=${len},apad=whole_dur=${len},asetpts=PTS-STARTPTS[ta]`; if (!hits.length) return `anullsrc=channel_layout=${layout}:sample_rate=${rate},${tail}`; const noise = "(2*(sin(n*12.9898+78.233)*43758.5453-floor(sin(n*12.9898+78.233)*43758.5453))-1)"; const boom = []; const punch = []; const rumble = []; const whoosh = []; for (const h of hits) { const a = ev6(h.at); const u = `(t-${a})`; const g = ev6(h.gain); if (h.kind === "riser") { const D = ev6(h.dur); const v = `min(${u},${D})`; const phase = `(${ev6(h.f0)}*${v}+${ev6((h.f1 - h.f0) / (2 * h.dur))}*${v}*${v}+${ev6(h.f1)}*(${u}-${v}))`; const rel = `clip((${ev6(h.dur + h.decay)}-${u})/${ev6(h.decay)},0,1)`; const span = ev6(h.dur + h.decay); boom.push(`if(between(t,${a},${a}+${span}),${g}*0.5*pow(${v}/${D},2)*${rel}*sin(2*PI*${phase}),0)`); whoosh.push(`if(between(t,${a},${a}+${span}),${g}*0.6*pow(${v}/${D},3)*${rel}*${noise},0)`); continue; } if (h.kind === "swell") { const D = h.dur; const peak = ev6(D * 0.75); const v = `min(${u},${ev6(D)})`; const phase = `(${ev6(h.f0)}*${v}+${ev6((h.f1 - h.f0) / (2 * D))}*${v}*${v}+${ev6(h.f1)}*(${u}-${v}))`; const env = `pow(sin(PI/2*min(1,${u}/${peak})),2)*exp(-max(0,${u}-${peak})/${ev6(h.decay)})`; const span = ev6(D * 0.75 + h.decay * 7); boom.push(`if(between(t,${a},${a}+${span}),${g}*0.42*${env}*sin(2*PI*${phase}),0)`); rumble.push(`if(between(t,${a},${a}+${span}),${g}*0.5*${env}*${noise},0)`); continue; } const k = 0.13; // the pitch drop's time constant const phase = `(${ev6(h.f1)}*${u}+${ev6((h.f0 - h.f1) * k)}*(1-exp(-${u}/${k})))`; const span = ev6(h.decay * 7); boom.push( `if(between(t,${a},${a}+${span}),${g}*0.3*min(1,${u}/0.003)*exp(-${u}/${ev6(h.decay)})*` + `(sin(2*PI*${phase})+0.6*exp(-${u}/${ev6(h.decay * 0.6)})*sin(4*PI*${phase})),0)`, ); punch.push(`if(between(t,${a},${a}+0.25),${g}*0.5*exp(-${u}/0.05)*${noise},0)`); rumble.push(`if(between(t,${a},${a}+${ev6(Math.min(2.6, h.decay * 6))}),${g}*0.32*min(1,${u}/0.02)*exp(-${u}/${ev6(h.decay * 1.3)})*${noise},0)`); } const src = (terms) => `aevalsrc=exprs='${terms.length ? terms.join("+") : "0"}':s=${rate}:c=${layout}:d=${len}`; // The riser's layer only when there is one: a teaser without a dip writes the graph it always did. const w = whoosh.length ? [`${src(whoosh)},highpass=f=400,lowpass=f=6500[tw]`] : []; return [ `${src(boom)}[tb]`, `${src(punch)},highpass=f=180,lowpass=f=3200[tp]`, `${src(rumble)},lowpass=f=260,lowpass=f=260[tr]`, ...w, `[tb][tp][tr]${w.length ? "[tw]" : ""}amix=inputs=${3 + w.length}:normalize=0,aecho=1:1:90|190:0.18|0.09,lowpass=f=5000,` + `volume=${ev6(level)},alimiter=limit=${ev6(limit)}:level=0:latency=1:attack=2:release=80,${tail}`, ].join(";"); } /** The teaser segment's encode: its frames, its sound, the shared parameters. */ export function teaserEncodeArgs({ framesDir, seconds, render, audio, outPath }) { return [ "-nostdin", "-v", "error", "-y", // The renderer writes an all-opaque frame as RGB and any other as RGBA; // a switch mid-sequence would reinitialise the graph and end it early. "-framerate", String(render.fps), "-reinit_filter", "0", "-start_number", "1", "-i", path.join(framesDir, "frame_%06d.png"), "-filter_complex", `[0:v]format=rgba,fps=${render.fps},setsar=1[tv];${audio}`, "-map", "[tv]", "-map", "[ta]", ...encodeArgs(render), "-frames:v", String(frameCount(seconds, render.fps)), "-shortest", outPath, ]; } /** * A teaser segment's key: its frames' render key, its sound's whole graph * (every hit's time and parameters, `hits: false`'s silence, the level) and * the encode's parameters (`encodeArgs`: crf, preset, the audio's bitrate, * rate and channels -- the graph says "stereo" whatever `audioChannels` is). * A segment whose recorded key differs is re-encoded, as every clip is when * one of those changes. */ export const teaserSegmentKey = (framesKey, audioGraph, render) => createHash("sha256") .update(JSON.stringify({ v: 2, frames: framesKey, audio: audioGraph, encode: encodeArgs(render) })) .digest("hex"); /** * Compose and render the teaser (cached by compose-chrome's key), then encode * its segment unless the one on disk was made from the same frames and sound. * `transition` is the cut's crossfade as this build plays it (0 under * `--no-xfade`): a dip's lead is that dissolve and the black, in the page and * in the sound alike. Dynamic import: compose-chrome imports this file, and * its page module must not reach umtool's bundle through the build (docs/quirks.md). */ export async function buildTeaserSegment(entry, { manifestPath, render, outDir, variant, transition = render.transition ?? 0.5 }) { const { composeChrome } = await import("./compose-chrome.mjs"); const t0 = Date.now(); const r = await composeChrome({ manifestPath, outDir, variant, region: "teaser", segment: entry.id, doRender: true, fps: render.fps, workers: 4, quality: "high", format: "png-sequence", transition, }); EMIT("chrome", { phase: r.cached ? "cached" : "render", region: "teaser", segment: entry.id, frames: r.frameCount, key: r.key, dir: r.frames, seconds: Number(((Date.now() - t0) / 1000).toFixed(1)), }); const seconds = teaserSeconds(entry, transition, render.fps); const hits = teaserHits(entry, transition, render.fps); const audio = teaserAudioGraph(hits, { seconds, render }); const key = teaserSegmentKey(r.key, audio, render); const seg = path.join(outDir, "segments", `${entry.id}.mp4`); const recPath = teaserRecordPath(seg); const rec = await readFile(recPath, "utf8").then(JSON.parse, () => null); if (rec?.key === key && (await exists(seg))) { EMIT("note", { id: entry.id, message: `${entry.id}: teaser segment unchanged (key ${key.slice(0, 12)})` }); return seg; } await execFileP(FFMPEG, teaserEncodeArgs({ framesDir: r.frames, seconds, render, audio, outPath: seg }), { maxBuffer: 1 << 26 }); // `transition` is the cut's as built (0 under --no-xfade): a dip's lead // counts it, and verify-build reads it back from here. await writeFile(recPath, JSON.stringify({ key, frames: r.key, hits: hits.length, transition }) + "\n", "utf8"); return seg; } // ---- stills -------------------------------------------------------------- // An `image` entry is a screenshot in the cut: the receipts a clip cannot say // out loud -- a post, a thread, a DM -- shown for `seconds` and then gone. // // It is built as a CLIP would be framed and as a CARD is encoded. The picture // area is a clip's exactly (the frame less the header, less the footer, less // the rail column), so a still dropped between two clips does not move the // letterbox; the stream layout is a card's exactly (still video plus silent // stereo, the same fps/setsar/encode args), so concat and xfade cannot tell the // three kinds apart. // // Two things it deliberately does NOT do: // // * It never derives a QR target. A clip's code is derived from the archive // that serves it; a screenshot has no such archive, and a code resolving to // the wrong place is worse than no code. `citeUrl` -- an explicit promise // about where this picture came from -- is the only thing that draws one. // * It reserves the footer's rows but draws nothing in them. The marker's // position is a function of `section`, which a still does not have, and a // timeline strip whose marker vanishes for six seconds reads as a bug. async function buildImageSegment(entry, render, outDir, chrome, provenance, baseDir, framing = null) { const seg = path.join(outDir, "segments", `${entry.id}.mp4`); const pal = render.palette; const { width, height } = render; // ONE picture or a ROW of them. `panels` is the same still with several // exhibits in it -- two phone captures that argue with each other side by // side -- and everything after the layout is identical, which is why it is a // form of this entry rather than a type of its own. const multi = entry.panels !== undefined && entry.panels !== null; const wantsScroll = entry.scroll !== undefined && entry.scroll !== null && entry.scroll !== false; if (multi && entry.src) { throw new Error(`${entry.id}: an image entry takes \`src\` or \`panels\`, not both`); } if (multi && wantsScroll) { // Both are answers to "it does not fit", and they are different answers. // Crawling a row would also have to decide which panel the crawl follows. throw new Error( `${entry.id}: \`scroll\` walks down ONE tall picture and \`panels\` puts several side by side — not both`, ); } if (!multi && !entry.src) throw new Error(`${entry.id}: an image entry needs \`src\` (or \`panels\`)`); if (multi && (!Array.isArray(entry.panels) || entry.panels.length < 2 || entry.panels.length > 4)) { throw new Error( `${entry.id}: panels must be a list of 2 to 4 images ` + `(got ${Array.isArray(entry.panels) ? entry.panels.length : typeof entry.panels})`, ); } const dur = Number(entry.seconds ?? 4); if (!Number.isFinite(dur) || dur <= 0) { throw new Error(`${entry.id}: seconds must be a positive number, got ${entry.seconds}`); } // Under the deck the picture area is the deck's footage box -- the box a clip // is framed into, so a still between two clips does not move either -- and // there is no header, footer or corner code: the deck carries all three. const fr = deckOn(render) ? framing ?? segmentFraming(render) : null; const deck = fr ? deckFraming(render, { feed: fr.layout === "feed" }) : null; const HH = render.headerHeight ?? 56; const VW = deck ? deck.box.width : contentWidth(render); const FH = chrome.footerHeight; const VH = deck ? deck.box.height : height - HH - FH; // Each picture carries its OWN redactions and crop, measured in its own // source pixels -- a panel's boxes were taken off that file in an image // viewer, and nothing in the layout is allowed to move them. const pieces = multi ? entry.panels.map((p, i) => ({ ...p, id: `${entry.id}.panels[${i}]` })) : [{ src: entry.src, crop: entry.crop, redact: entry.redact, id: entry.id }]; const resolved = []; for (const piece of pieces) { if (!piece.src) throw new Error(`${piece.id}: needs \`src\``); // Relative to the MANIFEST, not to the cwd. A manifest is checked in beside // the pictures it cites, and is built from wherever the operator happens to be. const file = path.resolve(baseDir, piece.src); if (!(await exists(file))) { throw new Error(`${piece.id}: no image at ${file} (src: ${piece.src})`); } // A row needs every panel measured to lay them out; a lone still only when // something is expressed in its source pixels. const needDims = multi || piece.redact !== undefined || piece.crop !== undefined || wantsScroll; const dims = needDims ? { ...(await imageDims(file, piece.id)), label: piece.src } : null; const filters = piece.redact !== undefined || piece.crop !== undefined ? sourcePixelFilters(piece, dims, pal.bg) : []; // What is LEFT after the crop. The crop is the framing decision; the layout // and the crawl are both facts about what was kept, not about the file. const kept = piece.crop ? { width: Number(piece.crop[2]), height: Number(piece.crop[3]) } : dims; resolved.push({ ...piece, file, filters, kept }); } // The line is the author's caption, not a record's title, so it comes from // the entry alone. Nothing to say means no header at all: drawtext refuses an // empty textfile outright. const line = imageAttributionLine(entry); const hasHeader = !deck && HH > 0 && line.length > 0; const attribPath = path.join(outDir, "segments", `${entry.id}.attrib.txt`); if (hasHeader) await writeFile(attribPath, line, "utf8"); // ---- the picture, or the row of them ------------------------------------ const panelParts = []; let head; let source = "[0:v]"; if (multi) { let layout; try { layout = panelLayout(resolved.map((r) => r.kept), { areaW: VW, areaH: VH, gap: entry.gap ?? PANEL_GAP, }); } catch (err) { throw new Error(`${entry.id}: ${err.message}`); } EMIT("note", { message: `${entry.id}: ${layout.boxes.length} panels ` + `${layout.boxes.map((b) => `${b.w}x${b.h}`).join(" + ")} with ${layout.gap}px gaps ` + `= ${layout.totalWidth}px of ${VW}` + (layout.factor < 1 ? ` (scaled to ${(layout.factor * 100).toFixed(1)}% to fit)` : ""), }); resolved.forEach((r, i) => { const box = layout.boxes[i]; const chain = [...r.filters, `scale=${box.w}:${box.h}`, "setsar=1"]; // The gap is ground, carried on the left panel's right edge. hstack has // no spacing of its own. if (i < resolved.length - 1) { chain.push(`pad=${box.w + layout.gap}:${box.h}:0:0:color=${pal.bg}`); } panelParts.push(`[${i}:v]${chain.join(",")}[pan${i}]`); }); panelParts.push( `${resolved.map((_, i) => `[pan${i}]`).join("")}hstack=inputs=${resolved.length}[row]`, ); source = "[row]"; head = [`pad=${VW}:${VH}:(ow-iw)/2:(oh-ih)/2:color=${pal.bg}`]; } else { const crawl = scrollPlan(entry, resolved[0].kept, { areaW: VW, areaH: VH, seconds: dur, bg: pal.bg, }); if (crawl) EMIT("note", { message: `${entry.id}: ${crawl.describe}` }); head = [ ...resolved[0].filters, ...(crawl ? crawl.filters : [ `scale=${VW}:${VH}:force_original_aspect_ratio=decrease`, `pad=${VW}:${VH}:(ow-iw)/2:(oh-ih)/2:color=${pal.bg}`, ]), ]; } const base = ( deck ? [...head, ...deck.place] : [ ...head, // Widen back to the full frame, leaving the rail column (if any) as ground. `pad=${width}:${VH}:0:0:color=${pal.bg}`, `pad=${width}:${height}:0:${HH}:color=${pal.bg}`, "setsar=1", `fps=${render.fps}`, ...(hasHeader ? headerFilters(render, attribPath) : []), ] ).join(","); // `qrForEntry` already prefers `citeUrl`; the guard is that we never reach it // without one, so no still can be given a derived code. Under the deck the // deck shows a still's code (the same `citeUrl` rule, deckQrUrl). const qr = deck || render.qr === false || render.rail || !entry.citeUrl ? null : await qrForEntry(entry, provenance, render, outDir); const qrM = render.qr?.margin ?? 28; const secs = dur.toFixed(3); // Inputs: one per picture, then the silence, then the code. const silenceIdx = resolved.length; const qrIdx = silenceIdx + 1; const parts = [...panelParts, `${source}${base}[q]`]; // The brand's mark leads the header, as on a clip; its input goes last. const brandMark = hasHeader && render.brand ? brandHeaderGeometry(render) : null; const markIdx = qr ? qrIdx + 1 : qrIdx; let q = "q"; if (brandMark) { parts.push(`[q][${markIdx}:v]overlay=x=${brandMark.markX}:y=${brandMark.markY}[qm]`); q = "qm"; } parts.push( qr ? `[${q}][${qrIdx}:v]overlay=x=${VW}-w-${qrM}:y=H-h-${FH + qrM}[v]` : `[${q}]null[v]`, ); try { await execFileP( FFMPEG, [ "-nostdin", "-v", "error", "-y", // Without -framerate the image demuxer runs at its 25 fps default and // the `fps=30` above DUPLICATES a frame -- at the segment's first frame, // which is exactly where the next xfade seam lands. It is also what // makes an animated crop evaluate per frame rather than freezing. ...resolved.flatMap((r) => [ "-loop", "1", "-framerate", String(render.fps), "-t", secs, "-i", r.file, ]), "-f", "lavfi", "-t", secs, "-i", `anullsrc=channel_layout=stereo:sample_rate=${render.audioRate}`, ...(qr ? ["-i", qr.png] : []), ...(brandMark ? ["-i", await markPng(render, brandMark.mark, path.join(outDir, "cards"))] : []), "-filter_complex", parts.join(";"), "-map", "[v]", "-map", `${silenceIdx}:a`, ...encodeArgs(render), "-shortest", seg, ], { maxBuffer: 1 << 24 }, ); } catch (err) { // An unreadable or unsupported picture is ffmpeg's answer to give, not ours // to guess at -- but an id has to be on it, or a 27-entry build reports a // decoder error belonging to nothing. const detail = String(err?.stderr ?? "").trim() || err?.message || String(err); const what = resolved.map((r) => r.src).join(", "); throw new Error(`${entry.id}: ffmpeg failed on ${what}\n${detail}`); } // Under the deck, the box it was framed into (see segmentFraming). if (fr) await writeCutRecord(seg, { version: 1, id: entry.id, framing: fr }); return seg; } /** * The head of a still's filter chain: everything expressed in SOURCE pixels. * * Pure, and exported, so the geometry can be tested without an encoder. * * REDACTION COMES FIRST, AND IT FILLS RATHER THAN BLURS. A screenshot can carry * a live QR code, a phone number or an address that must not ship in a video, * and a QR survives mild blur -- a pixelated code still scans, which is a * redaction that looks done and is not. A solid box in the frame's own * background colour cannot be undone by anybody. * * Both are in the ORIGINAL file's pixels, and redaction runs before the crop so * the numbers come straight off the file the author measured -- not off a * cropped intermediate whose origin moved. * * Out of bounds is an ERROR, not a clamp. ffmpeg would quietly shrink either * box to fit, and a redaction that silently became a smaller redaction is the * exact failure the field exists to prevent. * * @param {{ id: string, crop?: number[]|null, redact?: number[][]|null }} entry * @param {{ width: number, height: number, label?: string }} dims * @param {string} bg the palette background the boxes are filled with */ export function sourcePixelFilters(entry, dims, bg) { const where = dims.label ? `${dims.label} (${dims.width}x${dims.height})` : `${dims.width}x${dims.height}`; const box = (b, what) => { if (!Array.isArray(b) || b.length !== 4 || !b.every((n) => Number.isFinite(Number(n)))) { throw new Error(`${entry.id}: ${what} must be [x, y, w, h] in source pixels`); } const [x, y, w, h] = b.map(Number); if (w <= 0 || h <= 0 || x < 0 || y < 0 || x + w > dims.width || y + h > dims.height) { throw new Error(`${entry.id}: ${what} [${x}, ${y}, ${w}, ${h}] falls outside ${where}`); } return [x, y, w, h]; }; const out = []; if (entry.redact !== undefined && entry.redact !== null) { if (!Array.isArray(entry.redact)) { throw new Error(`${entry.id}: redact must be a list of [x, y, w, h] rectangles`); } entry.redact.forEach((r, i) => { const [x, y, w, h] = box(r, `redact[${i}]`); out.push(`drawbox=x=${x}:y=${y}:w=${w}:h=${h}:color=${bg}:t=fill`); }); } if (entry.crop !== undefined && entry.crop !== null) { const [x, y, w, h] = box(entry.crop, "crop"); out.push(`crop=${w}:${h}:${x}:${y}`); } return out; } /** The default breathing room between two panels, in pixels. */ export const PANEL_GAP = 40; /** * Two to four vertical screenshots, side by side. * * The layout is driven by HEIGHT, not width: these are phone captures of * different sizes, and matching their heights is what makes them read as one * exhibit rather than as two pictures that happen to be near each other. So * every panel is scaled to the picture area's height, and the widths fall out * of the aspect ratios. * * When the row is too wide for the frame, every panel shrinks by THE SAME * factor -- not each to its own share. A per-panel factor would keep the row * fitting and quietly change the relative sizes, which is a claim about the * exhibits nobody made; and because a panel's redaction is applied in its own * source pixels before any of this, a uniform factor is also the only one that * cannot slide a redaction box off what it was measured against. * * The gaps are ground, not picture, so they keep their width while the pictures * give way: the space between two exhibits means "these are two things", and * that reading should not get weaker as the exhibits get bigger. * * @param {{ width: number, height: number }[]} sizes the panels AFTER their crops * @returns {{ boxes: {w:number,h:number}[], gap:number, factor:number, totalWidth:number }} */ export function panelLayout(sizes, { areaW, areaH, gap = PANEL_GAP }) { if (!Array.isArray(sizes) || sizes.length < 2 || sizes.length > 4) { throw new Error(`panels must be a list of 2 to 4 images (got ${Array.isArray(sizes) ? sizes.length : typeof sizes})`); } sizes.forEach((s, i) => { if (!(s?.width > 0) || !(s?.height > 0)) throw new Error(`panels[${i}]: no usable dimensions`); }); if (!Number.isFinite(gap) || gap < 0) throw new Error(`gap must be a number ≥ 0 (got ${gap})`); const gaps = gap * (sizes.length - 1); const room = areaW - gaps; if (room <= 0) { throw new Error(`gap ${gap} leaves no room for ${sizes.length} panels across ${areaW}px`); } // Every panel at the area's full height first; the row's width is then a fact // rather than a choice, and the only question left is whether it fits. const atHeight = sizes.map((s) => Math.max(2, Math.round(s.width * (areaH / s.height)))); const factor = Math.min(1, room / atHeight.reduce((a, b) => a + b, 0)); const h = Math.max(2, Math.round(areaH * factor)); const boxes = atHeight.map((w) => ({ w: Math.max(2, Math.round(w * factor)), h })); return { boxes, gap, factor, totalWidth: boxes.reduce((a, b) => a + b.w, 0) + gaps, }; } /** A crawl's defaults: long enough to read the first line, and to finish it. */ export const SCROLL_HOLDS = { holdStart: 1.5, holdEnd: 2.0 }; /** * The `scroll` field, normalised and checked. * * `true` means the defaults, which is the common case -- an author asking for a * crawl is not usually asking for particular hold times. * * @param {{ id: string, scroll?: unknown }} entry * @param {number} seconds the still's own duration */ export function scrollHolds(entry, seconds) { const raw = entry.scroll; if (raw === undefined || raw === null || raw === false) return null; const spec = raw === true ? {} : raw; if (typeof spec !== "object" || Array.isArray(spec)) { throw new Error(`${entry.id}: scroll must be true or { holdStart, holdEnd } in seconds`); } const holdStart = Number(spec.holdStart ?? SCROLL_HOLDS.holdStart); const holdEnd = Number(spec.holdEnd ?? SCROLL_HOLDS.holdEnd); for (const [k, v] of [["holdStart", holdStart], ["holdEnd", holdEnd]]) { if (!Number.isFinite(v) || v < 0) throw new Error(`${entry.id}: scroll.${k} must be a number ≥ 0`); } // A crawl with no time left to move in is not a slow crawl, it is two holds // and a jump cut between them. Say so rather than render it. if (seconds <= holdStart + holdEnd) { throw new Error( `${entry.id}: seconds is ${seconds} but the holds alone are ` + `${holdStart} + ${holdEnd} = ${holdStart + holdEnd}s — there is no time left to scroll in`, ); } return { holdStart, holdEnd }; } /** * Where the top of the window sits, per frame. * * `clip()` is what parks the crawl at both ends, so the holds cost no extra * filter: before `holdStart` the ratio is negative and clamps to 0, after the * travel it is over 1 and clamps to the last row. `(ih-)` is the whole * distance there is to travel, measured on the scaled picture. * * Commas inside a filter option have to survive filtergraph parsing, and single * quotes around the expression is what protects them -- the same rule the * footer's fill bar is written under. */ export function scrollYExpr({ holdStart, holdEnd, seconds, areaH }) { const travel = seconds - holdStart - holdEnd; const n = (v) => String(Number(v.toFixed(3))); return `'clip((t-${n(holdStart)})/${n(travel)},0,1)*(ih-${areaH})'`; } /** * A tall screenshot, crawled. * * A full tweet thread does not fit in a 1920x1024 hole, and the two ways out * are both worse: shrink it until the text is unreadable, or cut it into four * stills and make the reader reassemble the thread. So the picture is scaled to * the AREA'S WIDTH -- which for a phone capture makes the text large -- and the * frame walks down it. * * Two caps, both learned from what happens without them: never wider than the * area (there is nowhere to put the overflow), and never more than 2x (a small * picture blown up to 1920 wide is a wall of artefacts, so it is centred at 2x * on the background instead). * * A picture that fits after all is NOT a crawl. It renders exactly as a plain * still would, because that is what it is -- a manifest that says `scroll` on a * short capture is describing an intent, not demanding motion. * * The motion is `crop`'s `y`, which IS per-frame in `t`. `drawbox` is not, and * the fill bar in the footer was drawn at its final width for weeks because of * exactly that difference. * * @returns {{ filters: string[], moving: boolean, describe: string } | null} */ export function scrollPlan(entry, kept, { areaW, areaH, seconds, bg }) { const holds = scrollHolds(entry, seconds); if (!holds) return null; if (!kept || !(kept.width > 0) || !(kept.height > 0)) { throw new Error(`${entry.id}: a scrolling still needs the source's dimensions`); } const factor = Math.min(areaW / kept.width, 2); const w = Math.max(2, Math.round(kept.width * factor)); const h = Math.max(2, Math.round(kept.height * factor)); const filters = [`scale=${w}:${h}`]; // Narrower than the area (a small capture at its 2x cap): centre it, rather // than leaving the picture pinned to the left edge. if (w < areaW) filters.push(`pad=${areaW}:${h}:(ow-iw)/2:0:color=${bg}`); if (h <= areaH) { filters.push(`pad=${areaW}:${areaH}:0:(oh-ih)/2:color=${bg}`); return { filters, moving: false, describe: `scroll asked for, but the picture fits (${w}x${h} in ${areaW}x${areaH}) — held still`, }; } filters.push(`crop=w=${areaW}:h=${areaH}:x=0:y=${scrollYExpr({ ...holds, seconds, areaH })}`); const travel = seconds - holds.holdStart - holds.holdEnd; return { filters, moving: true, describe: `crawling ${h - areaH}px over ${travel.toFixed(2)}s ` + `(hold ${holds.holdStart}s / ${holds.holdEnd}s, picture ${w}x${h})`, }; } /** The source's own pixels, which is the only frame a `crop` is expressed in. */ async function imageDims(file, id) { try { const { stdout } = await execFileP(FFPROBE, [ "-v", "error", "-select_streams", "v:0", "-show_entries", "stream=width,height", "-of", "csv=p=0:s=x", file, ]); const [w, h] = stdout.trim().split("\n")[0].split("x").map(Number); if (!(w > 0 && h > 0)) throw new Error(`ffprobe reported ${stdout.trim() || "nothing"}`); return { width: w, height: h }; } catch (err) { const detail = String(err?.stderr ?? "").trim() || err?.message || String(err); throw new Error(`${id}: cannot read the dimensions of ${file}\n${detail}`); } } // =========================================================================== // The claim rail // =========================================================================== // A persistent vertical ledger down the right edge, appending one row per claim // as the video runs. It is folded into the concat pass rather than added as a // second encode: the chain attaches AFTER the final xfade node, which already // has post-pass semantics (nothing downstream of the last xfade is dissolved, // and `t` there is absolute and continuous from 0). A separate pass would // re-quantize crf-20 output, and antialiased text on flat colour is exactly the // content that costs most. // // Five hard-won rules are load-bearing here; breaking any one produces a hang, // a silently wrong-length file or a frozen overlay: // // 1. crop's w/h are CONFIG-TIME (`t` is undefined there) but x/y are // per-frame. So every moving part is a fixed-size window walking a strip. // 2. crop clamps x/y into range, so over-scroll is safe and self-parking. // 3. A PNG on a plain -i through an animated crop is FROZEN. Every strip // needs `-loop 1 -framerate -t `. // 4. An UNBOUNDED `-loop 1` input deadlocks ffmpeg once several are chained. // Hence `-t` on all five. // 5. An overlay secondary longer than the main EXTENDS the output. The strips // are deliberately longer (bound = total + 2), so `shortest=1` is required // on EVERY rail overlay, not just the first. // // And two rendering ones: overlay's default `format=yuv420` subsamples alpha as // well as chroma, which fringes 14–23 px rail text — so every rail overlay is // `format=yuv444`, with a single `format=yuv420p` before the encoder. /** * Fill an evenly-spaced schedule between known anchors. * * `known` holds the entries that are pinned to a segment; everything else is * distributed linearly between its neighbouring pins, with `lo`/`hi` acting as * virtual anchors just outside the run. */ function distribute(known, n, lo, hi) { const at = new Array(n).fill(null); for (const [i, t] of known) at[i] = t; const pts = [[-1, lo], ...[...known].sort((a, b) => a[0] - b[0]), [n, hi]]; for (let k = 0; k < pts.length - 1; k += 1) { const [a, ta] = pts[k]; const [b, tb] = pts[k + 1]; for (let j = a + 1; j < b; j += 1) at[j] = ta + ((tb - ta) * (j - a)) / (b - a); } return at; } /** * When each ledger row appears, in finished-timeline seconds. * * ONE CHRONOLOGY. The cut plays in date order across every company, and the * ledger is sorted the same way, so a claim's position in the rail IS its * position in time. That collapses what used to live here: there is no longer a * per-company span to bound a claim to, no contiguity rule to enforce, and no * risk of scheduling a media claim over a coffee clip -- because "over a coffee * clip" now means "later in the same chronology", which is exactly right. * * What remains is the part that was always doing the work: a claim WITH a clip * behind it is pinned to that clip's segment, and the rest are spread evenly * between their neighbouring pins. Monotonicity holds by construction, since * both the pins and the rows are in date order. * * Every state change lands at `starts[i] + D/2` -- MID-DISSOLVE -- where the * picture is already crossfading and a ±3-frame error is invisible. */ export function scheduleClaims(ledger, entries, starts, D, endBound) { const segOf = new Map(entries.map((e, i) => [e.id, i])); const mid = (seg) => starts[seg] + D / 2; // A stacked ledger card carries several claims, and each one has a MOMENT // inside that card: the reveal of its own row. Pinning all of them to the // segment's mid-dissolve would land four rail rows on one frame and, worse, // break the pin-order guard's strict monotonicity for no reason. So a claim // on such a card is pinned to its own row's reveal. const within = new Map(); for (const e of entries) { if (e.type !== "ledger") continue; (e.claims ?? []).forEach((cid, r) => within.set(`${e.id}|${cid}`, ledgerRevealAt(r))); } const known = new Map(); ledger.forEach((c, i) => { const seg = c.entryId ? segOf.get(c.entryId) : undefined; if (seg === undefined) return; const off = within.get(`${c.entryId}|${c.id}`); known.set(i, off === undefined ? mid(seg) : starts[seg] + off); }); if (!known.size) throw new Error("ledger: no claim is pinned to a clip, so nothing anchors the rail"); // A pin that runs backwards means the ledger and the timeline disagree about // the order of events, which is a manifest bug rather than something to // silently smooth over -- the whole cut rests on the two agreeing. const pins = [...known].sort((a, b) => a[0] - b[0]); for (let i = 1; i < pins.length; i += 1) { if (pins[i][1] <= pins[i - 1][1]) { throw new Error( `ledger: ${ledger[pins[i][0]].id} is pinned to ${ledger[pins[i][0]].entryId}, which plays ` + `before ${ledger[pins[i - 1][0]].id}'s clip — the ledger is not in the cut's order`, ); } } // The first card is the title; the rail's own run opens just after it. const times = distribute(known, ledger.length, mid(0), endBound); for (let i = 1; i < times.length; i += 1) { if (times[i] <= times[i - 1]) times[i] = times[i - 1] + 1 / 30; } return times; } /** * The rail's filtergraph, as one builder with two call sites — the concat pass * and `--rail-only` — so the two paths cannot drift. * * Ramps are CUMULATIVE AND SATURATING, never gated. A piecewise sum of * `gte(t,s)*lt(t,s')*…` terms flashes to y=0 for one frame at any boundary gap, * because every gate evaluates false at once and the sum collapses. Terms that * rise to their delta and stay there cannot do that. */ export function railFilterChain(rail, assets, times, render, inLabel, firstInputIdx, bound, opts = {}) { const g = assets.geom; const SLIDE = rail.slide ?? 0.55; const fps = render.fps; const P = (s) => `clip((t-${s.toFixed(3)})/${SLIDE},0,1)`; // smoothstep() does not exist in ffmpeg's expression language. This is it. const ease = (s) => { const p = P(s); return `${p}*${p}*(3-2*${p})`; }; // Signed, and explicitly so. Joining terms with "+" was fine while every // delta was a positive row height; a rolling cell FALLS as often as it rises, // and `…+-40*x` is at best relying on ffmpeg's unary minus. const sum = (y0, terms) => terms.reduce( (acc, t) => `${acc}${t.d < 0 ? "-" : "+"}${Math.abs(t.d)}*${t.f}`, String(y0), ); const ramp = (y0, steps) => sum(y0, steps.filter((st) => st.delta !== 0).map((st) => ({ d: st.delta, f: ease(st.at) }))); const { K, ROWH, RW, RX, RTOP, LOGH, LOGTOP, TALLYTOP } = g; const logY = ramp(0, times.map((t, i) => ({ at: t, delta: i + 1 > K ? ROWH : 0 }))); // The curtain and the log MUST share the same eased P, or the curtain visibly // lags the rows mid-slide and unrevealed claims flash into view. const curtainY = ramp(LOGTOP, times.map((t, i) => ({ at: t, delta: i + 1 <= K ? ROWH : 0 }))); const hlY = ramp(LOGTOP, times.map((t, i) => ({ at: t, delta: i > 0 && i < K ? ROWH : 0 }))); /** * One lane's y, in the strip's own pixels. * * y(t) = r0 + Σ_k [ (a_k − b_{k−1})·gte(t,t_k) + (b_k − a_k)·ease(t_k) ] * * The first term is the instantaneous reposition to the next pair's starting * row; the second is the roll itself. Both are CUMULATIVE AND SATURATING, * which is the rail's hard rule: a gated piecewise sum flashes to y=0 for one * frame at any boundary gap, because every gate goes false at once. */ const laneY = (lane) => { const terms = []; let prevB = 0; lane.steps.forEach((st, i) => { if (!st) return; const at = times[i]; const jump = (st.a - prevB) * lane.cellH; const roll = (st.b - st.a) * lane.cellH; if (jump !== 0) terms.push({ d: jump, f: `gte(t,${at.toFixed(3)})` }); if (roll !== 0) terms.push({ d: roll, f: ease(at) }); prevB = st.b; }); return sum(0, terms); }; // The rail leaves by SLIDING OFF to the right, not by an enable= pop. One // offset expression shared by every overlay, so the column moves as one // object; `overlay`'s x is per-frame in `t`, which is what makes that // possible at all. Cumulative and saturating, like everything else here. const hideAt = opts.hideAt ?? null; const OFF = hideAt == null ? "" : `+${RW + 8}*${ease(hideAt)}`; const X = (x) => (OFF ? `'${x}${OFF}'` : String(x)); const i0 = firstInputIdx; const files = [assets.chrome, assets.log, assets.curtain, assets.hl, assets.tally]; if (assets.qr) files.push(assets.qr.path); const inputs = files.flatMap((f) => [ "-loop", "1", "-framerate", String(fps), "-t", bound.toFixed(3), "-i", f, ]); const chain = [ `[${i0 + 1}:v]crop=w=${RW}:h=${LOGH}:x=0:y='${logY}'[rlog]`, `${inLabel}[${i0}:v]overlay=x=${X(RX)}:y=${RTOP}:format=yuv444:shortest=1[rr0]`, `[rr0][rlog]overlay=x=${X(RX)}:y=${LOGTOP}:format=yuv444:shortest=1[rr1]`, // The highlight goes UNDER the curtain: while the list is still filling, the // row it marks has not been revealed yet, and the curtain is what hides it. `[rr1][${i0 + 3}:v]overlay=x=${X(RX)}:y='${hlY}':format=yuv444:shortest=1[rr2]`, `[rr2][${i0 + 2}:v]overlay=x=${X(RX)}:y='${curtainY}':format=yuv444:shortest=1[rr3]`, ]; // One crop per lane out of the SINGLE tally strip. Four numbers that roll // independently and a roster line that mostly does not, for one more input // than the slab cost. // // `split` first, and it is NOT optional: a filtergraph link may be consumed // exactly once, so five crops reading `[N:v]` is a parse error, not a // shortcut. This is the whole reason the lanes share one PNG and still cost // one input. chain.push( `[${i0 + 4}:v]split=${assets.lanes.length}${assets.lanes.map((_, j) => `[ts${j}]`).join("")}`, ); let lab = "[rr3]"; assets.lanes.forEach((lane, j) => { const isRoster = lane.kind === "roster"; const h = isRoster ? g.ROSTERH : g.TALLYROWH; const y = isRoster ? g.ROSTERTOP : TALLYTOP + j * g.TALLYROWH; const x = isRoster ? RX + g.ROSTERX : RX + g.CELLX; chain.push( `[ts${j}]crop=w=${lane.w}:h=${h}:x=${lane.x}:y='${laneY(lane)}'[rc${j}]`, `${lab}[rc${j}]overlay=x=${X(x)}:y=${y}:format=yuv444:shortest=1[rt${j}]`, ); lab = `[rt${j}]`; }); // The provenance tile LAST, so the parked curtain cannot paint over it. if (assets.qr) { const qrY = sum(0, assets.qr.steps.map((st) => ({ d: st.delta, f: `gte(t,${st.at.toFixed(3)})` }))); chain.push( `[${i0 + 5}:v]crop=w=${g.TILEW}:h=${g.TILEH}:x=0:y='${qrY}'[rqr]`, `${lab}[rqr]overlay=x=${X(RX + g.PAD)}:y=${g.TILETOP}:format=yuv444:shortest=1[rq]`, ); lab = "[rq]"; } chain.push(`${lab}format=yuv420p[vout]`); return { inputs, chain: chain.join(";"), outLabel: "[vout]" }; } /** * The chrome as PNG-sequence overlays, for `render.chromeEngine: "hyperframes"`. * * OPT-IN, and absent it nothing below runs -- the ffmpeg chrome path is left * byte-for-byte alone, which is the same bargain the rail was added under. * * The five ffmpeg traps the rail documents apply here unchanged, and two of them * bite harder with an image sequence: * * * `format=yuv444` on EVERY overlay. overlay's default yuv420 subsamples * ALPHA as well as chroma, which fringes small text -- and the band is * nothing but small text. * * `shortest=1` on EVERY overlay. A secondary longer than the main EXTENDS * the output; the sequence is rendered to the same length as the concat, but * a one-frame rounding difference either way must not change the duration. * * One `format=yuv420p` before the encoder, once, at the end. * * A finite image sequence needs no `-t`: unlike `-loop 1` it ends by itself, so * the deadlock the rail's five chained loops hit cannot happen here. */ export function chromeOverlayChain(render, regions, inLabel, firstInputIdx, opts = {}) { const { outLabel = "[hfout]", final = true } = opts; const inputs = []; const parts = []; let lab = inLabel; regions.forEach((r, i) => { // The deck's sequence is MIXED: HyperFrames writes a frame with nothing // transparent in it as RGB and every other frame as RGBA, so the decoded // stream changes pixel format part-way through. By default ffmpeg answers // that by REINITIALISING THE WHOLE filtergraph -- every xfade with it -- // which drops what was buffered and ends the output early (measured: a // 3.5 s xfade+overlay came out 2.0 s). `-reinit_filter 0` keeps the graph // and converts the odd frames instead, and `format=rgba` straight after // the input pins the overlay's secondary to one format with alpha // whichever kind of frame comes first. The `format` filter alone does not // stop the reinit. The deck only: the chart band's chain and inputs stay // byte-for-byte as they shipped. // // A posts window (`name: "posts"`) is a SHORT sequence laid over the cut at // its own second: `-itsoffset` puts its frame 1 at `offset` (negative in a // preview whose clock starts after the window does), and the overlay // passes the main frames through untouched before it starts and after it // ends (`eof_action=pass`). NOT `shortest=1`, which would end the whole // cut when the window ends. A window is never longer than the cut // (snapWindow), and nothing past the main's end is drawn: the deck's // `shortest=1` overlay ahead of it already ends the stream there. Same // frame-kind trap as the deck, same two guards. // The posts feed (`name: "feed"`) and the fact-check stamps (`name: // "stamp"`) are whole-cut sequences like the deck's, laid exactly as the // deck's is. const deck = r.name === "deck" || r.name === "feed" || r.name === "stamp" || r.name === "threads" || r.name === "flips"; const posts = r.name === "posts"; inputs.push( ...(deck || posts ? ["-reinit_filter", "0"] : []), ...(posts ? ["-itsoffset", offsetArg(r.offset)] : []), "-framerate", String(render.fps), "-start_number", "1", "-i", path.join(r.frames, "frame_%06d.png"), ); const idx = firstInputIdx + i; const last = i === regions.length - 1; const out = last && !final ? outLabel : `[hf${i}]`; let src = `[${idx}:v]`; if (deck || posts) { parts.push(`${src}format=rgba[hfa${i}]`); src = `[hfa${i}]`; } parts.push(`${lab}${src}overlay=x=${r.x}:y=${r.y}:format=yuv444:${posts ? "eof_action=pass" : "shortest=1"}${out}`); lab = out; }); if (final) parts.push(`${lab}format=yuv420p[vout]`); return { inputs, chain: parts.join(";"), outLabel: final ? "[vout]" : outLabel, count: regions.length, }; } /** A window's start, as ffmpeg's `-itsoffset` reads it: seconds, to the microsecond. */ const offsetArg = (v) => { const s = (Math.round(Number(v) * 1e6) / 1e6).toFixed(6); return s === "-0.000000" ? "0.000000" : s; }; /** * The posts windows as overlay regions: one per clip that carries posts * (`postWindows`), snapped to the frame grid (`snapWindow`), at * `postsGeometry`. `offset` is the second the window's frame 1 lands on in the * BASE's clock -- the cut's, or a preview's that starts `shift` seconds in. * `clip` (`{at, dur}`) keeps only the windows that intersect it. No posts, no * regions: the deck's overlay is then exactly what it was. */ export function postsRegions(render, outDir, schedule, { shift = 0, clip = null } = {}) { const fps = Number(schedule.fps ?? render.fps); const g = postsGeometry(render); return postWindows(schedule) .map((w) => ({ w, s: snapWindow(w, { fps, total: schedule.total }) })) .filter(({ s }) => !clip || (s.from < clip.at + clip.dur && s.to > clip.at)) .map(({ w, s }) => ({ name: "posts", segment: s.segment, window: w, frames: path.join(outDir, "chrome", `posts-${s.segment}-frames`), x: g.x, y: g.y, width: g.width, height: g.height, offset: s.from - shift, frameCount: s.frames, })); } /** The posts feed as an overlay region: its frames at feedGeometry's column. */ export function feedRegion(render, frames) { return { name: "feed", frames, ...feedGeometry(render).column }; } /** The thread rail as an overlay region: its frames at threadsGeometry's box. */ export function threadsRegion(render, frames, name = "threads") { const { cards: _c, ...box } = threadsGeometry(render); return { name, frames, ...box }; } /** The fact-check stamps as an overlay region: their frames at stampGeometry's box. */ export function stampRegion(render, frames, schedule) { return { name: "stamp", frames, ...stampGeometry(render, { feed: schedule.layout === "feed" }) }; } /** * Where each rendered chrome region sits in the frame. * * The chart band REPLACES the footer node track rather than joining it: the * track only moved at section handovers, which is precisely the fault the band * exists to fix. So it takes the footer's ground and 100px more of it, and the * picture loses that height. * * The deck is one region, full width, at the bottom of the frame -- where its * framing left plain ground (`deckGeometry().deck`). It replaces the chart * band's branch rather than joining it: the deck refuses `chromeEngine`. */ export function chromeRegions(render, outDir) { if (deckOn(render)) { const { deck } = deckGeometry(render); return [{ name: "deck", frames: path.join(outDir, "chrome", "deck-frames"), ...deck }]; } const H = render.chart?.height ?? 200; return [ { name: "chart", frames: path.join(outDir, "chrome", "chart-frames"), x: 0, y: render.height - H, width: contentWidth(render), height: H, }, ]; } /** * The footer's stand-in when the chrome is drawn in a browser. * * It reserves the band's HEIGHT and draws nothing, so every segment letterboxes * to the same picture box the overlay expects and the ground under the band is * the palette background. `footer: null` is what switches the whole ffmpeg * footer -- image, marker and fill bar -- off; the degenerate shape is the one * renderFooterAssets already returns for a manifest with no nodes, so this path * is not new. */ function reservedFooter(render) { return { footer: null, marker: null, bar: null, trackLen: 0, footerHeight: render.chart?.height ?? 200, trackY: 0, xs: [], x0: 0, markerRadius: 0, }; } /** * The footer's stand-in under the deck: none at all. * * The deck draws the position in the cut itself (its pips), and the segments * frame into the deck's own box rather than letterboxing above a footer, so * nothing reserves the footer's rows -- `footerHeight: 0` -- and nothing of the * ffmpeg footer is rendered. The shape is reservedFooter()'s, and the one * renderFooterAssets returns for a manifest with no nodes. */ function deckFooter() { return { footer: null, marker: null, bar: null, trackLen: 0, footerHeight: 0, trackY: 0, xs: [], x0: 0, markerRadius: 0, }; } /** * Everything the rail chain needs that depends on the built segments. Returns * null when the manifest does not ask for a rail — which is what keeps this * whole feature opt-in and every existing report byte-for-byte unchanged. */ async function buildRailPlan(manifest, render, entries, segments, D, outDir) { const rail = render.rail; if (!rail || !manifest.ledger?.length) return null; const { starts, total } = await segmentOffsets(segments, D, render.fps); const assets = await renderRailAssets( render, manifest.ledger, outDir, entries, manifest.provenance, ); // Every claim must be on the board before the closing ledger scroll reads it // back, so the last section's spare rows are spread up to that segment. const endIdx = entries.findIndex((e) => e.type === "scroll" || e.type === "chart"); const endBound = endIdx > 0 ? starts[endIdx] : total; const times = scheduleClaims(manifest.ledger, entries, starts, D, endBound); // The QR tile changes at the MID-DISSOLVE of every segment, instantaneously // — a code that eased into place would spend the ease unscannable, and the // picture is already crossfading there. if (assets.qr) { assets.qr.steps = entries.slice(1).map((_, i) => ({ at: starts[i + 1] + D / 2, delta: assets.geom.TILEH, })); } // Where the rail leaves. The closing ledger is a full-width card and the rail // is the one thing on screen it would have to be read around, so the column // slides off over that card's dissolve and does not come back. const hideIdx = entries.findIndex((e) => e.hideRail); const hideAt = hideIdx > 0 ? starts[hideIdx] : null; // The schedule, written down. // // The chart band has to sweep in step with the rail -- a playhead that tracks // the current moment is the whole point of it -- and the only way it and the // rail can be guaranteed to agree is for one of them to compute the schedule // and the other to READ it. Recomputing from segment durations would be a // second implementation of segmentOffsets(), and it would drift the first time // the crossfade changed. Same rule as widen(): imported, never reimplemented. await writeFile( path.join(outDir, "schedule.json"), JSON.stringify( { fps: render.fps, transition: D, total, endBound, segments: entries.map((e, i) => ({ id: e.id, type: e.type, start: starts[i] })), claims: manifest.ledger.map((c, i) => ({ id: c.id, at: times[i], entryId: c.entryId ?? null })), }, null, 2, ) + "\n", ); EMIT("note", { message: `rail: ${manifest.ledger.length} claims, ${times.filter((_, i) => manifest.ledger[i].entryId).length} pinned, ` + `window ${assets.geom.K} rows` + (hideAt == null ? "" : `, hides at ${hideAt.toFixed(1)}s`), }); return { assets, times, total, hideAt }; } // ---- the end sequence ---------------------------------------------------- // Two segment kinds that exist only to close the cut: the whole ledger read // back in one scroll, then the same claims plotted. Both go through the SAME // `fps=,setsar=1` and the SAME encodeArgs as every other segment — xfade // rejects a mismatched link with "First input link parameters do not match", // which would surface only at concat time, after every fetch has been paid for. async function buildScrollSegment(card, render, outDir, ledger) { const { path: png, contentHeight, width: VW } = await renderScrollCard(card, render, ledger, outDir); const seg = path.join(outDir, "segments", `${card.id}.mp4`); const pal = render.palette; const { width, height } = render; const HH = render.headerHeight ?? 56; // The band's height, not the manifest's footerHeight — otherwise the last // 100px of the scroll play underneath the chart. const FH = reservedFooterHeight(render); const winH = height - HH - FH; const dur = String(card.seconds); // clip() buys a free hold at BOTH ends, and crop's own clamping degrades an // off-by-a-few contentHeight into a static last frame rather than an error. const hold = card.hold ?? 2.0; const travel = Math.max(0, contentHeight - winH); const denom = Math.max(0.1, card.seconds - 2 * hold); await execFileP( FFMPEG, [ "-nostdin", "-v", "error", "-y", "-loop", "1", "-framerate", String(render.fps), "-t", dur, "-i", png, "-f", "lavfi", "-t", dur, "-i", `color=c=${pal.bg}:s=${width}x${height}:r=${render.fps}`, "-f", "lavfi", "-t", dur, "-i", `anullsrc=channel_layout=stereo:sample_rate=${render.audioRate}`, "-filter_complex", [ `[0:v]crop=w=${VW}:h=${winH}:x=0:y='${travel}*clip((t-${hold})/${denom.toFixed(3)},0,1)'[win]`, `[1:v][win]overlay=x=0:y=${HH}:shortest=1,fps=${render.fps},setsar=1[v]`, ].join(";"), "-map", "[v]", "-map", "2:a", ...encodeArgs(render), "-shortest", seg, ], { maxBuffer: 1 << 24 }, ); return seg; } /** * A stacked ledger card: rows revealed in sequence by a walking curtain. * * The curtain is an opaque `pal.bg` rectangle that starts covering every row * and steps down one row-height per reveal. Same device as the rail's, and for * the same reason: the card ground is flat, so an opaque rectangle over it is * an exact in-place wipe with no per-pixel filter. * * The ramp is CUMULATIVE AND SATURATING, like every other ramp here. */ async function buildLedgerSegment(card, render, outDir, ledger, avail) { const geo = await renderLedgerCard(card, render, ledger, outDir, avail); const seg = path.join(outDir, "segments", `${card.id}.mp4`); const pal = render.palette; const { width, height } = render; // `seconds` is DERIVED, not authored: the pins that land claims on their own // rows read the same clock, so a hand-set duration would silently move them. const dur = String(card.seconds ?? ledgerSeconds(geo.rows)); const curtainH = height; const curtain = path.join(outDir, "cards", `${card.id}.curtain.png`); await execFileP("magick", [ "-size", `${geo.width}x${curtainH}`, `xc:${pal.bg}`, curtain, ]); const SLIDE = render.rail?.slide ?? 0.55; const ease = (at) => { const p = `clip((t-${at.toFixed(3)})/${SLIDE},0,1)`; return `${p}*${p}*(3-2*${p})`; }; const y = [ String(geo.rowsTop), ...Array.from({ length: geo.rows }, (_, r) => `${geo.rowHeight}*${ease(ledgerRevealAt(r))}`), ].join("+"); await execFileP( FFMPEG, [ "-nostdin", "-v", "error", "-y", "-f", "lavfi", "-t", dur, "-i", `color=c=${pal.bg}:s=${width}x${height}:r=${render.fps}`, "-loop", "1", "-framerate", String(render.fps), "-t", dur, "-i", geo.path, "-loop", "1", "-framerate", String(render.fps), "-t", dur, "-i", curtain, "-f", "lavfi", "-t", dur, "-i", `anullsrc=channel_layout=stereo:sample_rate=${render.audioRate}`, "-filter_complex", [ `[0:v][1:v]overlay=x=0:y=0:shortest=1[a]`, `[a][2:v]overlay=x=0:y='${y}':shortest=1,fps=${render.fps},setsar=1[v]`, ].join(";"), "-map", "[v]", "-map", "3:a", ...encodeArgs(render), "-shortest", seg, ], { maxBuffer: 1 << 24 }, ); return seg; } async function buildChartSegment(card, render, outDir, ledger) { const chart = await renderChartCard(card, render, ledger, outDir); const seg = path.join(outDir, "segments", `${card.id}.mp4`); const pal = render.palette; const { width, height } = render; const VW = cardWidth(card, render); const dur = String(card.seconds); // The wipe CANNOT be `crop=w=''` — crop's w is config-time and `t` is // undefined there ("Error when evaluating the expression"). So: overlay the // finished chart, then slide an opaque pal.bg rectangle rightwards off it. // The card ground is flat pal.bg, so this is an exact in-place wipe with no // per-pixel filter, and it draws the plot in like a plotter. // `hold: true` -- the closing chart is a HOLD, not a reveal. // // The wipe existed because this card was the first and only time the viewer // saw the numbers plotted. With the chart band drawing live under the whole // cut, wiping it in again would re-tell a story the viewer has just watched // happen. So the card opens on the finished plot and the seconds go to // reading the final gap and its flags instead. if (card.hold) { await execFileP( FFMPEG, [ "-nostdin", "-v", "error", "-y", "-f", "lavfi", "-t", dur, "-i", `color=c=${pal.bg}:s=${width}x${height}:r=${render.fps}`, "-loop", "1", "-framerate", String(render.fps), "-t", dur, "-i", chart.path, "-f", "lavfi", "-t", dur, "-i", `anullsrc=channel_layout=stereo:sample_rate=${render.audioRate}`, "-filter_complex", `[0:v][1:v]overlay=x=0:y=0:shortest=1,fps=${render.fps},setsar=1[v]`, "-map", "[v]", "-map", "2:a", ...encodeArgs(render), "-shortest", seg, ], { maxBuffer: 1 << 24 }, ); return seg; } const wipeW = VW - chart.plotX; const wipe = path.join(outDir, "cards", `${card.id}.wipe.png`); await execFileP("magick", [ "-size", `${wipeW}x${Math.round(chart.plotH)}`, `xc:${pal.bg}`, wipe, ]); const wipeStart = card.wipeStart ?? 0.8; const wipeDur = card.wipeSeconds ?? Math.max(1, card.seconds - wipeStart - 3.0); await execFileP( FFMPEG, [ "-nostdin", "-v", "error", "-y", "-f", "lavfi", "-t", dur, "-i", `color=c=${pal.bg}:s=${width}x${height}:r=${render.fps}`, "-loop", "1", "-framerate", String(render.fps), "-t", dur, "-i", chart.path, "-loop", "1", "-framerate", String(render.fps), "-t", dur, "-i", wipe, "-f", "lavfi", "-t", dur, "-i", `anullsrc=channel_layout=stereo:sample_rate=${render.audioRate}`, "-filter_complex", [ `[0:v][1:v]overlay=x=0:y=0:shortest=1[a]`, `[a][2:v]overlay=x='${chart.plotX}+${wipeW}*clip((t-${wipeStart})/${wipeDur.toFixed(3)},0,1)'` + `:y=${Math.round(chart.plotY)}:shortest=1,fps=${render.fps},setsar=1[v]`, ].join(";"), "-map", "[v]", "-map", "3:a", ...encodeArgs(render), "-shortest", seg, ], { maxBuffer: 1 << 24 }, ); return seg; } // ---- room for posts: the hold and the footage move, where the cut is joined -- // A clip that carries posts is HELD on its last frame (`segments[i].hold` in // the deck's schedule) and its footage MOVES aside as its first post appears // (`schedule.moves`). Both happen on that segment's input chain, before the // join -- never in the segment file -- so `--chrome-only` changes them // without rebuilding a clip, and every other input's chain is exactly what it // was. A cut without posts has no joins (`segmentJoins` returns null) and // every line below behaves as it always did. /** * Per-input join work from the deck's schedule, aligned with its segments: * `{ hold, move }` for a carrying clip, null for every other; null overall when * no segment has either -- the switch that keeps a cut without posts on the * paths it always took. * * @returns {Array<{ hold: number, move: object|null } | null> | null} */ export function segmentJoins(schedule) { if (!schedule?.segments) return null; const moves = new Map((schedule.moves ?? []).map((m) => [m.segment, m])); const joins = schedule.segments.map((s) => { const hold = s.hold > 0 ? s.hold : 0; const move = moves.get(s.id) ?? null; return hold || move ? { hold, move } : null; }); return joins.some(Boolean) ? joins : null; } /** A number for an ffmpeg expression or option: at most 4 decimals, no trailing zeros. */ const exprNum = (v) => { const s = (Math.round(Number(v) * 1e4) / 1e4).toFixed(4).replace(/\.?0+$/, ""); return s === "-0" ? "0" : s; }; /** * The footage move as one `perspective` filter (destination sense, evaluated * per frame): an affine map of the WHOLE frame that takes the `from` box to * the box eased toward `to`. Each corner of the input frame is placed at * `base + d·e(t)`, where e is smoothstep over [segmentAt, segmentAt + seconds] * in the segment's own clock (its hold included: the move runs after the * `tpad`) and d is where that corner has gone at e = 1 -- * scale `to.width / from.width` (and height), then translate. Before the move * e = 0 and the map is the identity, which perspective copies bit for bit; * after it e = 1 and the frame holds at `to`. * * Perspective resamples at 1/256 px, so the box glides with no whole-pixel * stepping (scale + overlay and zoompan both round to whole pixels). It has no * `t`, and its `in` counts frames from 1 -- hence `(in-1)/fps`. What the * shrink uncovers is filled from the input's edge (perspective clamps), which * the deck framing made `palette.bg`; `fillborders` pins the outermost pixels * to it exactly, so a coding artefact at the edge cannot be smeared across * the uncovered band. */ export function moveFilter(move, render) { const { from: F, to: T } = move; const kx = T.width / F.width - 1; const ky = T.height / F.height - 1; const dx = (u) => T.x - F.x + (u - F.x) * kx; const dy = (v) => T.y - F.y + (v - F.y) * ky; const W = render.width ?? 1920; const H = render.height ?? 1080; const t = `(in-1)/${render.fps}`; const a = exprNum(move.segmentAt); const p = move.seconds > 0 ? `clip((${t}-${a})/${exprNum(move.seconds)},0,1)` : `gte(${t},${a})`; // smoothstep, 3p² − 2p³: flat at both ends, so the glide starts and lands without a jolt. const e = (base, d) => `'st(0,${p});${base}+(${exprNum(d)})*ld(0)*ld(0)*(3-2*ld(0))'`; const corners = [ ["x0", e("0", dx(0))], ["y0", e("0", dy(0))], ["x1", e("W", dx(W))], ["y1", e("0", dy(0))], ["x2", e("0", dx(0))], ["y2", e("H", dy(H))], ["x3", e("W", dx(W))], ["y3", e("H", dy(H))], ]; return [ `fillborders=left=2:right=2:top=2:bottom=2:mode=fixed:color=${render.palette.bg}`, `perspective=${corners.map(([k, v]) => `${k}=${v}`).join(":")}:interpolation=linear:sense=destination:eval=frame`, ].join(","); } /** The hold on a segment's picture: its last frame, cloned for `hold` seconds. */ export const holdVideoFilter = (hold) => `tpad=stop_mode=clone:stop_duration=${exprNum(hold)}`; /** The hold on its sound: silence, for the same `hold` seconds. */ 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. * * A fade longer than the segment is the segment: n is clamped to lastFrame * (`endFadeFrames`), so the fade runs from its first frame and its last is * still exactly bg. Unclamped, the weight's denominator stayed n while s * clamped to 0, and the last frame of a 3 s segment under `endFade: 5` was * only 59 % of the way there while the sound did reach silence. */ export const endFadeFrames = (fade, fps) => Math.max(1, Math.min(Math.round(fade.seconds * fps), fade.lastFrame)); export const endFadeVideoFilter = (fade, render) => { const fps = render.fps; const n = endFadeFrames(fade, 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})'`; }; /** The sound's half: the same frames as the picture's, so the two end together. */ export const endFadeAudioFilter = (fade, render) => { const end = fade.lastFrame / render.fps; const st = Math.max(0, end - endFadeFrames(fade, render.fps) / render.fps); 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. * * The move goes AFTER the hold: its clock (`in`) then counts the held frames * too, so a first post that appears inside the hold -- or so late that the * glide runs past the clip's own last frame -- still moves the footage, the * frozen frame with it, exactly when the schedule (and umtool's preview) say. * Before the hold, such a move never started or froze part-way. * * @returns {{ parts: string[], v: string, a: string }} */ export function joinInputChain(i, join, render) { if (!join) return { parts: [], v: `[${i}:v]`, a: `[${i}:a]` }; const parts = []; // Picture: hold, move, end fade. Sound: mute, hold, end fade, dip -- the // mute is in the clip's own clock and the hold is silence anyway; the end // fade (the last segment) and the dip (one before a teaser that dips) are // last, 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 af = [ join.mute != null ? muteAudioFilter(join.mute) : null, join.hold > 0 ? holdAudioFilter(join.hold) : null, join.fade ? endFadeAudioFilter(join.fade, render) : null, // A dip into the next segment: the sound's half, the end fade's arithmetic. // Its picture is the whole frame's, made after the overlays (dipWindows). join.dip ? endFadeAudioFilter(join.dip, 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), the end fade on the LAST segment * (`fade`: `{ seconds, lastFrame }`) and a dip on the segment BEFORE a teaser * that dips (`dips`: segment index → `{ seconds, lastFrame, black }`). A join * gains `mute` / `fade` / `dip` only when it has one, so a cut without any * 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, dips = new Map() } = {}) { if (!mutes.size && !fade && !dips.size) 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 }; for (const [i, dip] of dips) out[i] = { hold: 0, move: null, ...(out[i] ?? {}), dip }; 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, `render.endFade` on * the last segment, and each teaser's `dip` on the segment before it. 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 seconds = await probeDuration(segments[i], render.fps); const m = muteSegmentSeconds({ entry: e, record: await readCutRecord(segments[i]), render, seconds }); if (m.note) EMIT("note", { id: e.id, message: m.note }); const from = m.source === "record" ? "from its cut record" : "from the unsnapped start"; // Validation allows the clip's whole extent, but only the played window is // in the segment: a mark past the cut's end mutes nothing, and saying // "muted from" would claim otherwise. EMIT("note", { id: e.id, message: m.at >= seconds ? `${e.id}: muteFrom ${e.muteFrom} will not be heard -- it lies ${m.at}s into a segment ${seconds}s long, past the cut's end (${from})` : `${e.id}: muted from ${m.at}s into its segment (muteFrom ${e.muteFrom}, ${from})`, }); mutes.set(i, m.at); } // A segment's frames in the cut, its hold's clones included. const cutFrames = async (i) => Math.round((await probeDuration(segments[i], render.fps)) * render.fps) + Math.round((base?.[i]?.hold ?? 0) * render.fps); const seconds = endFadeOf(render); let fade = null; if (seconds > 0 && segments.length) { const last = segments.length - 1; fade = { seconds, lastFrame: (await cutFrames(last)) - 1 }; } // A teaser's dip fades the segment before it, over its last `fade` seconds. const dips = new Map(); for (let i = 1; i < entries.length && i < segments.length; i += 1) { const dip = dipOf(entries[i], render.fps); if (!dip) continue; dips.set(i - 1, { seconds: dip.fade, lastFrame: (await cutFrames(i - 1)) - 1, black: dip.black }); } return withCutEdits(base, segments.length, { mutes, fade, dips }); } /** * Where the cut dips to black, from its joins (`withCutEdits`' `dip`s) and its * segments' lengths in the cut (`cutOffsets`' `durs`, holds included), in the * cut's FRAMES: `s` the last frame untouched, `last` the dipped segment's last * frame -- black, as the end fade's last is bg -- and `until` the first frame * NOT held black: the dissolve's end plus `black`. Frame f in (s, last] is * (f − s)/(last − s) of the way to black; (last, until) is black. Null with * no dip, so every graph is the one it always was. * * The fade's frames are the end fade's (`endFadeFrames`: clamped to the * segment), so the picture and the sound -- `endFadeAudioFilter` on the same * join -- end together; the picture is `dipVideoFilter`'s. The teaser after it starts its dissolve inside the * fade, black (its lead, `teaserLead`), and stays black past `until`. * * @returns {Array<{ segment: number, s: number, last: number, until: number }> | null} */ export function dipWindows(joins, durs, D, fps) { if (!joins?.some((j) => j?.dip)) return null; const { starts } = scheduleFrom(durs, D); const out = []; joins.forEach((j, i) => { if (!j?.dip) return; const last = Math.round(starts[i] * fps) + j.dip.lastFrame; const s = last - endFadeFrames(j.dip, fps); out.push({ segment: i, s, last, until: last + 1 + Math.round(j.dip.black * fps) }); }); return out; } /** * The dips as one filter chain on the FINISHED picture -- after every overlay * (deck, feed, posts, rail), so the whole frame goes to black, not the footage * under a lit panel. `fade` out to BLACK, which ffmpeg does in the stream's * own yuv420p (luma to 16, chroma to 128; only a COLOURED fade needs RGB, the * end fade's reason for `geq`), slice-threaded -- about 25× faster than the * same blend in `geq` at 1080p. It runs from frame `s` (time s/fps) over * (last − s) frames, so `last` is black, and once done it writes black; its * `enable` window is (s, until) by half a frame each side, so every frame * before the fade and from `until` on passes untouched. `shift` is the second * the stream's own clock starts at in the cut's (a preview's window); a * window whose fade began before that is the same blend in `geq`. * * @returns {string|null} a `fade,…` chain, or null with no dips */ export function dipVideoFilter(windows, fps, shift = 0) { if (!windows?.length) return null; const n6 = (v) => (Math.round(v * 1e6) / 1e6).toFixed(6).replace(/\.?0+$/, "") || "0"; return windows.map((w) => { const st = w.s / fps - shift; const d = (w.last - w.s) / fps; const on = `enable='between(t,${n6((w.s + 0.5) / fps - shift)},${n6((w.until - 0.5) / fps - shift)})'`; if (st >= 0) return `fade=t=out:st=${n6(st)}:d=${n6(d)}:${on}`; // A preview that starts inside the fade: `fade` cannot start before its // stream does, so the same blend in `geq` (a few seconds of preview, where // its cost does not matter). const k = `clip((T-(${n6(st)}))/${n6(d)},0,1)`; const plane = (p, c) => `'${p}(X,Y)+(${c}-${p}(X,Y))*${k}+0.5'`; return `geq=lum=${plane("lum", 16)}:cb=${plane("cb", 128)}:cr=${plane("cr", 128)}:${on}`; }).join(","); } /** `inLabel` through the dips to `outLabel`, as graph parts: none when there are no dips. */ export function dipParts(inLabel, windows, fps, { shift = 0, outLabel = "[vdip]" } = {}) { const f = dipVideoFilter(windows, fps, shift); return f ? { parts: [`${inLabel}${f}${outLabel}`], label: outLabel } : { parts: [], label: inLabel }; } /** * 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 * the schedule's starts. Without joins this is segmentOffsets, unchanged. */ export async function cutOffsets(segments, D, fps, joins = null) { const { durs } = await segmentOffsets(segments, D, fps); const full = durs.map((d, i) => d + (joins?.[i]?.hold ?? 0)); return { ...scheduleFrom(full, D), durs: full }; } /** * The crossfade concat's filtergraph from the cut's segment lengths (`durs`, * holds included) and the per-input joins. Without joins, the graph it always was. */ export function xfadeGraph(durs, D, joins = null, render = null, { joined = false } = {}) { const parts = []; const ins = durs.map((d, i) => { // `joined`: the inputs already carry their joins (a batched build's batch // files), so only each join's `dip` is read -- for the transition after it. const c = joinInputChain(i, joined ? null : joins?.[i] ?? null, render); parts.push(...c.parts); // The sound is pinned to the picture's length before it is crossfaded. // `xfade` places segment i+1 by the PICTURE's length, `acrossfade` by the // SOUND's, and an encoded segment's audio is routinely a few to ~20 ms off // its video (AAC frames do not end on video frames). Unpinned, the error // accumulates segment by segment: measured 0.30 s early by the last clip of // a 17-clip cut, 2.0 s on one with title and sources cards. Padded with // silence and trimmed to exactly `d`, every clip's sound starts with its // picture. `d` is the segment's length in the cut (frames ÷ fps, hold // included), the same number the xfade offsets are summed from. const len = d.toFixed(6); const a = `[p${i}a]`; parts.push(`${c.a}apad=whole_dur=${len},atrim=end=${len},asetpts=PTS-STARTPTS${a}`); return { v: c.v, a }; }); let vlab = ins[0].v; let alab = ins[0].a; let acc = durs[0]; for (let i = 1; i < durs.length; i += 1) { const off = acc - D; // Into a teaser that dips, the outgoing segment plays the whole overlap // untouched -- picture (`expr='A'`) and sound (`nofade`) -- and the // teaser takes over at its end, under the dip's black. A dissolve there // would blend the footage with the teaser's black lead and darken it // faster than the deck and feed drawn over it, which only the dip fades. const dip = !!joins?.[i - 1]?.dip; const vx = dip ? "transition=custom:expr='A'" : "transition=fade"; const ax = dip ? "c1=nofade:c2=nofade" : "c1=tri:c2=tri"; parts.push(`${vlab}${ins[i].v}xfade=${vx}:duration=${D}:offset=${off.toFixed(3)}[v${i}]`); parts.push(`${alab}${ins[i].a}acrossfade=d=${D}:${ax}[a${i}]`); vlab = `[v${i}]`; alab = `[a${i}]`; acc = acc + durs[i] - D; } return { parts, vlab, alab }; } /** * A hard-cut concat through the concat FILTER, for a cut whose joins need a * filtergraph (the demuxer's stream copy cannot host one): every input's * chain, then `concat`, one encode at the parameters every segment shares. * `dips` (`dipWindows`) go on the joined picture when this file IS the cut -- * nothing is laid over it after; a base for an overlay pass leaves them to it. */ export function hardCutFilterArgs(segments, joins, render, outPath, dips = null) { const parts = []; const pairs = segments.map((_, i) => { const c = joinInputChain(i, joins?.[i] ?? null, render); parts.push(...c.parts); return `${c.v}${c.a}`; }); parts.push(`${pairs.join("")}concat=n=${segments.length}:v=1:a=1[vc][ac]`); const dp = dipParts("[vc]", dips, render.fps); parts.push(...dp.parts); return [ "-nostdin", "-v", "error", "-y", ...segments.flatMap((s) => ["-i", s]), "-filter_complex", parts.join(";"), "-map", dp.label, "-map", "[ac]", ...encodeArgs(render), outPath, ]; } // Crossfade every segment into the next. This is a full re-encode of the // timeline — the concat demuxer can only stream-copy hard cuts — so --no-xfade // stays available for quick iteration. `joins` (the deck's holds and moves, // `segmentJoins`) go on their inputs before the join; null leaves the graph // exactly as it was. // // A long cut is joined in BATCHES (xfadeBatches): one ffmpeg holding every // segment open at once grows with the segment count -- a 223-segment 1080p // cut reached 8 GB and was killed -- so past `xfadeBatchSize` segments each // run of consecutive segments is crossfaded into a file of its own // (`/xfade-batches/`, cached by key), and the batch files are // crossfaded together, with the chrome, the rail and the dips, in the last // pass. Every join is the same dissolve at the same second either way. export async function concatWithXfade(segments, render, outPath, railPlan, chrome = null, joins = null, { dip = true, workDir = path.dirname(outPath) } = {}) { const D = render.transition ?? 0.5; const { durs } = await cutOffsets(segments, D, render.fps, joins); const plan = xfadeBatches(segments.length, xfadeBatchSize(render)); const batches = plan ? await buildXfadeBatches({ segments, durs, batches: plan, render, joins, dir: path.join(workDir, "xfade-batches") }) : null; await execFileP(FFMPEG, xfadeConcatArgs({ segments, durs, render, outPath, railPlan, chrome, joins, dip, batches }), { maxBuffer: 1 << 26 }); } // ---- batched crossfade ------------------------------------------------------- /** Segments one crossfade pass may join before the cut is joined in batches. */ export const XFADE_BATCH_DEFAULT = 24; /** * The batch size this build uses: `REPORT_VIDEO_XFADE_BATCH` (the machine's * memory is the machine's), else `render.xfadeBatch`, else 24. 0 never batches. */ export function xfadeBatchSize(render = {}, env = process.env) { const fromEnv = env.REPORT_VIDEO_XFADE_BATCH != null && env.REPORT_VIDEO_XFADE_BATCH !== ""; const raw = fromEnv ? env.REPORT_VIDEO_XFADE_BATCH : render?.xfadeBatch; if (raw == null) return XFADE_BATCH_DEFAULT; const n = Number(raw); if (!Number.isInteger(n) || (n !== 0 && n < 2)) { throw new Error( `${fromEnv ? "REPORT_VIDEO_XFADE_BATCH" : "render.xfadeBatch"} must be 0 (never batch) or a whole number ≥ 2, ` + `got ${JSON.stringify(raw)}`, ); } return n; } /** * The batches a crossfade of `n` segments is joined in: null when one pass * does (`n` ≤ `size`, or `size` 0), else consecutive `{ from, to }` ranges * (`to` exclusive) of near-equal length. Neither pass opens more than `size` * inputs while there are at most size² segments; past that the batches grow, * so the join pass stays `size` wide. */ export function xfadeBatches(n, size) { if (!size || n <= size) return null; const count = Math.ceil(n / Math.max(size, Math.ceil(n / size))); return Array.from({ length: count }, (_, b) => ({ from: Math.floor((b * n) / count), to: Math.floor(((b + 1) * n) / count), })); } /** * Each batch's length in the cut: its segments' lengths less one transition * per join inside it. Crossfaded together with the same transition, the * batches start where their first segments do in a single pass, and the cut * is as long: Σ batch − (B − 1)·D = Σ segment − (N − 1)·D. */ export function xfadeBatchLengths(durs, D, batches) { return batches.map(({ from, to }) => { let len = durs[from]; for (let i = from + 1; i < to; i += 1) len = len + durs[i] - D; return len; }); } /** * The join pass's joins, one per batch: only the dissolve OUT of its last * segment matters there (a `dip` before a teaser plays the outgoing picture * untouched); the segments' own chains were applied inside the batch. */ export function xfadeBatchJoins(joins, batches) { const out = batches.map(({ to }) => (joins?.[to - 1]?.dip ? { dip: joins[to - 1].dip } : null)); return out.some(Boolean) ? out : null; } // A batch file is read once more, by the join pass: its picture is the cut's // own encode (codec, preset, crf, frame rate), its sound PCM -- no AAC // generation, and no encoder padding for the join to pin away. const batchEncodeArgs = (render) => [ "-c:v", "libx264", "-preset", render.preset ?? "medium", "-crf", String(render.crf ?? 20), "-pix_fmt", "yuv420p", "-r", String(render.fps), "-c:a", "pcm_s16le", "-ar", String(render.audioRate), "-ac", String(render.audioChannels), ]; /** * One batch's ffmpeg argv: its segments crossfaded exactly as the single pass * crossfades them (xfadeGraph, each segment's joins on its input), no chrome, * no rail, no dips -- those are the join pass's, over the whole cut. */ export function xfadeBatchArgs({ segments, durs, batch, render, joins = null, outPath }) { const D = render.transition ?? 0.5; const { from, to } = batch; const segs = segments.slice(from, to); const js = joins ? joins.slice(from, to) : null; const g = xfadeGraph(durs.slice(from, to), D, js, render); let { vlab } = g; // A batch of one with no chain maps an INPUT's picture, which -map cannot // name as a filter label. if (/^\[\d+:v\]$/.test(vlab)) { g.parts.push(`${vlab}null[bv]`); vlab = "[bv]"; } return [ "-nostdin", "-v", "error", "-y", ...segs.flatMap((s) => ["-i", s]), "-filter_complex", g.parts.join(";"), "-map", vlab, "-map", g.alab, ...batchEncodeArgs(render), outPath, ]; } /** * Crossfade each batch into `dir/batch-.mov`, reusing a file whose key * matches: the key is the batch's whole argv (graph, joins, encode) and each * input's size and mtime, so a rebuilt segment or a changed join redoes only * its own batch, and `--chrome-only` redoes only the join pass. Files of any * other key are removed first. Written under a temporary name and renamed, * so an interrupted batch is never taken for a finished one. * * @returns {{ files: string[], durs: number[], joins: Array|null }} the join pass's inputs */ export async function buildXfadeBatches({ segments, durs, batches, render, joins = null, dir }) { const D = render.transition ?? 0.5; const lens = xfadeBatchLengths(durs, D, batches); await mkdir(dir, { recursive: true }); const planned = []; for (const batch of batches) { const argv = xfadeBatchArgs({ segments, durs, batch, render, joins, outPath: "" }).slice(0, -1); const ins = segments.slice(batch.from, batch.to); const stats = await Promise.all(ins.map(async (s) => { const st = await stat(s); return [path.resolve(s), st.size, st.mtimeMs]; })); const key = createHash("sha256") .update(JSON.stringify({ argv: argv.map((a) => (ins.includes(a) ? path.resolve(a) : a)), stats })) .digest("hex") .slice(0, 16); planned.push(path.join(dir, `batch-${key}.mov`)); } const keep = new Set(planned.map((f) => path.basename(f))); for (const name of await readdir(dir)) { if (!keep.has(name)) await rm(path.join(dir, name), { force: true }); } for (const [b, batch] of batches.entries()) { const file = planned[b]; const label = `crossfade batch ${b + 1}/${batches.length}: segments ${batch.from + 1}–${batch.to}`; const got = (await exists(file)) ? await probeDuration(file, render.fps).catch(() => null) : null; if (got != null && Math.abs(got - lens[b]) <= 1.5 / render.fps) { EMIT("note", { message: `${label} (cached)` }); continue; } EMIT("note", { message: `${label}…` }); const tmp = file.replace(/\.mov$/, ".partial.mov"); await execFileP(FFMPEG, xfadeBatchArgs({ segments, durs, batch, render, joins, outPath: tmp }), { maxBuffer: 1 << 26 }); await assertConcatLength(tmp, lens[b], render.fps, label); await rename(tmp, file); } return { files: planned, durs: lens, joins: xfadeBatchJoins(joins, batches) }; } /** * concatWithXfade's ffmpeg argv, from the cut's segment lengths (`durs`, * `cutOffsets`'): the crossfades, the chrome, the rail, then the dips over all * of it. Pure, so a test can run the very graph the build does. * * `batches` (buildXfadeBatches') makes it the join pass of a batched build: * the batch files are its inputs, crossfaded at their own lengths, while the * dips are still placed from the segments' `durs` and `joins` -- the cut's * clock is the same. */ export function xfadeConcatArgs({ segments, durs, render, outPath, railPlan = null, chrome = null, joins = null, dip = true, batches = null }) { const D = render.transition ?? 0.5; const main = batches ? batches.files : segments; const inputs = main.flatMap((s) => ["-i", s]); const { parts, vlab, alab } = batches ? xfadeGraph(batches.durs, D, batches.joins, render, { joined: true }) : xfadeGraph(durs, D, joins, render); // The rail attaches to the LAST xfade node, so it runs after every dissolve // and sees an absolute, continuous `t`. One encode, not two. // // When the chrome is rendered rather than drawn, the PNG regions go on FIRST // and the rail chain reads their output. Not the other way round: the rail // chain ends in `format=yuv420p`, and overlaying an alpha sequence onto // yuv420p is the fringing trap the rail already documents, one layer later. const chromeIn = chrome ?? null; const railIn = chromeIn ? chromeIn.outLabel : vlab; const rc = railPlan ? railFilterChain( render.rail, railPlan.assets, railPlan.times, render, railIn, main.length, railPlan.total + 2, { hideAt: railPlan.hideAt }, ) : null; const railInputs = rc ? rc.inputs.filter((a) => a === "-i").length : 0; const hf = chromeIn ? chromeOverlayChain(render, chromeIn.regions, vlab, main.length + railInputs, { outLabel: chromeIn.outLabel, final: !rc, }) : null; if (hf) parts.push(hf.chain); if (rc) parts.push(rc.chain); // The dips last, over everything drawn: the whole frame goes to black. // (`dip: false` -- a base the rail is laid over later, which dips then.) const dp = dipParts(rc ? rc.outLabel : hf ? hf.outLabel : vlab, dip ? dipWindows(joins, durs, D, render.fps) : null, render.fps); parts.push(...dp.parts); const tail = dp.label; return [ "-nostdin", "-v", "error", "-y", ...inputs, ...(rc ? rc.inputs : []), ...(hf ? hf.inputs : []), "-filter_complex", parts.join(";"), "-map", tail, "-map", alab, ...encodeArgs(render), outPath, ]; } /** * Run the rail chain over an already-concatenated file. * * Two callers need this. `--rail-only` iterates on the rail in seconds instead * of re-running the whole concat; and `--no-xfade` has no choice, because * concatHardCut is `-c copy` and a stream-copy mux cannot host a filtergraph * at all. */ async function applyRail(inPath, outPath, render, railPlan, preview, dips = null) { const rc = railFilterChain( render.rail, railPlan.assets, railPlan.times, render, preview ? "[base]" : "[0:v]", 1, railPlan.total + 2, { hideAt: railPlan.hideAt }, ); const parts = []; if (preview) { // -ss restarts `t` near zero, which would put every absolute-time ramp in // the wrong place — the rail would look broken while being correct. Shift // the timestamps back to where the expressions think they are, then rebase // them so the preview file still starts at 0. parts.push(`[0:v]setpts=PTS+${preview.start.toFixed(3)}/TB[base]`); } parts.push(rc.chain); // The dips over the rail, in the cut's clock (a preview's is shifted back to it above). const dp = dipParts(rc.outLabel, dips, render.fps); parts.push(...dp.parts); const tail = preview ? "[vshift]" : dp.label; if (preview) parts.push(`${dp.label}setpts=PTS-STARTPTS[vshift]`); await execFileP( FFMPEG, [ "-nostdin", "-v", "error", "-y", ...(preview ? ["-ss", String(preview.start), "-t", String(preview.dur)] : []), "-i", inPath, ...rc.inputs, "-filter_complex", parts.join(";"), "-map", tail, "-map", "0:a", ...encodeArgsVideoOnly(render), outPath, ], { maxBuffer: 1 << 26 }, ); } // ---- the deck's overlay ---------------------------------------------------- // The deck (`render.chrome`) is ONE rendered composition over the whole cut. // With a transition it rides the crossfade's own encode (concatWithXfade's // `chrome` argument); with hard cuts the concat is `-c copy`, which cannot // host a filtergraph, so it is a second pass over the concatenated file -- // applyChrome, below. Either way the overlay chain is chromeOverlayChain's. /** * applyChrome's ffmpeg argv: the rendered regions over an already * concatenated file, one video re-encode, the audio copied. * * `preview` (`{ start, dur }`) cuts a window out of the input. The regions it * is given must then be a WINDOW render (`compose-chrome --from`), whose frame * 1 is cut time `start` -- the base is seeked to the same second and starts at * 0, so the two line up with no timestamp shifting. (The rail's preview has to * shift because its expressions read absolute `t`; a frame sequence has no `t`.) * * The rail is not ported here: the deck refuses `render.rail`, and the chart * band keeps its refusal of `transition: 0`, so nothing that reaches this * function draws one. */ export function applyChromeArgs(inPath, outPath, render, chromePlan, preview = null, dips = null) { const hf = chromeOverlayChain(render, chromePlan.regions, "[0:v]", 1, { final: true }); // The dips over the overlay: the whole frame, the panel with the footage. const dp = dipParts(hf.outLabel, dips, render.fps, { shift: preview ? Number(preview.start) : 0 }); return [ "-nostdin", "-v", "error", "-y", ...(preview ? ["-ss", String(preview.start), "-t", String(preview.dur)] : []), "-i", inPath, ...hf.inputs, "-filter_complex", [hf.chain, ...dp.parts].join(";"), "-map", dp.label, "-map", "0:a", ...encodeArgsVideoOnly(render), outPath, ]; } /** * The chrome over a concatenated file: the hard-cut build's overlay pass, and * `--chrome-preview` over a cached concat. Ported from the diet fork's * applyChrome, whose point stands: the CONCAT cannot host a filtergraph, the * pass after it always could. */ async function applyChrome(inPath, outPath, render, chromePlan, preview = null, dips = null) { await execFileP(FFMPEG, applyChromeArgs(inPath, outPath, render, chromePlan, preview, dips), { maxBuffer: 1 << 26, }); } /** * Which segments a window of the cut touches, and where the window starts in * their own local timeline. `starts`/`durs` are segmentOffsets'; a segment * occupies [starts[i], starts[i] + durs[i]] (crossfades overlap neighbours). */ export function windowSegments(starts, durs, at, dur) { const idx = []; for (let i = 0; i < starts.length; i += 1) { if (starts[i] < at + dur && starts[i] + durs[i] > at) idx.push(i); } if (!idx.length) throw new Error(`no segment covers ${at}s–${at + dur}s`); return { first: idx[0], last: idx[idx.length - 1], offset: at - starts[idx[0]] }; } /** * `--chrome-preview` with no cached concat: the window, built straight from * the few segments it touches -- the SAME xfade arithmetic concatWithXfade * runs over the whole cut (or a plain concat for hard cuts), trimmed to the * window, the window's deck frames over it. Seconds, not a whole-cut encode. */ export function previewFromSegmentsArgs({ segments, durs, starts, D, at, dur, render, chromePlan, outPath, joins = null }) { // `durs`/`starts` are the CUT's (cutOffsets: holds included), and the // window's inputs carry their joins as the full concat's do. const { first, last, offset } = windowSegments(starts, durs, at, dur); const segs = segments.slice(first, last + 1); const ds = durs.slice(first, last + 1); const js = joins ? joins.slice(first, last + 1) : null; let parts = []; let vlab; let alab; if (segs.length > 1 && D > 0) { ({ parts, vlab, alab } = xfadeGraph(ds, D, js, render)); } else { const ins = segs.map((_, i) => joinInputChain(i, js?.[i] ?? null, render)); for (const c of ins) parts.push(...c.parts); vlab = ins[0].v; alab = ins[0].a; if (segs.length > 1) { parts.push(`${ins.map((c) => `${c.v}${c.a}`).join("")}concat=n=${segs.length}:v=1:a=1[vc][ac]`); vlab = "[vc]"; alab = "[ac]"; } } const S = offset.toFixed(3); const T = Number(dur).toFixed(3); parts.push(`${vlab}trim=start=${S}:duration=${T},setpts=PTS-STARTPTS[vw]`); parts.push(`${alab}atrim=start=${S}:duration=${T},asetpts=PTS-STARTPTS[aw]`); const hf = chromeOverlayChain(render, chromePlan.regions, "[vw]", segs.length, { final: true }); parts.push(hf.chain); // The cut's dips (its whole joins and lengths), in the window's clock. const dp = dipParts(hf.outLabel, dipWindows(joins, durs, D, render.fps), render.fps, { shift: Number(at) }); parts.push(...dp.parts); return [ "-nostdin", "-v", "error", "-y", ...segs.flatMap((sg) => ["-i", sg]), ...hf.inputs, "-filter_complex", parts.join(";"), "-map", dp.label, "-map", "[aw]", ...encodeArgs(render), outPath, ]; } /** * Compose and render the deck (cached by compose-chrome's key), and check the * sequence is as long as the cut -- or the window -- it will be laid over; * then the posts windows the schedule carries, the same way, and the * fact-check stamps when it stamps a claim. Returns the overlay plan: the * deck's region first, then the feed's, each posts window's, the stamps'. * Dynamic import: compose-chrome imports this file. */ async function renderDeck({ manifestPath, render, outDir, variant, schedule, from = 0, duration = null }) { const { composeChrome } = await import("./compose-chrome.mjs"); EMIT("chrome", { phase: "compose", ...(duration != null ? { from, duration } : {}) }); const t0 = Date.now(); const r = await composeChrome({ manifestPath, outDir, variant, region: "deck", doRender: true, fps: render.fps, workers: 4, quality: "high", format: "png-sequence", ...(duration != null ? { from, duration } : {}), }); const want = frameCount(duration ?? schedule.total, render.fps); if (r.frameCount !== want) { throw new Error( `the deck's sequence is ${r.frameCount} frames but the ${duration != null ? "window" : "cut"} ` + `is ${want} (${(duration ?? schedule.total).toFixed(3)}s at ${render.fps} fps)`, ); } EMIT("chrome", { phase: r.cached ? "cached" : "render", frames: r.frameCount, key: r.key, dir: r.frames, seconds: Number(((Date.now() - t0) / 1000).toFixed(1)), }); const regions = chromeRegions(render, outDir).map((g) => ({ ...g, frames: r.frames })); // The posts FEED (a schedule with `layout: "feed"`): one sequence for the // whole cut -- or the same window as the deck's -- at feedGeometry's column, // laid like the deck's. postWindows has none for a feed, so the loop below // adds nothing. if (schedule.layout === "feed") { EMIT("chrome", { phase: "compose", region: "feed", ...(duration != null ? { from, duration } : {}) }); const t1 = Date.now(); const f = await composeChrome({ manifestPath, outDir, variant, region: "feed", doRender: true, fps: render.fps, workers: 4, quality: "high", format: "png-sequence", ...(duration != null ? { from, duration } : {}), }); if (f.frameCount !== want) { throw new Error(`the feed's sequence is ${f.frameCount} frames but the deck's is ${want}`); } EMIT("chrome", { phase: f.cached ? "cached" : "render", region: "feed", frames: f.frameCount, key: f.key, dir: f.frames, seconds: Number(((Date.now() - t1) / 1000).toFixed(1)), }); regions.push(feedRegion(render, f.frames)); } // Then the posts: one short sequence per clip that carries them, each with // its own cache, laid over the deck at its own second. A preview window // (`duration` given) takes only the windows it intersects, shifted into its // own clock; their frames are the build's, so a preview renders nothing a // build would not. const clip = duration != null ? { at: from, dur: duration } : null; for (const pr of postsRegions(render, outDir, schedule, { shift: clip ? clip.at : 0, clip })) { EMIT("chrome", { phase: "compose", region: "posts", segment: pr.segment }); const t1 = Date.now(); const p = await composeChrome({ manifestPath, outDir, variant, region: "posts", window: pr.window, doRender: true, fps: render.fps, workers: 2, quality: "high", format: "png-sequence", }); if (p.frames !== pr.frames || p.frameCount !== pr.frameCount) { throw new Error( `the posts window for ${pr.segment} rendered ${p.frameCount} frames to ${p.frames}; ` + `the overlay expects ${pr.frameCount} at ${pr.frames}`, ); } EMIT("chrome", { phase: p.cached ? "cached" : "render", region: "posts", segment: pr.segment, frames: p.frameCount, key: p.key, dir: p.frames, seconds: Number(((Date.now() - t1) / 1000).toFixed(1)), }); const { window: _w, frameCount: _n, ...region } = pr; regions.push(region); } // The fact-check stamps (a schedule whose `factcheck` stamps a claim): one // sequence for the whole cut -- or the deck's window -- laid last, over the // posts, at stampGeometry's box in the footage. if (schedule.factcheck?.stamps?.length) { EMIT("chrome", { phase: "compose", region: "stamp", ...(duration != null ? { from, duration } : {}) }); const t1 = Date.now(); const st = await composeChrome({ manifestPath, outDir, variant, region: "stamp", doRender: true, fps: render.fps, workers: 2, quality: "high", format: "png-sequence", ...(duration != null ? { from, duration } : {}), }); if (st.frameCount !== want) { throw new Error(`the stamps' sequence is ${st.frameCount} frames but the deck's is ${want}`); } EMIT("chrome", { phase: st.cached ? "cached" : "render", region: "stamp", frames: st.frameCount, key: st.key, dir: st.frames, seconds: Number(((Date.now() - t1) / 1000).toFixed(1)), }); regions.push(stampRegion(render, st.frames, schedule)); } // The thread rail (a schedule with `threads`): one sequence for the whole // cut -- or the deck's window -- beside the footage, laid like the deck's. if (schedule.threads?.threads?.length) { EMIT("chrome", { phase: "compose", region: "threads", ...(duration != null ? { from, duration } : {}) }); const t1 = Date.now(); const th = await composeChrome({ manifestPath, outDir, variant, region: "threads", doRender: true, fps: render.fps, workers: 2, quality: "high", format: "png-sequence", ...(duration != null ? { from, duration } : {}), }); if (th.frameCount !== want) { throw new Error(`the thread rail's sequence is ${th.frameCount} frames but the deck's is ${want}`); } EMIT("chrome", { phase: th.cached ? "cached" : "render", region: "threads", frames: th.frameCount, key: th.key, dir: th.frames, seconds: Number(((Date.now() - t1) / 1000).toFixed(1)), }); regions.push(threadsRegion(render, th.frames)); } // The flips panel (a schedule with `flips`): the same region as the rail. if (schedule.flips?.pairs?.length) { EMIT("chrome", { phase: "compose", region: "flips", ...(duration != null ? { from, duration } : {}) }); const t1 = Date.now(); const fl = await composeChrome({ manifestPath, outDir, variant, region: "flips", doRender: true, fps: render.fps, workers: 2, quality: "high", format: "png-sequence", ...(duration != null ? { from, duration } : {}), }); if (fl.frameCount !== want) { throw new Error(`the flips panel's sequence is ${fl.frameCount} frames but the deck's is ${want}`); } EMIT("chrome", { phase: fl.cached ? "cached" : "render", region: "flips", frames: fl.frameCount, key: fl.key, dir: fl.frames, seconds: Number(((Date.now() - t1) / 1000).toFixed(1)), }); regions.push(threadsRegion(render, fl.frames, "flips")); } return { regions, outLabel: "[hfout]" }; } /** * A cached concat is a base for the overlay only when it is as long as the * schedule says AND no segment is newer than it: a re-trimmed clip that kept * its length would otherwise pass the length check and play the old cut. */ async function freshConcat(file, segments, total, fps, joins = null) { const st = await stat(file).catch(() => null); if (!st) return false; // Same segments, same order, and the same holds and moves on them. Length // and age alone would take a REORDERED timeline's old concat -- every title, // QR and chapter then lands on the wrong footage while the length check // still passes -- or one whose holds were joined differently. const recorded = await readFile(`${file}.segments`, "utf8").catch(() => null); if (!sameConcatList(recorded, segments, joins)) return false; for (const s of segments) { if ((await stat(s)).mtimeMs > st.mtimeMs) return false; } const got = await probeDuration(file, fps).catch(() => null); return got != null && Math.abs(got - total) <= 1.5 / fps; } /** * Does a recorded concat list name exactly these segments, in this order, * joined the same way (`joins`, the deck's holds and moves)? */ export function sameConcatList(recorded, segments, joins = null) { return recorded != null && recorded === concatRecordText(segments, joins); } // ---- chapter markers ----------------------------------------------------- // A compilation like this is a reference document as much as a video: the report // cites moments, and a viewer wants to jump to them. Every clip therefore becomes // a chapter. Offsets are derived exactly the way concatWithXfade derives its xfade // offsets, so they stay correct for both crossfaded and hard-cut timelines. // // ffmetadata is a line-based format where =, ;, # and \ are structural, so a // title carrying any of them has to be escaped or the file silently mis-parses. const ffmetaEscape = (s) => String(s).replace(/([=;#\\])/g, "\\$1").replace(/\n/g, " "); export async function segmentOffsets(segments, D, fps) { const durs = []; for (const s of segments) durs.push(await probeDuration(s, fps)); // The arithmetic lives in deck.mjs so an estimate made before any segment // exists is the same sum, not a copy of it. return { ...scheduleFrom(durs, D), durs }; } /** * One entry's chapter name. * * An authored `chapter` wins; then, when the cut wears the deck, the entry's * on-screen title -- the words the viewer was shown at that moment, which is * what a chapter list is for; then the line derived from the record. Without * the deck an `onscreen` value is not drawn, so it does not name a chapter * either: a manifest without `render.chrome` builds exactly as it did. */ export async function chapterTitle(entry, index, provenance, { deck = false } = {}) { if (entry.chapter) return entry.chapter; if (deck && entry.onscreen?.title) return entry.onscreen.title; // A teaser is named by its own words, with or without the deck. if (entry.type === "teaser") return teaserTitle(entry) || `Teaser ${index + 1}`; // A still's chapter is the SAME line it burns into the header, for the reason // a clip's is: the chapter list and the picture are two views of one cut, and // a viewer jumping by chapter should land on the words they were shown. if (entry.type === "image") { return imageAttributionLine(entry) || `Image ${index + 1}`; } if (entry.type !== "clip") return entry.title ?? entry.heading ?? `Card ${index + 1}`; try { const meta = await clipMeta(entry, provenance); // The SAME overrides the header honours, resolved by the SAME functions. A // chapter list that says 2019 for a clip whose burned-in line says 2016 is // the mp4 disagreeing with itself, and only one of the two is on screen // while you watch. The channel is in both for the same reason: a chapter // list for a cut spanning three mirrors has to say which one each jump // lands in. const who = channelName(entry, meta, provenance); const date = entry.date ?? uploadDateToIso(meta.uploadDate); const title = String(entry.title ?? meta.title ?? clipLabel(entry)); const tail = `${date} — ${title.length > 60 ? `${title.slice(0, 57)}…` : title}`.trim(); return who ? `${who} · ${tail}` : tail; } catch { return `${index + 1}. ${clipLabel(entry)}`; } } /** * The deck's schedule, written down: `out//schedule.json`. * * From the PROBED segment durations (segmentOffsets, the same sum the concat * and the chapters use) and each clip's real source metadata, through * deckSchedule -- so the composition's handovers land on the frames the concat * actually cuts on. Never hand-written, and never estimated here: umtool's * preview estimates, a build measures. * * A clip whose metadata cannot be read gets `null`, as its chapter does, and * its subtitle falls back to what the entry itself says. * * @returns the schedule document (deck.mjs's shape) */ export async function writeChromeSchedule({ manifest, entries, segments, D, outDir }) { const doc = await measureChromeSchedule({ manifest, entries, segments, D }); await writeFile(path.join(outDir, "schedule.json"), JSON.stringify(doc, null, 2) + "\n"); return doc; } /** writeChromeSchedule's document, not written: what `--chapters-only` measures the cut by. */ export async function measureChromeSchedule({ manifest, entries, segments, D }) { const { render, provenance = {} } = manifest; const { durs } = await segmentOffsets(segments, D, render.fps); const metas = []; for (const e of entries) { if (e.type !== "clip") { metas.push(null); continue; } metas.push(await clipMeta(e, provenance).catch(() => null)); } // The variant's posts, placed on the clips this cut plays with their real // upload dates. A manifest without posts writes the schedule it always did. return deckSchedule({ entries, durs, D, render, provenance, metas, posts: manifest.posts ?? [] }); } async function muxChapters(finalPath, entries, segments, D, outDir, provenance, fps, deck = false, joins = null) { if (segments.length < 2) return; // The cut's own offsets: under the deck a held clip is longer in the cut // than its file, and every chapter after it starts that much later. const { starts, total } = await cutOffsets(segments, D, fps, joins); const lines = [";FFMETADATA1", ""]; for (let i = 0; i < entries.length; i += 1) { // Land just PAST the crossfade, so the marker opens on the incoming clip // rather than on the outgoing one mid-dissolve. const start = i === 0 ? 0 : starts[i] + D; const end = i === entries.length - 1 ? total : starts[i + 1] + D; lines.push( "[CHAPTER]", "TIMEBASE=1/1000", `START=${Math.round(start * 1000)}`, `END=${Math.round(end * 1000)}`, `title=${ffmetaEscape(await chapterTitle(entries[i], i, provenance, { deck }))}`, "", ); } const metaPath = path.join(outDir, "chapters.ffmeta"); await writeFile(metaPath, lines.join("\n"), "utf8"); // Stream copy — adding chapters must never re-encode the finished timeline. const tmp = finalPath.replace(/\.mp4$/, ".chapters.mp4"); await execFileP( FFMPEG, ["-nostdin", "-v", "error", "-y", "-i", finalPath, "-i", metaPath, "-map", "0", "-map_metadata", "0", "-map_chapters", "1", "-c", "copy", tmp], { maxBuffer: 1 << 24 }, ); await rename(tmp, finalPath); EMIT("chapters", { n: entries.length, file: path.basename(metaPath) }); } /** * The hard-cut concat's list, as written. Absolute: the concat demuxer resolves * a relative entry against the LIST's directory, so a relative `--out` named * every segment twice over and the hard-cut concat failed to open its first * input. An absolute path is unchanged by this, and so is every build that * already worked. */ export const concatListText = (segments) => segments.map((s) => `file '${path.resolve(s)}'`).join("\n") + "\n"; /** * What a cached hard-cut concat records it was made from: the list, and -- * only when there are joins -- one line per joined input with its hold and * move. Without joins it is the list alone, as it always was. */ export function concatRecordText(segments, joins = null) { const list = concatListText(segments); if (!joins) return list; // `mute`, `fade` and `dip` 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, ...(joins[i].mute != null ? { mute: joins[i].mute } : {}), ...(joins[i].fade ? { fade: joins[i].fade } : {}), ...(joins[i].dip ? { dip: joins[i].dip } : {}), })}`] : [])); return list + lines.join("\n") + "\n"; } /** * The hard-cut concat. Without joins, the demuxer's stream copy, as always; * with them (the deck's holds and moves) the concat filter over each input's * chain, one encode -- the copy cannot host a filtergraph. */ async function concatHardCut(segments, outDir, outPath, { record = false, joins = null, render = null, dips = null } = {}) { if (joins) { await execFileP(FFMPEG, hardCutFilterArgs(segments, joins, render, outPath, dips), { maxBuffer: 1 << 26 }); if (record) await writeFile(`${outPath}.segments`, concatRecordText(segments, joins), "utf8"); return; } const listPath = path.join(outDir, "concat.txt"); await writeFile(listPath, concatListText(segments), "utf8"); await execFileP( FFMPEG, ["-nostdin", "-v", "error", "-y", "-f", "concat", "-safe", "0", // The concat demuxer stitches per-file timestamps; without generated PTS a // stream copy can hand the next stage a discontinuous timeline, which the // rail's absolute-time expressions would then read off by that much. "-fflags", "+genpts", "-i", listPath, "-c", "copy", outPath], { maxBuffer: 1 << 24 }, ); // The deck reuses this file across --chrome-only runs, so it records exactly // which segments, in which order, it was made from (freshConcat reads it). if (record) await writeFile(`${outPath}.segments`, concatListText(segments), "utf8"); } // A hard-cut concat and a crossfaded one are different lengths, so a cached // prerail from one is a wrong base for the other. Keeping them in separate files // means the mode can be switched without a stale-cache trap -- and without the // length assertion below having to be the thing that explains it. const prerailPath = (outDir, slug, D) => path.join(outDir, `${slug}.prerail${D === 0 ? "-hardcut" : ""}.mp4`); // The finished timeline must be exactly as long as segmentOffsets says. Anything // else means a filter changed the length behind our backs. async function assertConcatLength(file, expected, fps, what) { const got = await probeDuration(file, fps); if (Math.abs(got - expected) > 1.5 / fps) { throw new Error( `${what}: duration ${got.toFixed(3)}s but the timeline is ${expected.toFixed(3)}s ` + `(${((got - expected) * fps).toFixed(1)} frames out)` + (/prerail/.test(what) ? " — delete it and let this rebuild it" : ""), ); } } /** * Build a manifest into a video. * * Exported so umtool's driver runs the SAME code the CLI does. It is still * SPAWNED rather than imported by the app: a 40-minute chain of yt-dlp and * ffmpeg inside a request handler has no cancellation story, and a runaway * grandchild would outlive the request that started it. */ /** * A cut's `src` clips (local-media.mjs), made ready before anything else runs: * every path checked -- a refusal names each entry, before a fetch or a frame * -- the reader set, and each clip's window filled in on this run's copy: * `start`/`end` default to the file's two ends and must lie inside it. */ export async function prepareLocalMedia(timeline, manifestDir, render = {}) { const allowAbsolute = render?.allowAbsoluteSrc === true; const errors = await validateLocalMedia(timeline, manifestDir, { allowAbsolute, isClip: isClipEntry }); if (errors.length) throw new Error(`manifest: ${errors.join("; ")}`); LOCAL = createLocalMedia({ baseDir: manifestDir, allowAbsolute }); for (const [i, e] of (timeline ?? []).entries()) { if (!e || !isClipEntry(e) || !hasLocalMedia(e)) continue; const who = `timeline[${i}] ${e.id}`; const { duration } = await probeMedia(LOCAL.file(e)).catch(() => ({ duration: null })); if (duration == null) { errors.push(`${who}: ffprobe cannot read a duration from ${e.src}`); continue; } e.srcDuration = duration; e.start ??= 0; e.end ??= duration; if (!(Number.isFinite(e.start) && Number.isFinite(e.end) && e.start >= 0 && e.end > e.start)) { errors.push(`${who}: start/end must be seconds with 0 ≤ start < end (got ${e.start}–${e.end})`); } else if (e.end > duration + SRC_EPS) { errors.push(`${who}: end ${e.end} is past the end of ${e.src} (${duration.toFixed(2)} s)`); } } if (errors.length) throw new Error(`manifest: ${errors.join("; ")}`); } export async function buildVideo({ manifestPath, opts = {}, out, only, fetchOnly } = {}) { const variant = opts.variant ?? "sourced"; const whole = JSON.parse(await readFile(manifestPath, "utf8")); const manifest = selectVariant(whole, variant); const { render, provenance } = manifest; // An `image` entry's `src` is relative to the MANIFEST, which is checked in // beside the pictures it cites -- not to the cwd the build was started from. // So is a clip's. const manifestDir = path.dirname(path.resolve(manifestPath)); // A `src` clip's file and cues, checked and measured before anything else. await prepareLocalMedia(manifest.timeline, manifestDir, render); // 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 batch size that cannot be read is refused as early (the env or the manifest's). xfadeBatchSize(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), ...validateTeasers(whole), ...validateClaims(whole), ...validateThreadEntries(whole), ...validateFlipEntries(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. if (deck) { const errors = validatePosts(whole.posts, whole.timeline ?? [], whole.render); if (errors.length) throw new Error(`posts: ${errors.join("; ")}`); } // Where each post's QR goes: its page on the archive (`posts.links` // "archive", the default), which needs the archive channel that keeps it. // Found once, just before the first schedule is written (a full build, // --chrome-only, --chrome-preview: after every refusal, so nothing is asked // of the network for a run that stops), and set on this run's copy of the // posts -- never written back to the manifest. A post the archive does not // have, or an archive that does not answer, links the original: a note, // not a failure. let postsLinked = false; const linkPosts = async () => { if (postsLinked) return; postsLinked = true; if (!Array.isArray(manifest.posts) || !manifest.posts.length) return; const resolver = opts.postResolver ?? createPostChannelResolver({ log: (m) => EMIT("log", { message: m }) }); const linked = await resolvePostLinks({ posts: manifest.posts, provenance, render, resolver }); manifest.posts = linked.posts; for (const message of linked.notes) EMIT("note", { message }); }; // The manifest already records which archive it was built against, so a clone // with no corpus needs no extra configuration to read cue windows. CUES = createCueSource({ siteOrigin: opts.siteOrigin ?? process.env.SITE_ORIGIN ?? siteOriginFromManifest(whole), resolveSiteIds: opts.resolveSiteIds === true, prefer: opts.cueSource ?? "auto", log: (m) => EMIT("log", { message: m }), }); const outRoot = out ?? path.join(path.dirname(path.resolve(manifestPath)), "out"); const dirs = variantPaths(outRoot, manifest.slug, variant); const outDir = dirs.dir; // Where media may already be on disk. The channels tree is the one the clip // bench reads for this project (sources.mjs channelsDirFor): the manifest's // own `provenance.channelsDir`, a `.shadow-channels/` beside it, else the // corpus the cue reader defaults to. const channelsDir = opts.channelsDir ?? channelsDirFor(manifestDir, whole, { shadowExists: await stat(path.join(manifestDir, SHADOW_CHANNELS)).then((st) => st.isDirectory(), () => false), fallback: DEFAULT_CHANNELS_DIR, }); dirs.local = localSources({ rawDir: dirs.rawDir, channelsDir, channelSlug: provenance?.channelSlug ?? null, probe: opts.probe, }); // A project's out/ first, through ensureOutDir: with UMTOOL_MEDIA_DIR set it // is a link to the media root, and the recursive mkdirs below would // otherwise make it a real directory here. A dangling link refuses here, // before a byte is fetched. await ensureWriteDir(outRoot); await mkdir(dirs.rawDir, { recursive: true }); for (const d of ["cards", "segments", "qr"]) { await mkdir(path.join(outDir, d), { recursive: true }); } // Fetch one clip's window and stop. This is what the clip bench's "fetch 20s // more" runs, so a bench fetch and a build fetch can never disagree about // naming, format selection, the VP9 trap or the Rumble HLS retry. // Reads the WHOLE manifest, not the variant's view of it: a clip bench fetch // is about a moment in the corpus, and which cut happens to carry it is // beside the point. if (fetchOnly) { let entry = whole.timeline.find((e) => e.id === fetchOnly); // A still has nothing to fetch and is already on disk, so this is a no-op // rather than an error: a bench that walks the timeline asking for each // entry's window should not have to know which kinds have one. if (entry?.type === "image" || entry?.type === "teaser" || (entry?.type === "clip" && hasLocalMedia(entry))) { const what = entry.type === "image" ? "an image" : entry.type === "teaser" ? "a teaser" : "a clip with its own `src`"; EMIT("note", { message: `${fetchOnly} is ${what} entry — nothing to fetch` }); EMIT("done", { out: null, nothingToFetch: true }); return { out: null, failures: [] }; } // `!== "clip"`, not `=== "card"`. The timeline's vocabulary is OPEN -- one // real manifest carries `scroll` and `chart` entries -- and the card-only // check sent `undefined` into the fetcher for either of those. if (entry && entry.type !== "clip") { throw new Error(`${fetchOnly} is a ${entry.type ?? "non-clip"} entry, not a clip`); } if (!entry) { // A LEDGER CLAIM. Adjudicating one means listening around the moment, and // most of the ledger is cited by no clip at all -- so the claim page asks // for a window the timeline has no entry for. It is fetched through this // same path so the file lands in clips-raw under the build's own naming, // inherits the format pin and the Rumble HLS retry, and is REUSED by a // later build rather than fetched a second time. const claim = (whole.ledger ?? []).find((e) => e.id === fetchOnly); if (!claim) throw new Error(`no timeline entry or ledger claim with id ${fetchOnly}`); if (!claim.video) throw new Error(`ledger claim ${fetchOnly} has no \`video\` to fetch`); const at = Number(claim.cite); if (!Number.isFinite(at)) throw new Error(`ledger claim ${fetchOnly} has no \`cite\` second`); // A claim is a MOMENT, not a window: the pad is the whole point, so the // entry is a hair either side of the cite and --pad does the rest. entry = { id: claim.id, video: claim.video, channel: claim.channel ?? null, start: Math.max(0, at - 1), end: at + 1, }; } const meta = await videoMeta(entry.video, entry.channel ?? provenance.channelSlug, { siteChannel: entry.siteChannel, siteVideo: entry.siteVideo }); const r = await fetchClip(entry, meta, render, dirs.local, opts); EMIT("done", { out: r.path, fetchStart: r.fetchStart, cached: r.cached, source: r.source }); return { out: r.path, failures: [] }; } // The thumbnail alone: a brand preset's still, from a cached window. if (opts.thumbnailOnly) { const r = await buildThumbnail(manifest, dirs, manifestDir); EMIT("done", { out: r.out, failures: [] }); return { out: r.out, failures: [] }; } // `--no-network`: every clip's source is found on disk BEFORE anything is // rendered, and a build that would have to fetch even one is refused here, // naming each -- not twenty minutes in, at the first one. (The runs that // rebuild no segment fetch nothing, so they are not asked.) if (opts.noNetwork && !opts.chaptersOnly && !opts.railOnly && !opts.chromeOnly && !opts.chromePreview) { const want = manifest.timeline.filter((e) => !only || e.id === only); const metaOf = (e) => clipMeta(e, provenance).catch(() => null); const { missing, audio, audioFallback } = await planLocalSources( want, whole.timeline ?? [], render, opts, dirs.local, metaOf, ); if (missing.length) throw new Error(needsFetchMessage(missing)); // Satisfied, not missed: said once, up front, so a cut that will show a // poster instead of a face is never a surprise at the end. if (audio.length) EMIT("note", { message: audioOnlyMessage(audio) }); if (audioFallback.length) EMIT("note", { message: audioOnlyMessage(audioFallback, { fallback: true }) }); } // Footer chrome is shared by every clip, so build it once up front. The deck // has none: it is the chrome. (`hyper` is the chart band's legacy switch, // which assertChrome already refuses beside a deck; the `!deck` says so here // too.) const hyper = !deck && render.chromeEngine === "hyperframes"; const chrome = deck ? deckFooter() : hyper ? reservedFooter(render) : await renderFooterAssets(render, manifest.timelineNodes, outDir); const entries = manifest.timeline.filter((e) => !only || e.id === only); if (only && !entries.length) throw new Error(`no timeline entry with id ${only}`); // Under the deck, the box every footage segment is framed into: the posts // feed's when this cut is a feed -- decided on the cut's WHOLE timeline, so // an `--only` rebuild frames a clip as the full build would. const framing = deck ? segmentFraming(render, feedOn({ render, posts: manifest.posts ?? [], entries: manifest.timeline ?? [] })) : null; // Read once, at the ROOT: a source's state is a fact about the manifest, not // about a variant. A stacked ledger card says why each claim is text rather // than footage, and this is where that answer comes from. const availability = new Map( ( await readFile(path.join(dirs.root, "availability.json"), "utf8").then( (j) => JSON.parse(j).sources ?? [], () => [], ) ).flatMap((src) => (src.claims ?? []).map((id) => [id, src.state])), ); const segments = []; const failures = []; const D = opts.noXfade || (render.transition ?? 0.5) === 0 ? 0 : render.transition ?? 0.5; // Where the cut dips to black (a teaser's `dip`), in its frames: for the // passes that lay the picture's last layer over an already joined base. // (The crossfade's own encode finds them from its joins.) const cutDips = async (segs, joins) => (joins?.some((j) => j?.dip) ? dipWindows(joins, (await cutOffsets(segs, D, render.fps, joins)).durs, D, render.fps) : null); // Retro-fit chapters onto an already-built file without re-encoding it. The // per-clip segments are still on disk, which is all the offsets need. if (opts.chaptersOnly) { const finalPath = dirs.final; const segs = entries.map((e) => path.join(outDir, "segments", `${e.id}.mp4`)); for (const seg of segs) { if (!(await exists(seg))) throw new Error(`--chapters-only needs ${seg}, which is missing — run a full build first`); } // Under the deck the cut's offsets include the holds: measured, not written. const joins = deck ? segmentJoins(await measureChromeSchedule({ manifest, entries, segments: segs, D })) : null; await muxChapters(finalPath, entries, segs, D, outDir, provenance, render.fps, deckOn(render), joins); return { out: finalPath, failures: [] }; } // Re-run the rail over a cached concat instead of rebuilding the timeline. // The rail is the part that gets iterated on; the 40-minute concat is not. if (opts.railOnly) { if (!render.rail) throw new Error("--rail-only needs render.rail in the manifest"); const finalPath = dirs.final; const prerail = prerailPath(outDir, manifest.slug, D); const segs = entries.map((e) => path.join(outDir, "segments", `${e.id}.mp4`)); for (const seg of segs) { if (!(await exists(seg))) throw new Error(`--rail-only needs ${seg}, which is missing — run a full build first`); } const joins = await cutJoins({ entries, segments: segs, render }); if (!(await exists(prerail))) { EMIT("concat", { mode: D === 0 ? "hardcut" : "xfade", n: segs.length }); // A base for the rail: its dips are laid with the rail, over it. if (D === 0) await concatHardCut(segs, outDir, prerail, { joins, render }); else await concatWithXfade(segs, render, prerail, null, null, joins, { dip: false, workDir: outDir }); } const railPlan = await buildRailPlan(manifest, render, entries, segs, D, outDir); await assertConcatLength(prerail, railPlan.total, render.fps, `cached ${path.basename(prerail)}`); const out = opts.preview ? path.join(outDir, `${manifest.slug}.preview.mp4`) : finalPath; await applyRail(prerail, out, render, railPlan, opts.preview ?? null, await cutDips(segs, joins)); if (!opts.preview) { await assertConcatLength(out, railPlan.total, render.fps, "rail build"); // applyRail re-encodes, so the chapters muxed onto the previous final are // gone. Put them back, or --rail-only quietly ships a chapterless cut. if (!opts.noChapters) { await muxChapters(out, entries, segs, D, outDir, provenance, render.fps, deckOn(render)); } } EMIT("done", { out, failures: [] }); return { out, failures: [] }; } // The deck again, over the segments already on disk: `--chrome-only` re-lays // it on the whole cut, `--chrome-preview ` on a window of it. No // segment is rebuilt and nothing is fetched; the schedule is re-measured from // the segments, so a re-render can never use durations a rebuild changed. if (opts.chromeOnly || opts.chromePreview) { const what = opts.chromeOnly ? "--chrome-only" : "--chrome-preview"; if (!deck) throw new Error(`${what} needs the deck: render.chrome is not set in the manifest`); if (opts.noChrome) throw new Error(`${what} and --no-chrome contradict each other`); if (only) throw new Error(`${what} lays the deck over the whole cut; --only does not apply`); const segs = entries.map((e) => path.join(outDir, "segments", `${e.id}.mp4`)); // Every refusal before any render: a teaser's segment is built below, so // only the others must be on disk already. for (const [i, seg] of segs.entries()) { if (entries[i].type === "teaser") continue; if (!(await exists(seg))) throw new Error(`${what} needs ${seg}, which is missing — run a full build first`); } // Segments framed for another layout (the posts feed switched on or off // since they were built) cannot take this cut's chrome: refused, by name. // A teaser is full frame, so its record is not read for this. { const records = await Promise.all(segs.map((sg) => readCutRecord(sg))); const problems = framingProblems({ entries, records, render, want: framing }); if (problems.length) throw new Error(`${what}: ${problems.join("; ")}`); } // A teaser's segment is chrome too -- graphics made from the manifest's // words, nothing fetched -- so it is (re)built here: re-rendered and // re-encoded only when its words, motion or sound changed. for (const e of entries) { if (e.type !== "teaser") continue; EMIT("card", { id: e.id, i: entries.indexOf(e), n: entries.length }); await buildTeaserSegment(e, { manifestPath, render, outDir, variant, transition: D }); } await linkPosts(); const schedule = await writeChromeSchedule({ manifest, entries, segments: segs, D, outDir }); EMIT("chrome", { phase: "schedule", total: schedule.total, segments: schedule.segments.length }); // 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) { const at = Math.max(0, Math.min(opts.chromePreview.at, schedule.total - 1 / render.fps)); const dur = Math.min(opts.chromePreview.dur, schedule.total - at); const plan = await renderDeck({ manifestPath, render, outDir, variant, schedule, from: at, duration: dur, }); const out = path.join(outDir, `${manifest.slug}.preview.mp4`); if (await freshConcat(prerail, segs, schedule.total, render.fps, joins)) { EMIT("chrome", { phase: "overlay", base: path.basename(prerail) }); await applyChrome(prerail, out, render, plan, { start: at, dur }, await cutDips(segs, joins)); } else { const { starts, durs } = await cutOffsets(segs, D, render.fps, joins); EMIT("chrome", { phase: "overlay", base: "segments" }); await execFileP(FFMPEG, previewFromSegmentsArgs({ segments: segs, durs, starts, D, at, dur, render, chromePlan: plan, outPath: out, joins, }), { maxBuffer: 1 << 26 }); } EMIT("done", { out, failures: [] }); return { out, failures: [] }; } const plan = await renderDeck({ manifestPath, render, outDir, variant, schedule }); EMIT("chrome", { phase: "overlay" }); EMIT("concat", { mode: D === 0 ? "hardcut" : "xfade", n: segs.length }); if (D === 0) { // The hard-cut concat is a stream copy of these very segments; when // nothing changed since it was made it is reused, and the overlay is // the only encode. if (await freshConcat(prerail, segs, schedule.total, render.fps, joins)) { EMIT("note", { message: `reusing ${path.basename(prerail)}` }); } else { await concatHardCut(segs, outDir, prerail, { record: true, joins, render }); } await assertConcatLength(prerail, schedule.total, render.fps, "hard-cut concat"); await applyChrome(prerail, dirs.final, render, plan, null, await cutDips(segs, joins)); } else { await concatWithXfade(segs, render, dirs.final, null, plan, joins, { workDir: outDir }); } await assertConcatLength(dirs.final, schedule.total, render.fps, "deck build"); // The overlay re-encodes, so the chapters on the previous final are gone. if (!opts.noChapters) await muxChapters(dirs.final, entries, segs, D, outDir, provenance, render.fps, deckOn(render), joins); EMIT("done", { out: dirs.final, failures: [] }); return { out: dirs.final, failures: [] }; } EMIT("start", { title: manifest.title, entries: entries.length, out: outDir }); for (let i = 0; i < entries.length; i += 1) { const entry = entries[i]; try { if (entry.type === "card") { EMIT("card", { id: entry.id, i, n: entries.length }); segments.push(await buildCardSegment(entry, render, outDir, manifest.timelineNodes, framing)); } else if (entry.type === "teaser") { EMIT("card", { id: entry.id, i, n: entries.length }); segments.push(await buildTeaserSegment(entry, { manifestPath, render, outDir, variant, transition: D })); } else if (entry.type === "image") { // `card`, not a new event name: umtool's activity feed and build chain // key off this one to mean "a segment that needs no network", and a // third word there would show as an unknown step rather than as work. EMIT("card", { id: entry.id, i, n: entries.length }); segments.push( await buildImageSegment(entry, render, outDir, chrome, provenance, manifestDir, framing), ); } else if (entry.type === "scroll" || entry.type === "chart" || entry.type === "ledger") { EMIT("card", { id: entry.id, i, n: entries.length }); if (!manifest.ledger?.length) throw new Error(`${entry.id} is type:${entry.type} but the manifest has no ledger[]`); segments.push( entry.type === "scroll" ? await buildScrollSegment(entry, render, outDir, manifest.ledger) : entry.type === "chart" ? await buildChartSegment(entry, render, outDir, manifest.ledger) : await buildLedgerSegment(entry, render, outDir, manifest.ledger, availability), ); } else { const meta = await clipMeta(entry, provenance); EMIT("clip", { id: entry.id, i, n: entries.length, video: clipLabel(entry), section: entry.section, sectionEnter: !!entry.sectionEnter, }); segments.push( await buildClipSegment( entry, meta, render, dirs, opts, chrome, manifest.timelineNodes, provenance, framing, ), ); } EMIT("segment", { id: entry.id, path: segments[segments.length - 1] }); } catch (err) { // Without --continue-on-error a dead source at entry 14 of 19 throws away // the thirteen fetches already paid for. With it, everything buildable is // built and the run reports what was not. if (!opts.continueOnError) throw err; const message = err?.message ?? String(err); failures.push({ id: entry.id, message }); EMIT("entry-failed", { id: entry.id, message }); } } if (only) { EMIT("done", { out: segments[0], failures }); return { out: segments[0], failures }; } // A timeline that silently lost a clip is a worse outcome than no file at all: // the finished video would look complete and be missing a citation. So the // segments are kept (they cost the fetches) and the concat is refused. if (failures.length) { EMIT("note", { message: `refusing to concat: ${failures.length} of ${entries.length} entries failed ` + `(${failures.map((f) => f.id).join(", ")})`, }); return { out: null, failures }; } const final = dirs.final; // The deck's schedule, from the segments just built. Written before the // concat because the composition is made from it; the rendered deck is then // laid in the concat itself (or, for hard cuts, in one pass after it). let schedule = null; if (deck) { await linkPosts(); 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, 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); // The rendered chrome, if this manifest asks for it. Absent, `chromePlan` is // null and every line below behaves exactly as it did -- which is the claim // the MD5 check tests. let chromePlan = null; if (hyper) { const regions = chromeRegions(render, outDir); for (const r of regions) { if (!(await exists(path.join(r.frames, "frame_000001.png")))) { throw new Error( `render.chromeEngine is "hyperframes" but ${r.name} has no frames at ${r.frames}. ` + `Run compose-chrome.mjs --region ${r.name} --render first.`, ); } } chromePlan = { regions, outLabel: "[hfout]" }; EMIT("note", { message: `chrome: ${regions.map((r) => `${r.name} ${r.width}x${r.height}`).join(", ")} as png-sequence` }); } // The deck: composed and rendered here, in the same command (compose-chrome // skips the render when its key and frame count match). `--no-chrome` keeps // the deck's framing and leaves the panel off -- a fast look at the picture. if (deck && !opts.noChrome) { chromePlan = await renderDeck({ manifestPath, render, outDir, variant, schedule }); EMIT("chrome", { phase: "overlay" }); } // `transition: 0` is a real editorial choice, not just a speed knob: hard cuts // hit harder on a compilation whose point is repetition. Honouring it here keeps // the manifest the source of truth, so a rebuild does not silently re-add fades. EMIT("concat", { mode: D === 0 ? "hardcut" : "xfade", n: segments.length }); if (D === 0) { // The chart band keeps this refusal. The deck does not need it: its // overlay is the second pass below, which can host anything. if (chromePlan && !deck) { throw new Error( 'render.chromeEngine "hyperframes" needs a filtergraph, and `transition: 0` concatenates with ' + "-c copy, which cannot host one. Give the manifest a transition, or drop chromeEngine.", ); } // concatHardCut is `-c copy`, which cannot host a filtergraph, so the rail // 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, { joins, render }); await assertConcatLength(prerail, railPlan.total, render.fps, "hard-cut concat"); await applyRail(prerail, final, render, railPlan, null, await cutDips(segments, joins)); } else if (chromePlan) { // Only the deck reaches here (the band refused above, and the deck // refuses a rail). Hard-cut concat to the prerail, then ONE overlay // re-encode to the final. await concatHardCut(segments, outDir, prerail, { record: true, joins, render }); await assertConcatLength(prerail, schedule.total, render.fps, "hard-cut concat"); await applyChrome(prerail, final, render, chromePlan, null, await cutDips(segments, joins)); } else { await concatHardCut(segments, outDir, final, { joins, render, dips: await cutDips(segments, joins) }); } } else { await concatWithXfade(segments, render, final, railPlan, chromePlan, joins, { workDir: outDir }); } // The deck's sequence is laid with shortest=1, so a sequence a frame short // would shorten the cut without a word; the schedule is the length to hold. if (deck) await assertConcatLength(final, schedule.total, render.fps, chromePlan ? "deck build" : "deck concat"); // Length is the canary for the two ways a rail input can go wrong: a file // LONGER than the timeline means a strip outran the main (a missing // shortest=1), and a hang means an unbounded -loop 1. if (railPlan) await assertConcatLength(final, railPlan.total, render.fps, "rail build"); if (!opts.noChapters) await muxChapters(final, entries, segments, D, outDir, provenance, render.fps, deckOn(render), joins); // A branded cut with a `thumbnail` gets one beside it. The cut is already // done, so a thumbnail that cannot be made is said, not thrown. if (render.brand && manifest.thumbnail) { await buildThumbnail(manifest, dirs, manifestDir).catch((err) => EMIT("note", { message: `thumbnail not made: ${err?.message ?? err}` }), ); } const { stdout } = await execFileP(FFPROBE, [ "-v", "error", "-show_entries", "format=duration,size", "-of", "default=noprint_wrappers=1", final, ]); const probe = Object.fromEntries( stdout.trim().split("\n").map((l) => l.split("=")), ); EMIT("done", { out: final, duration: Number(probe.duration), size: Number(probe.size) }); return { out: final, failures }; } // ---- the thumbnail (brand presets only) ---------------------------------- // `manifest.thumbnail`: // // { "headline": "The county said yes", // required; a few words // "clip": "c04", // the clip to take the still from (default: the first) // "at": 32990.5, // SOURCE seconds, the clip's own clock (default: its cite) // "still": "stills/c04.png" } // OR a picture, relative to the manifest // // The frame comes out of the CACHED window (clips-raw), never the network: a // clip that has not been fetched says so. Written to out/.thumbnail.png // beside the cut, and as a .jpg too when the PNG is over YouTube's 2 MB. export function thumbnailPath(outRoot, slug) { return path.join(outRoot, `${slug}.thumbnail.png`); } async function buildThumbnail(manifest, dirs, manifestDir) { const { render } = manifest; if (!render.brand) { throw new Error("the thumbnail step belongs to a brand preset — set render.brand"); } const t = manifest.thumbnail; if (!t?.headline) throw new Error("manifest.thumbnail.headline is required for the thumbnail step"); const work = path.join(dirs.dir, "cards"); await mkdir(work, { recursive: true }); let still; if (t.still) { still = path.resolve(manifestDir, t.still); if (!(await exists(still))) throw new Error(`thumbnail.still ${t.still} does not exist`); } else { const clip = t.clip ? manifest.timeline.find((e) => e.id === t.clip) : manifest.timeline.find((e) => e.type === "clip"); if (!clip || clip.type !== "clip") { throw new Error(`thumbnail.clip ${t.clip ?? "(first clip)"} is not a clip in this cut`); } const at = Number(t.at ?? clip.cite ?? clip.start); const win = await findContainingWindow(dirs.rawDir, clip.video, at, at); if (!win) { throw new Error(`no cached window of ${clip.video} holds ${at}s — fetch or build ${clip.id} first`); } still = path.join(work, "_thumb-frame.png"); await execFileP(FFMPEG, [ "-nostdin", "-v", "error", "-y", "-ss", Math.max(0, at - win.from).toFixed(3), "-i", win.path, "-frames:v", "1", still, ]); } const out = thumbnailPath(dirs.root, manifest.slug); const r = await renderThumbnail({ render, still, headline: t.headline, out, workDir: work }); const { size } = await stat(out); let jpg = null; if (size > THUMB_MAX_BYTES) { jpg = out.replace(/\.png$/, ".jpg"); await execFileP("magick", [out, "-sampling-factor", "4:4:4", "-quality", "92", jpg]); } EMIT("note", { message: `thumbnail -> ${out} (headline ${r.headlinePx}px, ${size} B)` + (jpg ? `; over YouTube's 2 MB, so also ${jpg}` : ""), }); return { out, jpg }; } /** * The deck's three flags, read off argv. Pure, so the parser is tested * without running a build; throws a sentence on a malformed one. * * --chrome-only re-lay the deck over the segments on disk * --no-chrome the deck's framing, no overlay * --chrome-preview a window, to out//.preview.mp4 */ export function chromeFlags(argv) { const out = { chromeOnly: argv.includes("--chrome-only"), noChrome: argv.includes("--no-chrome"), chromePreview: null, }; const i = argv.indexOf("--chrome-preview"); if (i >= 0) { const at = Number(argv[i + 1]); const dur = Number(argv[i + 2]); if (argv[i + 1] === undefined || argv[i + 2] === undefined || !Number.isFinite(at) || !Number.isFinite(dur) || at < 0 || dur <= 0) { throw new Error("--chrome-preview takes in seconds (at ≥ 0, dur > 0)"); } out.chromePreview = { at, dur }; } const runs = [out.chromeOnly, out.noChrome, out.chromePreview].filter(Boolean).length; if (runs > 1) throw new Error("--chrome-only, --no-chrome and --chrome-preview are different runs — pick one"); return out; } async function main() { const argv = process.argv.slice(2); const manifestPath = argv.find((a) => !a.startsWith("--")); if (!manifestPath) { console.error( "usage: build-video.mjs [--out ] [--variant sourced|full]\n" + " [--only ] [--fetch-only ]\n" + " [--pad ] [--pad-before ] [--pad-after ] [--skip-fetch] [--no-network] [--audio-fallback] [--no-xfade] [--no-chapters] [--chapters-only]\n" + " [--progress ndjson] [--continue-on-error] [--no-reuse]\n" + " [--no-rail] [--rail-only] [--preview ]\n" + " [--chrome-only] [--no-chrome] [--chrome-preview ] (render.chrome, the deck)\n" + " [--site-origin ] [--resolve-site-ids] [--cue-source auto|local|http]\n" + " [--thumbnail] (render.brand only: out/.thumbnail.png and stop)", ); process.exit(2); } const flag = (n) => { const i = argv.indexOf(n); return i >= 0 ? argv[i + 1] : undefined; }; setProgressMode(flag("--progress") ?? "human"); const padArg = flag("--pad"); const padBeforeArg = flag("--pad-before"); const padAfterArg = flag("--pad-after"); const opts = { variant: flag("--variant") ?? "sourced", skipFetch: argv.includes("--skip-fetch"), noNetwork: argv.includes("--no-network"), audioFallback: argv.includes("--audio-fallback"), continueOnError: argv.includes("--continue-on-error"), noXfade: argv.includes("--no-xfade"), noChapters: argv.includes("--no-chapters"), chaptersOnly: argv.includes("--chapters-only"), noReuse: argv.includes("--no-reuse"), noRail: argv.includes("--no-rail"), railOnly: argv.includes("--rail-only"), pad: padArg === undefined ? undefined : Number(padArg), padBefore: padBeforeArg === undefined ? undefined : Number(padBeforeArg), padAfter: padAfterArg === undefined ? undefined : Number(padAfterArg), siteOrigin: flag("--site-origin"), resolveSiteIds: argv.includes("--resolve-site-ids"), cueSource: flag("--cue-source"), thumbnailOnly: argv.includes("--thumbnail"), }; try { Object.assign(opts, chromeFlags(argv)); } catch (err) { console.error(err.message); process.exit(2); } const pv = argv.indexOf("--preview"); if (pv >= 0) { opts.preview = { start: Number(argv[pv + 1]), dur: Number(argv[pv + 2]) }; opts.railOnly = true; if (!Number.isFinite(opts.preview.start) || !Number.isFinite(opts.preview.dur)) { console.error("--preview takes in seconds"); process.exit(2); } } const { failures } = await buildVideo({ manifestPath, opts, out: flag("--out"), only: flag("--only"), fetchOnly: flag("--fetch-only"), }); // Non-zero on a partial run, so a caller that ignores the events still learns // the build did not produce what was asked for. if (failures.length) process.exit(1); } if (import.meta.url === `file://${process.argv[1]}`) { main().catch((err) => { EMIT("error", { message: err?.message ?? String(err) }); console.error(err.message ?? err); process.exit(1); }); }