commit 24caf1ef46902b2810e47859060f7072f1c9b055
parent 918934a32e67a428de3dff59f8e78bb4c940e032
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date: Fri, 18 Sep 2026 17:59:13 -0400
report-to-video: a still's header line, in the same module as a clip's
An image entry has no moment inside it to cite, so the clock a clip carries
would point at nothing. imageAttributionLine() is the same ` · ` shape without
it, and the empty string when there is nothing to say -- which the renderer
will read as "draw no header", because an empty textfile is the one thing
drawtext refuses outright.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Diffstat:
2 files changed, 107 insertions(+), 0 deletions(-)
diff --git a/umtool/report-to-video/attribution.mjs b/umtool/report-to-video/attribution.mjs
@@ -71,3 +71,27 @@ export function attributionLine(entry, meta) {
const date = entry.date ?? uploadDateToIso(meta.uploadDate);
return `${title} · ${date} @ ${hms(entry.cite ?? entry.start ?? 0)}`;
}
+
+/**
+ * The same header line, for a still IMAGE entry.
+ *
+ * An image has no clock: there is no moment inside it to cite, so the ` @ h:mm:ss`
+ * a clip carries would be a number pointing at nothing. What is left is the
+ * author's own caption and, when the picture is dated, its date -- in the same
+ * ` · ` shape, so a still sitting between two clips reads as one more line of the
+ * same header rather than as a different program's output.
+ *
+ * The title is NOT run through `cleanTitle`: a clip's title comes from an
+ * archive record full of emoji and !commands, but an image's is typed by the
+ * author for this cut and is already the line they meant.
+ *
+ * Both fields absent yields "", which the renderer reads as "draw no header" --
+ * an empty textfile is the one thing drawtext refuses outright.
+ *
+ * @param {{ title?: string|null, date?: string|null }} entry
+ */
+export function imageAttributionLine(entry) {
+ const title = String(entry?.title ?? "").trim();
+ const date = String(entry?.date ?? "").trim();
+ return [title, date].filter(Boolean).join(" · ");
+}
diff --git a/umtool/report-to-video/attribution.test.mjs b/umtool/report-to-video/attribution.test.mjs
@@ -0,0 +1,83 @@
+// Tests for attribution.mjs — the one definition of the burned-in header line.
+//
+// Three programs draw or promise this string (the renderer, the clip bench's
+// preview, the manifest writer's validation), so it is worth pinning to the
+// character.
+//
+// Run with: pnpm test:scripts
+import assert from "node:assert/strict";
+import test from "node:test";
+
+import {
+ attributionLine,
+ cleanTitle,
+ hms,
+ imageAttributionLine,
+ isCalendarDate,
+ uploadDateToIso,
+} from "./attribution.mjs";
+
+test("hms: hours only when there are any", () => {
+ assert.equal(hms(0), "0:00");
+ assert.equal(hms(61), "1:01");
+ assert.equal(hms(3599), "59:59");
+ assert.equal(hms(3600), "1:00:00");
+ assert.equal(hms(32989.9), "9:09:49");
+});
+
+test("attributionLine: a clip carries a clock", () => {
+ assert.equal(
+ attributionLine({ title: "Monday Mail #11", date: "2016-09-12", cite: 32989 }, {}),
+ "Monday Mail #11 · 2016-09-12 @ 9:09:49",
+ );
+ assert.equal(
+ attributionLine({ start: 10 }, { title: "Stream 🙂 !socials", uploadDate: "20250101" }),
+ "Stream · 2025-01-01 @ 0:10",
+ );
+});
+
+test("imageAttributionLine: title and date", () => {
+ assert.equal(
+ imageAttributionLine({
+ title: "Bx (@bx_on_x) on X, September 2026 — 1/4",
+ date: "2026-08-19",
+ }),
+ "Bx (@bx_on_x) on X, September 2026 — 1/4 · 2026-08-19",
+ );
+});
+
+test("imageAttributionLine: no date, no separator", () => {
+ assert.equal(
+ imageAttributionLine({ title: "1st pic — Substack profile" }),
+ "1st pic — Substack profile",
+ );
+ // A still has no moment to cite, so there is never a clock on it.
+ assert.doesNotMatch(imageAttributionLine({ title: "x", date: "2026-08-19" }), /@/);
+});
+
+test("imageAttributionLine: a date alone still reads", () => {
+ assert.equal(imageAttributionLine({ date: "2026-08-19" }), "2026-08-19");
+});
+
+test("imageAttributionLine: nothing to say is the empty string", () => {
+ // The renderer reads "" as "draw no header": an empty textfile is the one
+ // thing drawtext refuses outright.
+ assert.equal(imageAttributionLine({}), "");
+ assert.equal(imageAttributionLine({ title: " ", date: null }), "");
+ assert.equal(imageAttributionLine(undefined), "");
+});
+
+test("imageAttributionLine: the author's title is not cleaned", () => {
+ // An @handle is the caption's subject here, not a chat !command to strip.
+ const line = imageAttributionLine({ title: "Bx (@bx_on_x) on X" });
+ assert.equal(line, "Bx (@bx_on_x) on X");
+ assert.notEqual(line, cleanTitle("Bx (@bx_on_x) on X"));
+});
+
+test("isCalendarDate / uploadDateToIso", () => {
+ assert.equal(isCalendarDate("2026-08-19"), true);
+ assert.equal(isCalendarDate("2025-02-31"), false);
+ assert.equal(isCalendarDate("20250101"), false);
+ assert.equal(uploadDateToIso("20250101"), "2025-01-01");
+ assert.equal(uploadDateToIso("2025-01-01"), "2025-01-01");
+});