commit 47088cec2d02150a028544284bb68c3936800f4f
parent 2352bec4c99f55e87b8de7929afede48c6c6beba
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date: Mon, 5 Oct 2026 11:58:37 -0400
umtool: a post card's own marks -- accent (rail, rim, flag colour), logo (top corner, above the QR) and flag (a short label above its words or picture)
Sets one kind of card apart from another at a glance, e.g. a source document's
sentence beside platform posts. Popup cards only; validated (#rrggbb, a relative
picture path, a 1-60 character label); a post without them draws as before.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Diffstat:
5 files changed, 95 insertions(+), 20 deletions(-)
diff --git a/umtool/report-to-video/README.md b/umtool/report-to-video/README.md
@@ -441,7 +441,10 @@ footage, so it rides on a clip.
"siteUrl": null, // optional: an http(s) page the QR links instead
"postId": null, // optional: its id, when `url` does not carry one
"shot": "shots/post-1.png", // optional: a screenshot drawn instead of the text card
- "variant": "full" } ] // optional: in that cut only, as an entry's `variant`
+ "variant": "full", // optional: in that cut only, as an entry's `variant`
+ "accent": "#c98fd6", // optional: the card's rail, rim and flag colour
+ "logo": "brand/source.png", // optional: a logo in the card's top corner, above the QR
+ "flag": "No source in the article" } ] // optional: a short label (≤ 60) above the words or picture
```
- **Which clip.** The one whose recording most closely PRECEDES the post: the
@@ -480,6 +483,11 @@ footage, so it rides on a clip.
(below), or of its own `url`. A post with a `shot` draws the screenshot in
place of the words, as wide as the card's text and no taller than a full card
of words, or than `shotMaxHeight` px when that is set.
+- **Marks.** A post's own `accent`, `logo` and `flag` set one kind of card apart
+ from another at a glance — a source document's sentence beside a platform post,
+ say: the rail and rim in the accent, the logo above the QR in the top corner,
+ the flag as a label in the accent above the words or picture. Popup cards only;
+ the feed draws its cards as before.
- **Where.** A column `inset` from the top of the footage box and from the
FRAME's edge on the `position` side (inside the footage box when `shift` is
`false`), `width` wide. Cards stack top-down; when the next would overflow the
diff --git a/umtool/report-to-video/chrome-posts.mjs b/umtool/report-to-video/chrome-posts.mjs
@@ -206,6 +206,9 @@ export function postDate(post, dateFormat = "long") {
* `qrSrcs` = `{ [postId]: "assets/pqrNN.png" }`, `gsap` = the vendored script.
* `shotSrcs` = `{ [postId]: "assets/shotNN.png" }`: a post with a screenshot
* (`posts[].shot`) draws it in place of its text card, its QR cell kept.
+ * `logoSrcs` = `{ [postId]: "assets/logoNN.png" }`: a post's `logo`, drawn in
+ * the card's top corner above its QR. A post's `accent` colours its rail and
+ * rim (and its flag); its `flag` is a short label above its words or picture.
* The region is `postsGeometry(render)`, region-local and transparent outside
* the cards. `?still=<t>` and the preview's `deck:seek` take CUT seconds.
*/
@@ -218,6 +221,7 @@ export function postsHtml(schedule, render, window, opts = {}) {
const fonts = opts.fonts ?? {};
const qrSrcs = opts.qrSrcs ?? {};
const shotSrcs = opts.shotSrcs ?? {};
+ const logoSrcs = opts.logoSrcs ?? {};
const gsapSrc = opts.gsap ?? "assets/gsap.min.js";
const posts = windowPosts(schedule, window.segment);
if (!posts.length) throw new Error(`posts: no post rides on ${window.segment}`);
@@ -226,6 +230,7 @@ export function postsHtml(schedule, render, window, opts = {}) {
const pad = 18;
const plateW = set.qrSize + 2 * pad;
+ const LOGO_H = 72;
const rail = 6;
const metaSize = 18;
const textSize = 24;
@@ -238,11 +243,17 @@ export function postsHtml(schedule, render, window, opts = {}) {
const { name, platform } = postWho(p);
const src = qrSrcs[p.id];
const shot = shotSrcs[p.id];
+ const logo = logoSrcs[p.id];
+ const acc = typeof p.accent === "string" && /^#[0-9a-fA-F]{6}$/.test(p.accent) ? p.accent : null;
+ const style = acc
+ ? ` style="--acc: ${acc}; --acc-rim: ${rgba(acc, 0.95)}; --acc-glow: ${rgba(acc, 0.55)}"`
+ : "";
+ const flag = p.flag ? `<div class="flag">${esc(p.flag)}</div>` : "";
return (
- `<article class="post${shot ? " has-shot" : ""}" data-post="${esc(p.id)}" data-k="c${j}">` +
+ `<article class="post${shot ? " has-shot" : ""}${logo ? " has-logo" : ""}" data-post="${esc(p.id)}" data-k="c${j}"${style}>` +
(shot
- ? `<div class="body shot-body"><img class="shot" src="${esc(shot)}" alt=""></div>`
- : `<div class="body">` +
+ ? `<div class="body shot-body">${flag}<img class="shot" src="${esc(shot)}" alt=""></div>`
+ : `<div class="body">${flag}` +
`<div class="meta">` +
(platform ? `<span class="platform">${esc(platform)}</span>` : "") +
`<span class="who"><span class="handle">${esc(name)}</span></span>` +
@@ -250,6 +261,7 @@ export function postsHtml(schedule, render, window, opts = {}) {
`<div class="text">${postParagraphs(p.text).map((t) => `<p class="para">${esc(t)}</p>`).join("")}</div>` +
`</div>`) +
`<div class="plate">` +
+ (logo ? `<img class="logo" src="${esc(logo)}" alt="">` : "") +
(src ? `<img src="${esc(src)}" width="${set.qrSize}" height="${set.qrSize}" alt="">` : "") +
`</div>` +
`<div class="glow" data-k="g${j}"></div>` +
@@ -308,14 +320,23 @@ export function postsHtml(schedule, render, window, opts = {}) {
the accent, an accent rail down its leading edge. */
.post { position: absolute; left: 0; top: 0; width: ${W}px; min-height: ${set.qrSize + 2 * pad}px;
visibility: hidden; opacity: 0; border-radius: 10px; overflow: hidden;
- border-left: ${rail}px solid ${pal.accent};
+ border-left: ${rail}px solid var(--acc, ${pal.accent});
background: linear-gradient(180deg, ${top} 0%, ${bottom} 100%);
box-shadow: inset 0 0 0 1px ${rgba(pal.fg, 0.12)}; transform-origin: 50% 0%; }
/* The flare as a card lands, settling to a quiet accent rim. Last in the
card, over the QR's cell -- its blur stays inside the cell's padding,
clear of the code. */
.glow { position: absolute; left: 0; top: 0; right: 0; bottom: 0; opacity: 0; pointer-events: none;
- box-shadow: inset 0 0 0 2px ${rgba(pal.accent, 0.95)}, inset 0 0 14px ${rgba(pal.accent, 0.55)}; }
+ box-shadow: inset 0 0 0 2px var(--acc-rim, ${rgba(pal.accent, 0.95)}),
+ inset 0 0 14px var(--acc-glow, ${rgba(pal.accent, 0.55)}); }
+ /* A card's flag: a short label in its accent, above its words or picture. */
+ .flag { display: inline-block; margin: 0 0 10px; padding: 3px 12px 4px; border-radius: 6px;
+ font-family: 'DeckSansBold', sans-serif; font-size: ${metaSize}px; line-height: ${metaSize + 6}px;
+ letter-spacing: 0.02em; color: ${pal.bg}; background: var(--acc, ${pal.accent}); }
+ /* A card's logo: the top corner, above its QR. */
+ .post.has-logo { min-height: ${set.qrSize + 2 * pad + LOGO_H + 10}px; }
+ .logo { display: block; width: ${plateW - 16}px; height: auto; max-height: ${LOGO_H}px; object-fit: contain;
+ border-radius: 6px; }
.body { position: relative; width: ${W - plateW - rail}px; padding: ${pad - 4}px ${pad + 2}px ${pad - 3}px ${pad + 2}px; }
.meta { display: flex; align-items: center; gap: 10px;
font-size: ${metaSize}px; line-height: ${Math.round(metaSize * 1.3)}px; white-space: nowrap; }
@@ -341,10 +362,11 @@ export function postsHtml(schedule, render, window, opts = {}) {
object-fit: contain; object-position: left top; border-radius: 6px; }
/* The source cell: the QR in a cell a shade down, as on the deck. */
.plate { position: absolute; right: 0; top: 0; bottom: 0; width: ${plateW}px;
- display: flex; align-items: center; justify-content: center;
+ display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 10px;
background: linear-gradient(180deg, ${mix(top, pal.bg, 0.55)} 0%, ${mix(bottom, pal.bg, 0.6)} 100%);
border-left: 1px solid ${rgba(pal.fg, 0.07)}; }
- .plate img { display: block; width: ${set.qrSize}px; height: ${set.qrSize}px; border-radius: 6px;
+ .has-logo .plate { justify-content: space-between; padding: ${pad - 6}px 0 ${pad}px; }
+ .plate img:not(.logo) { display: block; width: ${set.qrSize}px; height: ${set.qrSize}px; border-radius: 6px;
image-rendering: pixelated;
box-shadow: 0 0 0 1px ${rgba(pal.fg, 0.25)}, 0 6px 18px rgba(0, 0, 0, 0.35); }
</style>
diff --git a/umtool/report-to-video/chrome-posts.test.mjs b/umtool/report-to-video/chrome-posts.test.mjs
@@ -15,7 +15,7 @@ import { fileURLToPath } from "node:url";
import {
postDate, postParagraphs, postsCues, postsHtml, postWho, snapWindow, windowPosts,
} from "./chrome-posts.mjs";
-import { postSchedule, postsGeometry, postWindows } from "./deck.mjs";
+import { postSchedule, postsGeometry, postWindows, validatePosts } from "./deck.mjs";
import { applyChromeArgs, chromeOverlayChain, chromeRegions, postsRegions } from "./build-video.mjs";
const HERE = path.dirname(fileURLToPath(import.meta.url));
@@ -479,6 +479,35 @@ test("a post with a screenshot draws it in place of its text card, its QR cell k
assert.doesNotMatch(postsHtml(sched, RENDER, win, { fonts: FONTS }), /class="shot"/);
});
+test("a post's accent, logo and flag mark its card; a post without them is drawn as before", () => {
+ const sched = schedule([
+ POST("a", "2024-10-19T17:01:17.640Z", { shot: "shots/a.png", accent: "#c98fd6", logo: "brand/l.png", flag: "No source in the article" }),
+ POST("b", "2024-11-27"),
+ ]);
+ const a = windowPosts(sched, "c2").find((p) => p.id === "a");
+ assert.deepEqual([a.accent, a.logo, a.flag], ["#c98fd6", "brand/l.png", "No source in the article"]);
+ const win = snapWindow(postWindows(sched).find((w) => w.segment === "c2"), { fps: 30, total: sched.total });
+ const html = postsHtml(sched, RENDER, win, {
+ fonts: FONTS, shotSrcs: { a: "assets/shot00.png" }, logoSrcs: { a: "assets/logo00.png" },
+ qrSrcs: { a: "assets/qr00.png", b: "assets/qr01.png" },
+ });
+ const open = html.indexOf('data-post="a"');
+ const card = html.slice(open - 60, html.indexOf("<article", open + 1));
+ assert.match(card, /class="post has-shot has-logo"/);
+ assert.match(card, /style="--acc: #c98fd6; --acc-rim: rgba\(201, ?143, ?214, ?0\.95\)/);
+ assert.ok(card.includes('<div class="flag">No source in the article</div><img class="shot"'), "the flag heads the picture");
+ assert.ok(card.includes('<img class="logo" src="assets/logo00.png" alt=""><img src="assets/qr00.png"'), "logo above the QR");
+ const b = html.slice(html.indexOf('data-post="b"') - 60);
+ assert.doesNotMatch(b.slice(0, b.indexOf("</article>")), /--acc|class="flag"|class="logo"/);
+ assert.match(html, /border-left: 6px solid var\(--acc, /);
+ // Validation.
+ assert.deepEqual(validatePosts([POST("a", "2026-01-01", { accent: "#c98fd6", logo: "brand/l.png", flag: "Found by us" })]), []);
+ const bad = validatePosts([POST("a", "2026-01-01", { accent: "pink", logo: "/abs/l.png", flag: "x".repeat(61) })]).join(" | ");
+ assert.match(bad, /accent must be a #rrggbb colour/);
+ assert.match(bad, /logo must be relative to the manifest/);
+ assert.match(bad, /flag must be a short label/);
+});
+
test("shotMaxHeight caps a screenshot in px; unset, a full card of words does", () => {
const sched = schedule([POST("a", "2024-10-19T17:01:17.640Z", { shot: "shots/a.png" })]);
const win = snapWindow(postWindows(sched)[0], { fps: 30, total: sched.total });
diff --git a/umtool/report-to-video/compose-chrome.mjs b/umtool/report-to-video/compose-chrome.mjs
@@ -613,24 +613,25 @@ async function copyFonts(render, assetsDir, names, { strict }) {
}
/**
- * The posts' screenshots (`posts[].shot`, relative to the manifest), copied
- * in beside the page as `shotNN<ext>`, one per distinct file. Returns
+ * The posts' screenshots (`posts[].shot`, relative to the manifest) -- or,
+ * with `key` "logo", their logos (`posts[].logo`) -- copied
+ * in beside the page as `shotNN<ext>` (`logoNN<ext>`), one per distinct file. Returns
* `{ [postId]: "assets/shotNN.png" }`. A shot that cannot be copied refuses
* the region: a card drawn without the picture its post names is not the
* card the manifest asks for.
*/
-async function copyShots(posts, manifestDir, assetsDir) {
+async function copyShots(posts, manifestDir, assetsDir, key = "shot") {
const out = {};
const byFile = new Map();
for (const p of posts) {
- if (typeof p.shot !== "string" || !p.shot) continue;
- const file = path.resolve(manifestDir, p.shot);
+ if (typeof p[key] !== "string" || !p[key]) continue;
+ const file = path.resolve(manifestDir, p[key]);
if (!byFile.has(file)) {
- const name = `shot${String(byFile.size).padStart(2, "0")}${path.extname(file).toLowerCase()}`;
+ const name = `${key}${String(byFile.size).padStart(2, "0")}${path.extname(file).toLowerCase()}`;
try {
await copyFile(file, path.join(assetsDir, name));
} catch (e) {
- throw new Error(`post ${p.id}: its shot ${p.shot} cannot be copied: ${e.message}`);
+ throw new Error(`post ${p.id}: its ${key} ${p[key]} cannot be copied: ${e.message}`);
}
byFile.set(file, `assets/${name}`);
}
@@ -672,7 +673,8 @@ async function regionHtml(region, { manifest, manifestDir, base, projDir, assets
const qrSrcs = {};
for (const p of posts) if (p.qrUrl) qrSrcs[p.id] = byUrl.get(p.qrUrl);
const shotSrcs = await copyShots(posts, manifestDir, assetsDir);
- return postsHtml(schedule, render, window, { fonts, qrSrcs, shotSrcs });
+ const logoSrcs = await copyShots(posts, manifestDir, assetsDir, "logo");
+ return postsHtml(schedule, render, window, { fonts, qrSrcs, shotSrcs, logoSrcs });
}
if (region === "feed") {
// The posts feed: the deck's faces and QR maker, one page for the whole cut.
diff --git a/umtool/report-to-video/deck.mjs b/umtool/report-to-video/deck.mjs
@@ -758,7 +758,9 @@ const POST_VARIANTS = Object.freeze(["sourced", "full"]);
export const POST_LAYOUTS = Object.freeze(["popup", "feed"]);
/** Where a post's QR links (`posts.links`): its page on the archive, or the platform's own link. */
export const POST_LINKS = Object.freeze(["archive", "original"]);
-const POST_KEYS = ["id", "platform", "author", "handle", "date", "text", "url", "attachTo", "hide", "siteChannel", "siteUrl", "postId", "shot", "variant"];
+const POST_KEYS = ["id", "platform", "author", "handle", "date", "text", "url", "attachTo", "hide", "siteChannel", "siteUrl", "postId", "shot", "variant", "accent", "logo", "flag"];
+/** A post's `flag`: at most this many characters. */
+export const POST_FLAG_MAX = 60;
/** The pictures a post's `shot` may be. */
export const POST_SHOT_EXTS = Object.freeze([".png", ".jpg", ".jpeg", ".webp"]);
@@ -768,8 +770,8 @@ export const POST_SHOT_EXTS = Object.freeze([".png", ".jpg", ".jpeg", ".webp"]);
* post, RELATIVE to the manifest (it is checked in beside it, as an image
* entry's `src` is), inside the manifest's folder, and a picture by its name.
*/
-function shotError(shot) {
- if (typeof shot !== "string" || !shot.trim()) return "must be a path to the post's screenshot, relative to the manifest";
+function shotError(shot, what = "the post's screenshot") {
+ if (typeof shot !== "string" || !shot.trim()) return `must be a path to ${what}, relative to the manifest`;
const parts = shot.split(/[\\/]+/);
if (/^([A-Za-z]:)?[\\/]/.test(shot) || parts.includes("..")) {
return "must be relative to the manifest and inside its folder (no leading /, no ..)";
@@ -833,6 +835,15 @@ export function validatePosts(posts, timeline = [], render = null) {
}
// A screenshot drawn instead of the text card; the text stays the post's words.
if (p.shot != null && shotError(p.shot)) errors.push(`${w}.shot ${shotError(p.shot)}`);
+ // The card's own marks: a colour for its rail and rim, a logo in its corner,
+ // a short flag above its words -- so one kind of card reads apart from another.
+ if (p.accent != null && (typeof p.accent !== "string" || !/^#[0-9a-fA-F]{6}$/.test(p.accent))) {
+ errors.push(`${w}.accent must be a #rrggbb colour`);
+ }
+ if (p.logo != null && shotError(p.logo, "a logo picture")) errors.push(`${w}.logo ${shotError(p.logo, "a logo picture")}`);
+ if (p.flag != null && (typeof p.flag !== "string" || !p.flag.trim() || p.flag.length > POST_FLAG_MAX)) {
+ errors.push(`${w}.flag must be a short label (1 to ${POST_FLAG_MAX} characters)`);
+ }
});
// Posts that will be drawn need a column that fits the footage box.
if (render && deckOn(render) && resolveDeck(render).posts.show && posts.some((p) => isObj(p) && !p.hide)) {
@@ -1004,6 +1015,9 @@ function postFieldsOf(p, provenance, links) {
qrUrl: postQrUrl(p, provenance, { links }),
// Only with a screenshot, so a post without one places as it always did.
...(typeof p.shot === "string" && p.shot ? { shot: p.shot } : {}),
+ ...(typeof p.accent === "string" && p.accent ? { accent: p.accent } : {}),
+ ...(typeof p.logo === "string" && p.logo ? { logo: p.logo } : {}),
+ ...(typeof p.flag === "string" && p.flag.trim() ? { flag: p.flag.trim() } : {}),
};
}