Archilyzer · Source

archilyzer

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

commit 97e03c8cc2bf2b60da934e061321149640e866ce
parent cf7063defc170f96a4502f825afa97f328b6de81
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date:   Fri, 18 Sep 2026 17:29:34 -0400

report-video: one definition of the attribution line

build-video.mjs drew the header, and anything that wanted to show the same
string had to reimplement it. A preview that shows a line the renderer would
never draw is worse than no preview, and the only way to find that out was a
twenty-minute build.

report-to-video/attribution.mjs now owns cleanTitle, the header's clock, the
YYYY-MM-DD check and the line itself. No node builtins: a client component
imports it too.

chapterTitle() honours entry.title / entry.date the same way, so the chapter
list and the burned-in line cannot disagree about the same clip. Both absent
still reproduces the old output byte for byte.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

Diffstat:
Aumtool/report-to-video/attribution.mjs | 73+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mumtool/report-to-video/build-video.mjs | 42++++++++++--------------------------------
Mumtool/report-to-video/package.json | 1+
3 files changed, 84 insertions(+), 32 deletions(-)

diff --git a/umtool/report-to-video/attribution.mjs b/umtool/report-to-video/attribution.mjs @@ -0,0 +1,73 @@ +// The burned-in attribution line, in ONE place. +// +// Three programs have to agree on this string to the character: build-video.mjs +// draws it into the frame, the clip bench previews it while somebody edits the +// fields, and manifest.mjs decides which of those fields it will accept. When +// they were three implementations the bench could show a line the renderer +// would never produce -- and the only way to find out was a twenty-minute +// build. +// +// So: no node builtins in this file. It is imported by a CLIENT component as +// well as by the pipeline, and anything that reaches for `fs` here would take +// the preview down with it. + +// Stream titles are full of emoji and !commands. drawtext renders them as tofu +// with a text font, and they add nothing to an attribution line. +export function cleanTitle(title) { + return String(title ?? "") + .replace(/[\u{1F000}-\u{1FFFF}\u{2600}-\u{27BF}\u{FE0F}]/gu, "") + .replace(/\s*[!@]\S+/g, "") + .replace(/\s{2,}/g, " ") + .replace(/[\s·|-]+$/, "") + .trim(); +} + +/** The clock in the header: whole seconds, hours only when there are any. */ +export function hms(total) { + const s = Math.floor(Math.max(0, Number(total) || 0)); + const h = Math.floor(s / 3600); + const m = Math.floor((s % 3600) / 60); + const sec = s % 60; + return h > 0 + ? `${h}:${String(m).padStart(2, "0")}:${String(sec).padStart(2, "0")}` + : `${m}:${String(sec).padStart(2, "0")}`; +} + +export const DATE_RE = /^\d{4}-\d{2}-\d{2}$/; + +/** + * `YYYY-MM-DD`, and a day that exists. + * + * The regex alone accepts 2025-02-31, which reads as a date right up until + * somebody tries to check the clip against the stream it claims to come from. + */ +export function isCalendarDate(s) { + const v = String(s ?? ""); + if (!DATE_RE.test(v)) return false; + const [y, m, d] = v.split("-").map(Number); + const dt = new Date(Date.UTC(y, m - 1, d)); + return dt.getUTCFullYear() === y && dt.getUTCMonth() === m - 1 && dt.getUTCDate() === d; +} + +/** yt-dlp's `20250101` as the header writes it. Anything else passes through. */ +export function uploadDateToIso(d) { + const s = String(d ?? ""); + return /^\d{8}$/.test(s) ? `${s.slice(0, 4)}-${s.slice(4, 6)}-${s.slice(6, 8)}` : s; +} + +/** + * The line burned into the header, from a clip entry and its source's metadata. + * + * A clip's own `title` and `date` WIN. The header otherwise prints the upload + * date of the archived copy, which for a VOD mirror is often years after the + * stream — so the override is how a mirrored clip shows the stream's own date. + * Both absent reproduces the line byte for byte. + * + * @param {{ title?: string|null, date?: string|null, cite?: number|null, start?: number|null }} entry + * @param {{ title?: string|null, uploadDate?: string|null }} meta + */ +export function attributionLine(entry, meta) { + const title = entry.title ?? cleanTitle(meta.title); + const date = entry.date ?? uploadDateToIso(meta.uploadDate); + return `${title} · ${date} @ ${hms(entry.cite ?? entry.start ?? 0)}`; +} diff --git a/umtool/report-to-video/build-video.mjs b/umtool/report-to-video/build-video.mjs @@ -62,6 +62,10 @@ import { cardWidth, contentWidth, reservedFooterHeight, } from "./render-cards.mjs"; import { createCueSource, siteOriginFromManifest } from "./cues.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, hms, uploadDateToIso } from "./attribution.mjs"; const execFileP = promisify(execFile); @@ -197,27 +201,6 @@ export function setProgressMode(mode) { }; } -function hms(total) { - const s = Math.floor(total); - const h = Math.floor(s / 3600); - const m = Math.floor((s % 3600) / 60); - const sec = s % 60; - return h > 0 - ? `${h}:${String(m).padStart(2, "0")}:${String(sec).padStart(2, "0")}` - : `${m}:${String(sec).padStart(2, "0")}`; -} - -// Stream titles here are full of emoji and !commands. drawtext renders them as -// tofu with a text font, and they add nothing to an attribution line. -function cleanTitle(title) { - return title - .replace(/[\u{1F000}-\u{1FFFF}\u{2600}-\u{27BF}\u{FE0F}]/gu, "") - .replace(/\s*[!@]\S+/g, "") - .replace(/\s{2,}/g, " ") - .replace(/[\s·|-]+$/, "") - .trim(); -} - // 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) { @@ -537,14 +520,7 @@ async function buildClipSegment(entry, meta, render, dirs, opts, chrome, nodes, // mirror is often years after the stream. A clip may carry `date` (the // stream's own YYYY-MM-DD) and `title` (a display title) to override the // record's; both absent reproduces the old line byte for byte. - const d = meta.uploadDate; - const date = entry.date ?? `${d.slice(0, 4)}-${d.slice(4, 6)}-${d.slice(6, 8)}`; - const title = entry.title ?? cleanTitle(meta.title); - await writeFile( - attribPath, - `${title} · ${date} @ ${hms(entry.cite ?? entry.start)}`, - "utf8", - ); + await writeFile(attribPath, attributionLine(entry, meta), "utf8"); // 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 @@ -1433,9 +1409,11 @@ async function chapterTitle(entry, index, provenance) { if (entry.type !== "clip") return entry.title ?? entry.heading ?? `Card ${index + 1}`; try { const meta = await videoMeta(entry.video, entry.channel ?? provenance.channelSlug, { siteChannel: entry.siteChannel, siteVideo: entry.siteVideo }); - const d = String(meta.uploadDate ?? ""); - const date = /^\d{8}$/.test(d) ? `${d.slice(0, 4)}-${d.slice(4, 6)}-${d.slice(6, 8)}` : d; - const title = String(meta.title ?? entry.video); + // The SAME overrides the header honours. 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. + const date = entry.date ?? uploadDateToIso(meta.uploadDate); + const title = String(entry.title ?? meta.title ?? entry.video); return `${date} — ${title.length > 60 ? `${title.slice(0, 57)}…` : title}`.trim(); } catch { return `${index + 1}. ${entry.video}`; diff --git a/umtool/report-to-video/package.json b/umtool/report-to-video/package.json @@ -12,6 +12,7 @@ "report-compose-chrome": "./compose-chrome.mjs" }, "exports": { + "./attribution": "./attribution.mjs", "./build-video": "./build-video.mjs", "./check-availability": "./check-availability.mjs", "./compose-chrome": "./compose-chrome.mjs",