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:
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) {