commit de476ce02f9103b49af12f2487f0f479e7a5ab5c
parent e4b6c190b4b2c03c97d3cda97cbdfb952b775a9c
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date: Sat, 26 Sep 2026 03:21:56 -0400
umtool: endCard null reads as off, so a twice-resolved render keeps it off; the header mark is cached per drawing (a hash of the kit SVG in its name), not per size; doctor lists rsvg-convert as needed by the brand preset (S4 review)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Diffstat:
4 files changed, 36 insertions(+), 7 deletions(-)
diff --git a/umtool/lib/tools.mjs b/umtool/lib/tools.mjs
@@ -38,7 +38,7 @@ export const TOOLS = () => [
{ id: "yt-dlp", bin: process.env.YTDLP_BIN ?? "yt-dlp", args: ["--version"], neededBy: ["report-video fetch", "check-sources"], required: true },
{ id: "qrencode", bin: process.env.QRENCODE_BIN ?? "qrencode", args: ["--version"], neededBy: ["report-video QR"], required: true },
{ id: "imagemagick", bin: "magick", args: ["-version"], fallback: "convert", neededBy: ["report-video cards, rail, chrome"], required: false },
- { id: "rsvg-convert", bin: process.env.RSVG_BIN ?? "rsvg-convert", args: ["--version"], neededBy: ["report-video chart"], required: false },
+ { id: "rsvg-convert", bin: process.env.RSVG_BIN ?? "rsvg-convert", args: ["--version"], neededBy: ["report-video chart", "brand preset"], required: false },
{ id: "python", bin: facedetPython(), args: ["--version"], neededBy: ["faces (facecrop.py)"], required: false },
{ id: "facecrop.py", file: facecropPy(), neededBy: ["faces"], required: false },
];
diff --git a/umtool/report-to-video/brand-cards.mjs b/umtool/report-to-video/brand-cards.mjs
@@ -16,6 +16,7 @@
import { execFile } from "node:child_process";
import { promisify } from "node:util";
import { access, mkdir, writeFile } from "node:fs/promises";
+import { createHash } from "node:crypto";
import path from "node:path";
import { brandFaces, brandKit, childOpts } from "./brand.mjs";
@@ -81,12 +82,19 @@ export async function lockupPng(render, px, dir) {
};
}
-/** Mark M2 (`any`: rounded square) as a `size` px PNG, once per directory. */
+/**
+ * Mark M2 (`any`: rounded square) as a `size` px PNG. Rendered once per
+ * directory per DRAWING: the name carries a hash of the kit's SVG, so a kit
+ * regenerated by `brand-media.ts --video-kit` is never answered by a stale
+ * PNG left in out/<variant>/cards.
+ */
export async function markPng(render, size, dir) {
- const file = path.join(dir, `_mark-${size}.png`);
+ const svg = brandKit(render.brand).mark.any;
+ const key = createHash("sha256").update(svg).digest("hex").slice(0, 12);
+ const file = path.join(dir, `_mark-${size}-${key}.png`);
if (!(await exists(file))) {
await mkdir(dir, { recursive: true });
- await svgPng(render, brandKit(render.brand).mark.any, path.join(dir, `_mark-${size}.svg`), file, size / 512);
+ await svgPng(render, svg, path.join(dir, `_mark-${size}-${key}.svg`), file, size / 512);
}
return file;
}
diff --git a/umtool/report-to-video/brand.mjs b/umtool/report-to-video/brand.mjs
@@ -75,11 +75,13 @@ export function brandKit(id) {
/**
* `render.endCard` as the preset reads it: `false` turns the card off, a number
- * or `{ seconds, url }` configures it, absent is the default 20 s.
+ * or `{ seconds, url }` configures it, absent is the default 20 s. `null` is
+ * OFF too: it is what this function returns for `false`, so resolving a render
+ * block twice leaves an ended-off card off.
* @returns {{ seconds: number, url: string } | null}
*/
export function endCardConfig(endCard) {
- if (endCard === false) return null;
+ if (endCard === false || endCard === null) return null;
const o = typeof endCard === "number" ? { seconds: endCard } : (endCard ?? {});
const seconds = o.seconds ?? END_CARD_DEFAULT_SECONDS;
if (!Number.isFinite(seconds) || seconds <= 0) {
diff --git a/umtool/report-to-video/brand.test.mjs b/umtool/report-to-video/brand.test.mjs
@@ -20,7 +20,7 @@ import {
} from "./brand.mjs";
import { headerFilters, selectVariant } from "./build-video.mjs";
import { renderCard } from "./render-cards.mjs";
-import { renderThumbnail, THUMB } from "./brand-cards.mjs";
+import { markPng, renderThumbnail, THUMB } from "./brand-cards.mjs";
const PLAIN_RENDER = {
width: 1920, height: 1080, fps: 30, headerHeight: 56,
@@ -82,6 +82,19 @@ test("endCardConfig: default 20 s, a number, an object, or false", () => {
assert.throws(() => endCardConfig({ seconds: 0 }), /positive/);
});
+test("endCard false stays off when a render block is resolved twice", () => {
+ assert.equal(endCardConfig(null), null);
+ const branded = { ...PLAIN_RENDER, brand: "archilyzer-media" };
+ const once = resolveBrandRender({ ...branded, endCard: false });
+ assert.equal(once.endCard, null);
+ const twice = resolveBrandRender(once);
+ assert.equal(twice.endCard, null);
+ assert.equal(brandManifest(manifest(twice)).timeline.length, 1);
+ // A configured card resolves to itself.
+ const on = resolveBrandRender({ ...branded, endCard: { seconds: 12 } });
+ assert.deepEqual(resolveBrandRender(on).endCard, on.endCard);
+});
+
test("brandManifest appends one end card, clear of the rail, unless told otherwise", () => {
const branded = { ...PLAIN_RENDER, brand: "archilyzer-media" };
const m = brandManifest(manifest(branded));
@@ -170,6 +183,12 @@ test("rendered: title and end cards at the frame size, the thumbnail at 1280 x 7
"magick", [out, "-crop", "110x50+1170+670", "+repage", "-format", "%k", "info:"], { encoding: "utf8" },
);
assert.equal(corner, "1");
+ // The header mark is cached per DRAWING, not per size: its name carries
+ // the kit SVG's hash, so a regenerated kit cannot be answered by a stale PNG.
+ const mark = await markPng(render, 34, dir);
+ assert.match(path.basename(mark), /^_mark-34-[0-9a-f]{12}\.png$/);
+ assert.equal(size(mark), "34 34");
+ assert.equal(await markPng(render, 34, dir), mark);
} finally {
rmSync(dir, { recursive: true, force: true });
}