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:
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",