Archilyzer · Source

archilyzer

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

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:
Mumtool/report-to-video/README.md | 10+++++++++-
Mumtool/report-to-video/chrome-posts.mjs | 36+++++++++++++++++++++++++++++-------
Mumtool/report-to-video/chrome-posts.test.mjs | 31++++++++++++++++++++++++++++++-
Mumtool/report-to-video/compose-chrome.mjs | 18++++++++++--------
Mumtool/report-to-video/deck.mjs | 20+++++++++++++++++---
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() } : {}), }; }