Archilyzer · Source

archilyzer

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

commit 621834ac38581fc1b8876bb983a3f11cc530d210
parent 72f1ead91254f8ae450860abd4b3314396d8cc84
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date:   Sat, 26 Sep 2026 02:40:15 -0400

umtool: the brand preset is a choice where a report video's render block is born — the new-project form's brand select and `umtool new --brand`, declared in the registry; no brand writes the skeleton it always did

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

Diffstat:
Mumtool/bin/umtool.mjs | 7+++++--
Mumtool/components/NewProjectMenu.tsx | 24+++++++++++++++++++++++-
Mumtool/e2e/projects.spec.ts | 51+++++++++++++++++++++++++++++++++++++++++++++++++++
Mumtool/lib/projects/kinds.mjs | 5++++-
Mumtool/lib/projects/scaffold.mjs | 21++++++++++++++++-----
Mumtool/lib/projects/scaffold.ts | 2++
6 files changed, 101 insertions(+), 9 deletions(-)

diff --git a/umtool/bin/umtool.mjs b/umtool/bin/umtool.mjs @@ -25,7 +25,7 @@ // umtool build <project> [--preset preview|fast|final] [--only ID] [--dry] // umtool index [--rebuild] [--prune] [--since MS] [--json] // umtool new <slug> [--kind report-video] [--from <report.md>|<share URL>|<channel>/<id>] -// [--site-origin URL] [--seed chapters] +// [--site-origin URL] [--seed chapters] [--brand archilyzer-media] // umtool doctor [--json] exit 1 if the report pipeline is missing a tool // umtool snapshot <project> [--label L] copy the manifest into revisions/ // umtool diff <project> <snapshot> what changed since that snapshot @@ -460,6 +460,7 @@ function usage() { " umtool build <project> [--preset preview|fast|final] [--only ID]", " umtool index [--rebuild] [--prune] [--since MS] [--json]", " umtool new <slug> [--from <report.md>|<share URL>|<channel>/<id>] [--site-origin URL] [--seed chapters]", + " [--brand archilyzer-media] render.brand: the report-to-video brand preset", " umtool doctor [--json] exit 1 if the report pipeline is missing a tool", " umtool snapshot <project> [--label L] copy the manifest into revisions/", " umtool diff <project> <snapshot> what changed since that snapshot", @@ -735,7 +736,7 @@ async function cmdNew() { die( "usage: umtool new <slug> [--kind report-video] [--title T]\n" + " [--from <sweep-report.md> | <share URL> | <channel>/<videoId>]\n" + - " [--site-origin <url>] [--seed chapters]", + " [--site-origin <url>] [--seed chapters] [--brand archilyzer-media]", ); } const kind = val("--kind") ?? "report-video"; @@ -750,6 +751,7 @@ async function cmdNew() { from: val("--from") ?? null, siteOrigin: val("--site-origin") ?? null, seed: val("--seed") ?? null, + brand: val("--brand") ?? null, }); } catch (e) { die(e?.message ?? String(e)); @@ -763,6 +765,7 @@ async function cmdNew() { } else { console.log(` video.manifest.json an EMPTY timeline — see README.md`); } + if (r.brand) console.log(` render.brand ${r.brand} — palette, faces, title lockup, header mark, end card`); if (r.from?.kind === "report") console.log(` sweep-report.md copied from ${r.from.path}`); console.log(` README.md ${r.seeded ? "the seeded clips" : `${r.citations} citation(s)`} as a checklist`); for (const n of r.notes) console.log(` NOTE: ${n}`); diff --git a/umtool/components/NewProjectMenu.tsx b/umtool/components/NewProjectMenu.tsx @@ -13,12 +13,14 @@ import { useRouter } from "next/navigation"; // a shape to fill in, which is the whole reason it exists: the five songs and // six report videos made by hand each came out slightly different. -type Kind = { id: string; label: string; scaffold?: { fields: string[] } | null }; +type Brand = { id: string; label: string }; +type Kind = { id: string; label: string; scaffold?: { fields: string[]; brands?: Brand[] } | null }; const FIELD_LABEL: Record<string, string> = { from: "from — a sweep-report .md, a viewer share URL, or <channel>/<videoId>", siteOrigin: "site origin — the archive every QR resolves to (e.g. https://jeralyzer.pages.dev)", seed: "seed the timeline from the video's digest chapters (review each)", + brand: "brand — a report-to-video preset: its palette and faces, the title lockup, the header mark, an end card", }; export default function NewProjectMenu({ kinds, from = null }: { kinds: Kind[]; from?: string | null }) { @@ -31,10 +33,12 @@ export default function NewProjectMenu({ kinds, from = null }: { kinds: Kind[]; const [src, setSrc] = useState(from ?? ""); const [origin, setOrigin] = useState(""); const [seed, setSeed] = useState(false); + const [brand, setBrand] = useState(""); const [busy, setBusy] = useState(false); const [error, setError] = useState<string | null>(null); const fields = can.find((k) => k.id === kind)?.scaffold?.fields ?? []; + const brands = can.find((k) => k.id === kind)?.scaffold?.brands ?? []; // Seeding only means something for a video ref, and the server refuses it // otherwise -- so the box is offered only when the source looks like one. const looksLikeVideo = /[?&]v=/.test(src) || /^[\w.-]+\/[\w.-]+$/.test(src.trim()); @@ -47,6 +51,7 @@ export default function NewProjectMenu({ kinds, from = null }: { kinds: Kind[]; if (fields.includes("from") && src.trim()) body.from = src.trim(); if (fields.includes("siteOrigin") && origin.trim()) body.siteOrigin = origin.trim(); if (fields.includes("seed") && seed && looksLikeVideo) body.seed = "chapters"; + if (fields.includes("brand") && brand) body.brand = brand; const res = await fetch("/api/projects/new", { method: "POST", headers: { "content-type": "application/json" }, @@ -147,6 +152,23 @@ export default function NewProjectMenu({ kinds, from = null }: { kinds: Kind[]; seed from chapters </label> )} + {fields.includes("brand") && brands.length > 0 && ( + <select + value={brand} + onChange={(e) => setBrand(e.target.value)} + aria-label={FIELD_LABEL.brand} + title={FIELD_LABEL.brand} + data-new-brand + className="rounded border border-[var(--color-line)] bg-[var(--color-panel-2)] px-2 py-1 text-[12px]" + > + <option value="">no brand</option> + {brands.map((b) => ( + <option key={b.id} value={b.id}> + {b.label} + </option> + ))} + </select> + )} <button type="button" disabled={busy || !slug.trim()} diff --git a/umtool/e2e/projects.spec.ts b/umtool/e2e/projects.spec.ts @@ -499,3 +499,54 @@ test("a timeline entry of an unknown type renders, rather than crashing the page await expect(page.locator("[data-entry]")).toHaveCount(6); await expect(page.locator("[data-entry=c01]")).toHaveAttribute("data-kind", "clip"); }); + +// The brand preset (report-to-video/brand.mjs, `render.brand`) is offered where +// a report video's render block is born: the new-project form and `umtool new`. +// No brand writes exactly the skeleton it always did. +test("new project offers the brand preset, and only a branded scaffold carries render.brand", async ({ + page, +}) => { + const dir = path.join(FIXTURE, "scaffold-brand"); + rmSync(dir, { recursive: true, force: true }); + const env = { ...cliEnv, REPORTS_DIR: dir }; + const run = (args: string[]) => + execFileSync("node", ["bin/umtool.mjs", ...args], { cwd: UMTOOL, encoding: "utf8", env }); + type Render = { brand?: string; palette: Record<string, string> }; + const renderOf = (root: string, slug: string) => + (JSON.parse(readFileSync(path.join(root, slug, "video.manifest.json"), "utf8")) as { render: Render }).render; + + const out = JSON.parse(run(["new", "branded", "--brand", "archilyzer-media", "--json"])) as { brand?: string }; + expect(out.brand).toBe("archilyzer-media"); + expect(renderOf(dir, "branded").brand).toBe("archilyzer-media"); + // The palette is still written: dropping `brand` later leaves a cut that renders. + expect(renderOf(dir, "branded").palette.bg).toBeTruthy(); + + const plain = JSON.parse(run(["new", "plain", "--json"])) as Record<string, unknown>; + expect("brand" in plain).toBe(false); + expect("brand" in renderOf(dir, "plain")).toBe(false); + + let code = 0; + try { + run(["new", "acme", "--brand", "acme"]); + } catch (e) { + code = (e as { status: number }).status; + } + expect(code).not.toBe(0); + rmSync(dir, { recursive: true, force: true }); + + // The form: the preset is a choice, "no brand" the default. + const slug = "brand-ui-probe"; + const created = path.join(FIXTURE, slug); + rmSync(created, { recursive: true, force: true }); + await page.goto("/"); + await page.locator("[data-new-project]").click(); + const brand = page.locator("[data-new-brand]"); + await expect(brand).toHaveValue(""); + await expect(brand.locator("option")).toHaveText(["no brand", "Archilyzer Media"]); + await page.locator("[data-new-slug]").fill(slug); + await brand.selectOption("archilyzer-media"); + await page.locator("[data-new-create]").click(); + await page.waitForURL(`**/browse/${slug}`); + expect(renderOf(FIXTURE, slug).brand).toBe("archilyzer-media"); + rmSync(created, { recursive: true, force: true }); +}); diff --git a/umtool/lib/projects/kinds.mjs b/umtool/lib/projects/kinds.mjs @@ -14,6 +14,7 @@ // spec that fails if a kind id appears as a string literal anywhere outside // lib/projects/ and components/projects/ -- that is the mechanical form of this // rule, and it is what stops `if (kind === "report-video")` appearing in a page. +import { BRAND_CHOICES } from "umtool-report-to-video/brand-ids"; import { MANIFEST_NAME, REPORT_DECISION_KINDS, reportDecisions, reportSignature, summariseReport } from "./report.mjs"; import { CUT_NAMES, songSignature, summariseSong } from "./song.mjs"; import { sweepSignature, summariseSweep } from "./sweep.mjs"; @@ -77,7 +78,9 @@ export const PROJECT_KINDS = [ views: ["clip", "claim"], // What "new project" asks for, beyond a slug and a title. Declared HERE so // the menu renders fields from data and never branches on a kind id. - scaffold: { fields: ["from", "siteOrigin", "seed"] }, + // `brand` offers report-to-video's presets (render.brand); none is the + // default and writes the manifest it always did. + scaffold: { fields: ["from", "siteOrigin", "seed", "brand"], brands: BRAND_CHOICES }, }, { id: "song", diff --git a/umtool/lib/projects/scaffold.mjs b/umtool/lib/projects/scaffold.mjs @@ -5,6 +5,7 @@ // Plain ESM: `umtool new` runs from a terminal with no server. import { mkdir, readFile, stat, writeFile } from "node:fs/promises"; import path from "node:path"; +import { BRAND_IDS } from "umtool-report-to-video/brand-ids"; import { GLOBAL_CHANNELS_DIR, MANIFEST_NAME } from "./report.mjs"; export const SLUG_RE = /^[a-z0-9][a-z0-9-]*$/; @@ -23,8 +24,8 @@ export const SLUG_RE = /^[a-z0-9][a-z0-9-]*$/; * (`--seed chapters` is the one exception, and it invents nothing: the windows * it writes are the digest's own chapter boundaries.) */ -export function skeleton(slug, title, provenance) { - return { +export function skeleton(slug, title, provenance, { brand = null } = {}) { + const doc = { schemaVersion: 1, slug, title, @@ -62,6 +63,12 @@ export function skeleton(slug, title, provenance) { timelineNodes: [], timeline: [], }; + // A brand preset (report-to-video/brand.mjs) owns the palette and the faces + // and adds the title lockup, the header mark, the end card and a thumbnail + // step. The key is ADDED, never a different skeleton: the palette above is + // still written, so dropping `brand` later leaves a manifest that renders. + if (brand) doc.render.brand = brand; + return doc; } /** @@ -151,9 +158,9 @@ export async function chaptersAsClips({ channelsDir, channel, video }) { * Write the project. Refuses an existing directory and a slug that will not * route; everything else it cannot know is written EMPTY so `check` blocks. * - * @param {{ root: string, slug: string, title?: string, from?: string | null, siteOrigin?: string | null, seed?: string | null, channelsDir?: string }} args + * @param {{ root: string, slug: string, title?: string, from?: string | null, siteOrigin?: string | null, seed?: string | null, brand?: string | null, channelsDir?: string }} args */ -export async function scaffoldReportVideo({ root, slug, title, from = null, siteOrigin = null, seed = null, channelsDir = GLOBAL_CHANNELS_DIR() }) { +export async function scaffoldReportVideo({ root, slug, title, from = null, siteOrigin = null, seed = null, brand = null, channelsDir = GLOBAL_CHANNELS_DIR() }) { if (!SLUG_RE.test(String(slug ?? ""))) { throw new Error(`"${slug}" will not route — use lower-case letters, digits and dashes`); } @@ -171,6 +178,9 @@ export async function scaffoldReportVideo({ root, slug, title, from = null, site } if (seed && seed !== "chapters") throw new Error(`--seed must be "chapters", not "${seed}"`); if (seed && src?.kind !== "video") throw new Error("--seed chapters needs --from to be a video ref"); + if (brand && !BRAND_IDS.includes(brand)) { + throw new Error(`--brand must be one of ${BRAND_IDS.join(", ")}, not "${brand}"`); + } let finalTitle = String(title ?? "").trim() || slug.replace(/-/g, " "); let reportText = null; @@ -194,7 +204,7 @@ export async function scaffoldReportVideo({ root, slug, title, from = null, site } if (siteOrigin) provenance.siteOrigin = String(siteOrigin); - const doc = skeleton(slug, finalTitle, provenance); + const doc = skeleton(slug, finalTitle, provenance, { brand }); let seeded = 0; if (seed === "chapters") { @@ -254,5 +264,6 @@ export async function scaffoldReportVideo({ root, slug, title, from = null, site seeded, notes, siteOrigin: provenance.siteOrigin ?? "", + ...(brand ? { brand } : {}), }; } diff --git a/umtool/lib/projects/scaffold.ts b/umtool/lib/projects/scaffold.ts @@ -14,6 +14,7 @@ export type NewProjectBody = { from?: string | null; siteOrigin?: string | null; seed?: string | null; + brand?: string | null; }; /** @@ -39,6 +40,7 @@ export async function scaffoldProject(body: NewProjectBody): Promise<{ href: str from: body.from || null, siteOrigin: body.siteOrigin || null, seed: body.seed || null, + brand: body.brand || null, }); result = { ...r, href: `/browse/${r.id}` }; } catch (e) {