commit 2ab4c35f0b72e4bbe5eb73fa6f9c910ed09f3baa
parent df2ea2d0d65781848d6117719cb447ecc7e861bc
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date: Tue, 18 Aug 2026 21:50:41 -0400
umtool: /browse is the project index, and reports become reachable
The registry, wired to the UI. /browse lists report videos and um-songs side by
side with four filters; app/browse/[song]/ becomes app/browse/[...path]/, which
resolves the longest path prefix that is a project and hands the rest to that
kind's view. The song pages are unchanged -- they moved to components/projects/
and take an id instead of awaiting params.
URLs that already existed still mean the same thing. A project id is a PATH now
(quartering-uh-song/videos/yoshi), but /browse/yoshi and /browse/yoshi/wide are
the URLs in every decision href, every spec and whatever anybody has open, so a
bare basename resolves as an alias -- ONLY when it is unique. Two projects
sharing a name is exactly why an id is a path, so that case reports the
collision and offers both canonical URLs rather than picking one.
Mix reach. listMedia walks the project roots two deep (a report's deliverable is
at <project>/out/<slug>.mp4, a song's cut at videos/<song>/<cut>.mp4 -- neither
was visible) with segments|clips-raw|cards|qr added to the scratch names, so the
deliverable appears and ~260 intermediates do not. But depth alone was not
enough: the picker's cap is saturated, and measured, four of the six report
deliverables still fell off the end of a 600-entry newest-first list. So the
picker asks each PROJECT for its own files, with its own small cap -- coverage
becomes a property of the enumeration rather than of the cap. 11 optgroups, every
deliverable present, 135 ms.
shadcn lands as class helpers only, on a zero-client-JS surface first. Its token
names are ALIASED onto the existing palette rather than imported: the house rule
is that colour means something, and its default primary/accent/destructive set
would walk back in the second green that was deleted for making a navigation
state wear a verdict's clothes. `accent` maps to a surface, not a hue. /browse
and /browse/decisions ship no new client chunk and both are still server-rendered.
Three things the tests caught that review had not:
- A circular import. Moving songIds() into song.mjs closed
song -> walk -> kinds -> song. Plain node survives it; Turbopack evaluates
kinds.mjs while song.mjs is still initialising and every song page 500s with
"Cannot access 'CUT_NAMES' before initialization". `pnpm build` does not see
it because nothing prerenders. Hence lib/projects/song-ids.mjs.
- The song-decision dispatch had the production path written out by hand, so
under the fixture -- whose songs live elsewhere -- NO song decisions were
produced and the inbox looked clean because it was empty. It now asks the
same question songIds() does.
- An unignored dist dir poisons the stylesheet. Tailwind v4 auto-detects its
sources and honours .gitignore, so a scratch .next-probe/ got scanned, its
binary turbopack cache yielded candidates like `p-[var(-sM0or-Z)]`, and every
page 500d on a CSS parse error with nothing wrong in the CSS. .gitignore now
covers umtool/.next-*/.
e2e: 114 passed. browse.spec.ts needed only selector changes (a project id is a
path), which was the claim. The new e2e/projects.spec.ts includes three
source-level assertions -- no kind id is special-cased outside the registry, the
reserved names equal the real static pages under app/browse, and a kind injected
through UMTOOL_EXTRA_KINDS reaches the index, the chips and the CLI with no code
edit. Those are the ones that fail when somebody special-cases a kind in a page,
which no page test can see.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Diffstat:
36 files changed, 2395 insertions(+), 521 deletions(-)
diff --git a/.gitignore b/.gitignore
@@ -137,5 +137,12 @@ yarn-error.log*
# one project).
umtool/.e2e-song/
umtool/.next/
-umtool/.next-e2e/
+# Any alternate dist dir, not just the e2e one.
+#
+# Tailwind v4 auto-detects its sources and honours .gitignore -- so a dist dir
+# that is NOT ignored gets scanned, its binary turbopack cache yields candidate
+# class names like `p-[var(-sM0or-Z)]`, and the generated stylesheet fails to
+# parse. Every page then 500s on a CSS error with nothing wrong in the CSS.
+# Cost an e2e run's worth of confusing red to find.
+umtool/.next-*/
umtool/test-results/
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
@@ -302,8 +302,16 @@ importers:
specifier: ^5.6.0
version: 5.9.3
+ scripts/report-to-video: {}
+
umtool:
dependencies:
+ class-variance-authority:
+ specifier: ^0.7.1
+ version: 0.7.1
+ clsx:
+ specifier: ^2.1.1
+ version: 2.1.1
next:
specifier: 16.2.3
version: 16.2.3(@babel/core@7.29.0)(@playwright/test@1.59.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
@@ -313,6 +321,12 @@ importers:
react-dom:
specifier: 19.2.4
version: 19.2.4(react@19.2.4)
+ report-to-video:
+ specifier: workspace:*
+ version: link:../scripts/report-to-video
+ tailwind-merge:
+ specifier: ^3.6.0
+ version: 3.6.0
yt-dlp-transcript-common:
specifier: workspace:*
version: link:../common
diff --git a/scripts/report-to-video/resolve-windows.mjs b/scripts/report-to-video/resolve-windows.mjs
diff --git a/umtool/app/api/browse/poster/route.ts b/umtool/app/api/browse/poster/route.ts
@@ -3,6 +3,8 @@ import path from "node:path";
import { SONG_REPORTS } from "@/lib/paths";
import { readSong, resolveRendition, type Song } from "@/lib/browse";
import { posterFrame } from "@/lib/poster";
+import { projectRef, summariseProject } from "@/lib/projects";
+import { resolveInRoots } from "@/lib/paths";
export const dynamic = "force-dynamic";
@@ -39,10 +41,38 @@ function send(buf: Buffer, file: string, source: string) {
export async function GET(request: Request) {
const url = new URL(request.url);
+ const project = url.searchParams.get("project");
const id = url.searchParams.get("song") ?? "";
const rel = url.searchParams.get("rel");
const width = Math.min(1280, Math.max(120, Number(url.searchParams.get("w")) || 480));
+ // `?project=` is the general form; `?song=` stays because every existing song
+ // link and every existing spec uses it.
+ //
+ // A project poster takes NO client-supplied rel. The frame it draws is the
+ // one its own summariser chose -- the deliverable, else a built segment (which
+ // already carries the chrome, so the card looks like the video mid-build),
+ // else a raw clip. That is a server-derived path, which is why this route can
+ // widen to every kind without widening what a caller can ask it to open.
+ if (project) {
+ const ref = await projectRef(project);
+ if (!ref) return new Response("no such project", { status: 404 });
+ const summary = await summariseProject(ref);
+ if (!summary.posterRel) return new Response("nothing to draw", { status: 404 });
+ const abs = resolveInRoots(path.join(ref.dir, summary.posterRel));
+ if (!abs) return new Response("outside the roots", { status: 400 });
+ if (TYPES[path.extname(abs).toLowerCase()]) {
+ try {
+ return send(await readFile(abs), abs, "file");
+ } catch {
+ return new Response("nothing to draw", { status: 404 });
+ }
+ }
+ const poster = await posterFrame(abs, width);
+ if (!poster) return new Response(null, { status: 404 });
+ return send(await readFile(poster.file), poster.file, poster.cached ? "cached" : "fresh");
+ }
+
const song = await readSong(id);
if (!song) return new Response("no such song", { status: 404 });
diff --git a/umtool/app/api/mix/files/route.ts b/umtool/app/api/mix/files/route.ts
@@ -1,12 +1,25 @@
-import { listMedia } from "@/lib/media";
import { MEDIA_ROOTS } from "@/lib/paths";
+import { listMedia } from "@/lib/media";
+import { mediaGroups } from "@/lib/projects";
export const dynamic = "force-dynamic";
-// Everything the bench could load, newest first -- which is almost always the
-// order you want, because the file you are about to judge is the one that just
-// finished rendering.
-export async function GET() {
+// Everything the bench could load.
+//
+// `?group=project` is the shape the picker uses now. The flat list stays for
+// anything that just wants "the newest renders", and because a flat newest-first
+// list is still almost always the order you want when the file you are about to
+// judge is the one that just finished rendering -- it is only COVERAGE it is bad
+// at, and coverage is what a picker over twelve projects needs.
+export async function GET(request: Request) {
+ const grouped = new URL(request.url).searchParams.get("group") === "project";
+ if (grouped) {
+ const { groups, other } = await mediaGroups();
+ return Response.json(
+ { roots: MEDIA_ROOTS, groups, other },
+ { headers: { "cache-control": "no-store" } },
+ );
+ }
const files = await listMedia();
return Response.json(
{ roots: MEDIA_ROOTS, files },
diff --git a/umtool/app/browse/[...path]/page.tsx b/umtool/app/browse/[...path]/page.tsx
@@ -0,0 +1,88 @@
+import Link from "next/link";
+import { notFound } from "next/navigation";
+import BrowseHeader from "@/components/BrowseHeader";
+import ProjectView from "@/components/projects/ProjectView";
+import ProjectGrid from "@/components/projects/ProjectGrid";
+import { listProjects, resolveProjectPath } from "@/lib/projects";
+
+export const dynamic = "force-dynamic";
+
+// ---------------------------------------------------------------------------
+// Everything below /browse that is not a tool page.
+//
+// This replaced app/browse/[song]/ rather than joining it: two dynamic segments
+// at the same level is a Next routing conflict, so it is a delete-and-move. The
+// song pages themselves are unchanged -- they moved to components/projects/ and
+// are handed an id instead of awaiting params. /browse/yoshi/wide is still the
+// same URL and still renders the same page.
+//
+// Resolution is LONGEST-PREFIX: a project id is a path, so `a/b` being a project
+// must not stop `a/b/c` from being one too. Whatever is left over is a VIEW, and
+// which views exist is the kind's business, not this file's.
+// ---------------------------------------------------------------------------
+
+export default async function BrowsePathPage({
+ params,
+ searchParams,
+}: {
+ params: Promise<{ path?: string[] }>;
+ searchParams: Promise<Record<string, string | undefined>>;
+}) {
+ const { path: segments = [] } = await params;
+ const search = await searchParams;
+ const resolved = await resolveProjectPath(segments);
+ if (!resolved) notFound();
+
+ if (resolved.project) {
+ return <ProjectView project={resolved.project} rest={resolved.rest} search={search} />;
+ }
+
+ // Two projects answer to the same bare name. Which one you meant is not
+ // guessable, and guessing is how a link quietly starts pointing at the wrong
+ // thing -- so the page says so and offers both canonical paths.
+ if ("ambiguous" in resolved && resolved.ambiguous) {
+ return (
+ <div className="flex h-full flex-col">
+ <BrowseHeader
+ crumbs={[{ href: "/browse", label: "projects" }, { label: segments.join("/") }]}
+ note={`${resolved.ambiguous.length} projects answer to that name`}
+ />
+ <main className="deck-main flex-1 p-4">
+ <p className="mb-2 text-[12px] text-[var(--color-dim)]">
+ <code className="font-mono">{segments[0]}</code> is the name of more than one project,
+ so it cannot be a shortcut to any of them. Use a full path:
+ </p>
+ <ul className="space-y-1">
+ {resolved.ambiguous.map((p) => (
+ <li key={p.id}>
+ <Link
+ href={`/browse/${p.id}${segments.slice(1).map((s) => `/${s}`).join("")}`}
+ className="font-mono text-[12px] text-[var(--color-sel)] hover:underline"
+ >
+ /browse/{p.id}
+ </Link>
+ </li>
+ ))}
+ </ul>
+ </main>
+ </div>
+ );
+ }
+
+ // A folder: the same grid, filtered to what is under it.
+ const folder = resolved.folder;
+ if (!folder) notFound();
+ const all = await listProjects();
+ const mine = all.filter((p) => p.id.startsWith(`${folder.path}/`));
+ return (
+ <div className="flex h-full flex-col">
+ <BrowseHeader
+ crumbs={[{ href: "/browse", label: "projects" }, { label: folder.label }]}
+ note={`${mine.length} project${mine.length === 1 ? "" : "s"}`}
+ />
+ <main className="deck-main flex-1 p-4">
+ <ProjectGrid projects={mine} />
+ </main>
+ </div>
+ );
+}
diff --git a/umtool/app/browse/[song]/[cut]/page.tsx b/umtool/app/browse/[song]/[cut]/page.tsx
@@ -1,122 +0,0 @@
-import Link from "next/link";
-import { notFound } from "next/navigation";
-import BrowseHeader from "@/components/BrowseHeader";
-import CutBench from "@/components/CutBench";
-import ProvenancePanel from "@/components/ProvenancePanel";
-import { isCutName, probeAll, readSong } from "@/lib/browse";
-import { fmtBytes, fmtDur } from "@/lib/format";
-import { buildStatus } from "@/lib/manifest";
-import { readProvenance } from "@/lib/provenance";
-import { readNotes } from "@/lib/notes";
-import { readCompares } from "@/lib/compares";
-import LoudnessTable from "@/components/LoudnessTable";
-import { cachedLoudness } from "@/lib/loudness";
-import { DEFAULT_TARGET } from "@/lib/loudness-types";
-import { readSpec } from "@/lib/spec";
-import { resolveRendition } from "@/lib/browse";
-
-export const dynamic = "force-dynamic";
-
-export default async function CutPage({
- params,
- searchParams,
-}: {
- params: Promise<{ song: string; cut: string }>;
- searchParams: Promise<{ v?: string; plan?: string; notes?: string }>;
-}) {
- const { song: id, cut: cutName } = await params;
- const { v, plan, notes: notesParam } = await searchParams;
- if (!isCutName(cutName)) notFound();
-
- const song = await readSong(id);
- if (!song) notFound();
- const cut = song.cuts.find((c) => c.name === cutName);
- if (!cut) notFound();
-
- const rels = [cut.shipped?.rel, ...cut.variants.map((x) => x.rel)].filter(
- (r): r is string => !!r,
- );
- const info = await probeAll(id, rels);
-
- const durations: Record<string, number> = {};
- for (const [rel, i] of Object.entries(info)) durations[rel] = i.duration;
-
- // The recipe is recorded per FILE, so the shipped cut is the one it describes.
- // A named plan in build.json is a fact; the ?plan= override is the user's
- // choice where no builder recorded one.
- const build = cut.shipped
- ? await buildStatus(id, cut.shipped.rel)
- : { entry: null, stale: null };
- const prov = await readProvenance(id, plan ?? build.entry?.plans[0] ?? null);
- const notes = await readNotes(id);
- const compares = await readCompares(id);
-
- // Cached figures only. Measuring here would put an ffmpeg decode per file in
- // front of every navigation to this page.
- const spec = await readSpec(id);
- const loudness = await Promise.all(
- rels.map(async (rel) => {
- const abs = resolveRendition(id, rel);
- return { rel, loudness: abs ? await cachedLoudness(abs) : null };
- }),
- );
- const target = {
- lufs: spec.loudness?.targetLufs ?? DEFAULT_TARGET.lufs,
- truePeak: spec.loudness?.truePeak ?? DEFAULT_TARGET.truePeak,
- };
-
- return (
- <div className="flex h-full flex-col">
- <BrowseHeader
- crumbs={[
- { href: "/browse", label: "songs" },
- { href: `/browse/${song.id}`, label: song.id },
- { label: cutName },
- ]}
- note={
- cut.shipped
- ? `${fmtDur(durations[cut.shipped.rel] ?? 0)} · ${fmtBytes(cut.shipped.size)}`
- : "not built"
- }
- />
- <main className="deck-main flex-1">
- {!cut.shipped && cut.variants.length === 0 ? (
- <div className="p-4 text-[12px] text-[var(--color-dim)]">
- Nothing here yet — no <code className="font-mono">{cutName}.mp4</code> and no variants
- named for it.{" "}
- <Link href={`/browse/${song.id}`} className="text-[var(--color-sel)] underline">
- back to {song.id}
- </Link>
- </div>
- ) : (
- <>
- <CutBench
- song={song.id}
- cut={cutName}
- shipped={cut.shipped}
- variants={cut.variants}
- durations={durations}
- initialVariant={v ?? null}
- notes={notes}
- compares={compares}
- />
- <div className="px-4 pb-4">
- <LoudnessTable song={song.id} initial={loudness} target={target} />
- </div>
- <div className="px-4 pb-4">
- <ProvenancePanel
- song={song.id}
- cut={cutName}
- prov={prov}
- build={build}
- planParam={plan ?? null}
- notes={notes}
- allNotes={notesParam === "all"}
- />
- </div>
- </>
- )}
- </main>
- </div>
- );
-}
diff --git a/umtool/app/browse/[song]/page.tsx b/umtool/app/browse/[song]/page.tsx
@@ -1,254 +0,0 @@
-import Link from "next/link";
-import { notFound } from "next/navigation";
-import BrowseHeader from "@/components/BrowseHeader";
-import VerdictChip from "@/components/VerdictChip";
-import SpecSheet from "@/components/SpecSheet";
-import NoteField from "@/components/NoteField";
-import CopyButton from "@/components/CopyButton";
-import { readSong, probeAll, thumbAliasesFor, type Cut, type Song } from "@/lib/browse";
-import ThumbBench from "@/components/ThumbBench";
-import { thumbView } from "@/lib/thumbs";
-import { fmtAgo, fmtBytes, fmtDur } from "@/lib/format";
-import { Markdown } from "@/lib/markdown";
-import { operationsFor, readSpec, validateSpec } from "@/lib/spec";
-import { readNotes, type NoteMap } from "@/lib/notes";
-import { TRIM_SETS } from "@/lib/trim";
-
-export const dynamic = "force-dynamic";
-
-export default async function SongPage({ params }: { params: Promise<{ song: string }> }) {
- const { song: id } = await params;
- const song = await readSong(id);
- if (!song) notFound();
-
- // Durations are worth an ffprobe HERE but not on the index: this page is a
- // handful of files and "which of these is the short cut" is exactly the
- // question it answers. Memoised by mtime in lib/browse.ts.
- const rels = [
- ...song.cuts.flatMap((c) => [c.shipped?.rel, ...c.variants.map((v) => v.rel)]),
- ...song.unattributed.map((v) => v.rel),
- ].filter((r): r is string => !!r);
- const info = await probeAll(id, rels);
-
- const spec = await readSpec(id);
- const problems = await validateSpec(spec);
- const notes = await readNotes(id);
- // Two small JSON reads, no probing -- the bench draws what the manifests say.
- const thumbs = await thumbView(id, thumbAliasesFor(id));
-
- return (
- <div className="flex h-full flex-col">
- <BrowseHeader
- crumbs={[{ href: "/browse", label: "songs" }, { label: song.id }]}
- note={`${song.present}/${song.cuts.length} cuts · ${song.variantCount} variants`}
- />
- <div className="flex flex-wrap items-start gap-3 border-b border-[var(--color-line)] px-4 py-2">
- <div className="min-w-0 flex-1">
- {/* The one obvious place to write about the song, open by default --
- everything else on the page is collapsed until asked for. */}
- <NoteField song={id} target="song" initial={notes.song ?? null} label="notes on this song" />
- </div>
- <CopyButton
- label="copy this song"
- title="this song as markdown — spec, cuts, verdicts, notes and resolved marks"
- url={`/api/browse/context?song=${encodeURIComponent(id)}`}
- />
- </div>
- <main className="deck-main flex-1">
- <div className="grid gap-4 p-4 xl:grid-cols-[minmax(0,2fr)_minmax(0,1fr)]">
- <div className="space-y-3">
- {song.cuts.map((cut) => (
- <CutCard key={cut.name} song={song} cut={cut} info={info} notes={notes} />
- ))}
-
- {song.unattributed.length > 0 && (
- <section className="rounded border border-[var(--color-dirty)]/40 bg-[var(--color-panel)] p-3">
- <div className="micro mb-2">
- matches no cut name — attribute by renaming, never by guessing
- </div>
- <ul className="space-y-1">
- {song.unattributed.map((v) => (
- <li key={v.rel} className="num text-[12px] text-[var(--color-dim)]">
- <span className="font-mono text-[var(--color-text)]">{v.rel}</span>{" "}
- {fmtBytes(v.size)}
- </li>
- ))}
- </ul>
- </section>
- )}
- </div>
-
- <aside className="space-y-4">
- <ThumbBench song={song.id} view={thumbs} notes={notes} />
- <SpecSheet
- song={song.id}
- initial={spec}
- problems={problems}
- operations={operationsFor(spec, song)}
- plans={song.plans.map((p) => p.name)}
- trimSets={Object.values(TRIM_SETS).map((t) => ({ id: t.id, label: t.label }))}
- />
- {song.readme && (
- <section className="rounded border border-[var(--color-line)] bg-[var(--color-panel)] p-3">
- <Markdown text={song.readme} />
- </section>
- )}
- <section className="rounded border border-[var(--color-line)] bg-[var(--color-panel)] p-3">
- <div className="micro mb-2">plans</div>
- {song.plans.length === 0 ? (
- <p className="text-[12px] text-[var(--color-dim)]">no plan/ directory</p>
- ) : (
- <ul className="space-y-0.5">
- {song.plans.map((p) => (
- <li key={p.name} className="num text-[11px] text-[var(--color-dim)]">
- <span className="flex gap-2">
- <span className="truncate font-mono text-[var(--color-text)]">{p.name}</span>
- <span className="ml-auto shrink-0">{fmtBytes(p.size)}</span>
- </span>
- <NoteField
- song={song.id}
- target={`plan:${p.name}`}
- initial={notes[`plan:${p.name}`] ?? null}
- label="what this plan is"
- rows={3}
- />
- </li>
- ))}
- </ul>
- )}
- {song.hasClipsCsv && <div className="micro mt-2">clips.csv present</div>}
- </section>
- </aside>
- </div>
- </main>
- </div>
- );
-}
-
-function CutCard({
- song,
- cut,
- info,
- notes,
-}: {
- song: Song;
- cut: Cut;
- info: Record<string, { duration: number; width: number; height: number }>;
- notes: NoteMap;
-}) {
- const shipped = cut.shipped;
- const live = cut.variants.filter((v) => !v.retired);
- const retired = cut.variants.filter((v) => v.retired);
-
- return (
- <section
- data-cut={cut.name}
- data-present={shipped ? "1" : "0"}
- className="rounded border border-[var(--color-line)] bg-[var(--color-panel)]"
- >
- <div className="flex items-start gap-3 p-3">
- {shipped ? (
- /* eslint-disable-next-line @next/next/no-img-element */
- <img
- src={`/api/browse/poster?song=${encodeURIComponent(song.id)}&rel=${encodeURIComponent(shipped.rel)}&w=320`}
- alt=""
- width={160}
- height={90}
- className="w-40 shrink-0 rounded bg-[var(--color-panel-2)] object-cover"
- />
- ) : (
- <div className="flex h-[90px] w-40 shrink-0 items-center justify-center rounded border border-dashed border-[var(--color-line)] text-[11px] text-[var(--color-dim)]">
- not built
- </div>
- )}
-
- <div className="min-w-0 flex-1 space-y-1">
- <div className="flex flex-wrap items-baseline gap-2">
- <Link
- href={`/browse/${song.id}/${cut.name}`}
- className="font-mono text-[13px] text-[var(--color-text)] hover:text-[var(--color-sel)]"
- >
- {cut.name}
- </Link>
- {shipped ? (
- <span className="num text-[11px] text-[var(--color-meter)]">
- {fmtDur(info[shipped.rel]?.duration ?? 0)}
- </span>
- ) : (
- <span className="text-[11px] text-[var(--color-dim)]">— a hole in the set</span>
- )}
- {shipped && info[shipped.rel] && (
- <span className="num text-[11px] text-[var(--color-dim)]">
- {info[shipped.rel].width}×{info[shipped.rel].height}
- </span>
- )}
- <span className="num ml-auto text-[11px] text-[var(--color-dim)]">
- {shipped ? `${fmtBytes(shipped.size)} · ${fmtAgo(shipped.mtimeMs)}` : ""}
- </span>
- </div>
-
- {shipped && (
- <VerdictChip song={song.id} rel={shipped.rel} initial={shipped.verdict} />
- )}
-
- {/* Two different notes, deliberately. The `cut:` one is about the SLOT
- -- it survives a promote and can be written about a cut that has
- not been built. The `file:` one is about these bytes. */}
- <NoteField
- song={song.id}
- target={`cut:${cut.name}`}
- initial={notes[`cut:${cut.name}`] ?? null}
- label={`notes on ${cut.name}`}
- rows={3}
- />
- {shipped && (
- <NoteField
- song={song.id}
- target={`file:${shipped.rel}`}
- initial={notes[`file:${shipped.rel}`] ?? null}
- label="notes on this file"
- rows={3}
- />
- )}
-
- {live.length > 0 && (
- <ul className="space-y-1 pt-1">
- {live.map((v) => (
- <li
- key={v.rel}
- data-variant={v.rel}
- data-variant-tag={v.tag}
- className="flex flex-wrap items-center gap-2 rounded bg-[var(--color-panel-2)] px-2 py-1"
- >
- <span className="font-mono text-[12px] text-[var(--color-text)]">{v.tag}</span>
- <span className="num text-[11px] text-[var(--color-meter)]">
- {fmtDur(info[v.rel]?.duration ?? 0)}
- </span>
- <span className="num text-[11px] text-[var(--color-dim)]">{fmtBytes(v.size)}</span>
- <div className="ml-auto">
- <VerdictChip song={song.id} rel={v.rel} initial={v.verdict} />
- </div>
- <div className="w-full">
- <NoteField
- song={song.id}
- target={`file:${v.rel}`}
- initial={notes[`file:${v.rel}`] ?? null}
- label={`notes on ${v.tag}`}
- rows={3}
- />
- </div>
- </li>
- ))}
- </ul>
- )}
-
- {retired.length > 0 && (
- <div className="micro pt-1" data-retired={retired.length}>
- {retired.length} retired: {retired.map((v) => v.tag).join(", ")}
- </div>
- )}
- </div>
- </div>
- </section>
- );
-}
diff --git a/umtool/app/browse/at/page.tsx b/umtool/app/browse/at/page.tsx
@@ -0,0 +1,53 @@
+import Link from "next/link";
+import { notFound } from "next/navigation";
+import ProjectView from "@/components/projects/ProjectView";
+import { projectRefs } from "@/lib/projects";
+
+export const dynamic = "force-dynamic";
+
+// ---------------------------------------------------------------------------
+// The escape hatch for a project that cannot be reached at its own URL.
+//
+// Two ways that happens, and both used to fail silently. A project whose first
+// path segment is a tool page name -- `find`, `trim`, `decisions` -- can never
+// win the route, because a static segment beats a dynamic one; it was listed,
+// linked, and the link rendered the phrase console. And a project whose name
+// isSegment() dislikes (a space is enough) simply vanished from the listing.
+//
+// `?path=` is validated by MEMBERSHIP of the current scan, not by inspecting the
+// string: it either is one of the projects the walk found or it is a 404. That
+// is the same rule every other name that crosses the wire in this app follows,
+// and it is why this page cannot be turned into a file reader.
+// ---------------------------------------------------------------------------
+
+export default async function AtPage({
+ searchParams,
+}: {
+ searchParams: Promise<Record<string, string | undefined>>;
+}) {
+ const search = await searchParams;
+ const wanted = search.path;
+ const project = wanted ? (await projectRefs()).find((p) => p.id === wanted) : null;
+ if (!project) notFound();
+
+ const why =
+ project.routing === "shadowed"
+ ? `/browse/${project.id.split("/")[0]} is a tool page, so this project can never be opened at its own URL. Rename the directory, or work here.`
+ : project.routing === "unroutable"
+ ? "This directory's name cannot be a URL segment, so it has no address of its own. Rename it to letters, digits, dots, dashes and underscores to give it one."
+ : null;
+
+ return (
+ <>
+ {why && (
+ <div className="border-b border-[var(--color-bad)] bg-[color-mix(in_srgb,var(--color-bad)_10%,transparent)] px-4 py-2 text-[12px] text-[var(--color-bad)]">
+ {why}{" "}
+ <Link href="/browse" className="underline">
+ every project
+ </Link>
+ </div>
+ )}
+ <ProjectView project={project} rest={[]} search={search} />
+ </>
+ );
+}
diff --git a/umtool/app/browse/decisions/page.tsx b/umtool/app/browse/decisions/page.tsx
@@ -1,4 +1,5 @@
import Link from "next/link";
+import { badgeVariants, type BadgeVariants } from "@/components/ui/badge";
import BrowseHeader from "@/components/BrowseHeader";
import CopyButton from "@/components/CopyButton";
import {
@@ -22,10 +23,13 @@ export const dynamic = "force-dynamic";
// Zero client JS. The filters are links that change searchParams; nothing here
// changes without a navigation, and the list is short by construction.
-const TONE: Record<Severity, string> = {
- blocking: "text-[var(--color-bad)] border-[var(--color-bad)]",
- open: "text-[var(--color-dirty)] border-[var(--color-dirty)]",
- info: "text-[var(--color-dim)] border-[var(--color-line)]",
+// The three severities ARE three badge variants, one for one. That is not a
+// coincidence to be tidied away: the palette reserves its hues for verdicts,
+// and a severity is the closest thing this list has to one.
+const TONE: Record<Severity, BadgeVariants["variant"]> = {
+ blocking: "blocking",
+ open: "open",
+ info: "info",
};
export default async function DecisionsPage({
@@ -136,9 +140,7 @@ export default async function DecisionsPage({
data-target={d.target}
className="flex flex-wrap items-baseline gap-2 rounded border border-[var(--color-line)] bg-[var(--color-panel)] px-3 py-1.5"
>
- <span
- className={`rounded border px-1.5 py-0.5 text-[10px] uppercase tracking-wider ${TONE[d.severity]}`}
- >
+ <span className={badgeVariants({ variant: TONE[d.severity], size: "sm" })}>
{d.severity}
</span>
<Link
@@ -173,17 +175,16 @@ function Chip({
href: string;
on: boolean;
label: string;
- tone?: string;
+ tone?: BadgeVariants["variant"];
}) {
return (
<Link
href={href}
+ // Set by hand, not by the variant. It is what the e2e suite asserts on,
+ // and it is the actual accessibility statement -- the colour is only the
+ // visible half of it.
aria-current={on ? "true" : undefined}
- className={`rounded border px-1.5 py-0.5 font-mono text-[11px] ${
- on
- ? "border-[var(--color-sel)] text-[var(--color-sel)]"
- : (tone ?? "border-[var(--color-line)] text-[var(--color-dim)] hover:text-[var(--color-text)]")
- }`}
+ className={badgeVariants({ variant: on ? "on" : (tone ?? "neutral") })}
>
{label}
</Link>
diff --git a/umtool/app/browse/page.tsx b/umtool/app/browse/page.tsx
@@ -1,35 +1,180 @@
import Link from "next/link";
+import { badgeVariants, type BadgeVariants } from "@/components/ui/badge";
import BrowseHeader from "@/components/BrowseHeader";
import NewSongForm from "@/components/NewSongForm";
import CopyButton from "@/components/CopyButton";
-import { CUT_NAMES, listSongs } from "@/lib/browse";
-import { fmtAgo } from "@/lib/format";
+import ProjectGrid from "@/components/projects/ProjectGrid";
+import { KINDS, decisionCounts, listFolders, listProjects } from "@/lib/projects";
+import { PROJECT_STATES } from "@/lib/project-types";
export const dynamic = "force-dynamic";
// A STATIC segment, so it wins over app/[mode]/page.tsx -- which would
// otherwise catch /browse, fail isMode(), and 404. Same trick as /mix.
//
-// Zero client JS on this level. It is a list of five cards; every poster is an
-// <img> the browser fetches on its own, and nothing here changes without a
-// navigation.
+// ZERO CLIENT JS, still. The four filters are links that change searchParams,
+// exactly as /browse/decisions does it, and `?q=` is a plain GET form carrying
+// the others as hidden inputs -- the /browse/find idiom -- so any filtered view
+// is one pasteable URL. Nothing here hydrates.
+//
+// Counts on the chips come from the UNFILTERED set on purpose. A chip whose
+// number changes when you click a different chip moves under the cursor, and
+// the whole point of a filter row is to say how much is behind each one.
+
+type Search = {
+ kind?: string;
+ template?: string;
+ state?: string;
+ open?: string;
+ q?: string;
+ sort?: string;
+};
+
+export default async function BrowsePage({
+ searchParams,
+}: {
+ searchParams: Promise<Search>;
+}) {
+ const sp = await searchParams;
+ const { kind, template, state, open, q, sort } = sp;
+
+ const all = await listProjects();
+ const counts = await decisionCounts();
+ const folders = await listFolders();
+
+ const needle = (q ?? "").trim().toLowerCase();
+ const match = (p: (typeof all)[number]) => {
+ const c = counts.get(p.id);
+ return (
+ (!kind || p.kind === kind) &&
+ (!template || p.template === template) &&
+ (!state || p.state === state) &&
+ (!open ||
+ (open === "blocking" ? (c?.blocking ?? 0) > 0 : (c?.blocking ?? 0) + (c?.open ?? 0) > 0)) &&
+ (!needle || p.haystack.includes(needle))
+ );
+ };
+
+ const items = all.filter(match);
+ const sorted =
+ sort === "name" ? [...items].sort((a, b) => a.id.localeCompare(b.id)) : items;
+
+ const nOf = (pred: (p: (typeof all)[number]) => boolean) => all.filter(pred).length;
-export default async function BrowsePage() {
- const songs = await listSongs();
+ const qs = (next: Partial<Search>) => {
+ const p = new URLSearchParams();
+ for (const [k, v] of Object.entries({ ...sp, ...next })) if (v) p.set(k, String(v));
+ const s = p.toString();
+ return `/browse${s ? `?${s}` : ""}`;
+ };
+
+ // Group by folder, using the COLLAPSED labels but never collapsed URLs.
+ const groups = new Map<string, typeof sorted>();
+ for (const p of sorted) {
+ // Which displayed folder owns this project: the deepest collapsed node
+ // whose path is a prefix of the project's id.
+ let owner = "";
+ for (const f of folders.keys()) {
+ if (f && (p.id === f || p.id.startsWith(`${f}/`)) && f.length > owner.length) owner = f;
+ }
+ const list = groups.get(owner);
+ if (list) list.push(p);
+ else groups.set(owner, [p]);
+ }
+
+ const blocking = [...counts.values()].reduce((n, c) => n + c.blocking, 0);
+ const openN = [...counts.values()].reduce((n, c) => n + c.open, 0);
return (
<div className="flex h-full flex-col">
<BrowseHeader
- crumbs={[{ label: "songs" }]}
- note={`${songs.length} songs · ${CUT_NAMES.length} cuts each`}
+ crumbs={[{ label: "projects" }]}
+ note={`${all.length} projects · ${blocking} blocking · ${openN} open`}
/>
<main className="deck-main flex-1 p-4">
+ {/* --- filters, as links ---------------------------------------- */}
+ <div className="mb-2 flex flex-wrap items-center gap-1.5">
+ <span className="micro">kind</span>
+ <Chip href={qs({ kind: "", template: "" })} on={!kind && !template} label={`all ${all.length}`} />
+ {KINDS.map((k) => (
+ <Chip
+ key={k.id}
+ href={qs({ kind: k.id, template: "" })}
+ on={kind === k.id && !template}
+ label={`${k.label} ${nOf((p) => p.kind === k.id)}`}
+ />
+ ))}
+ </div>
+
+ <div className="mb-2 flex flex-wrap items-center gap-1.5">
+ <span className="micro">state</span>
+ <Chip href={qs({ state: "" })} on={!state} label="any" />
+ {PROJECT_STATES.filter((s) => nOf((p) => p.state === s) > 0).map((s) => (
+ <Chip
+ key={s}
+ href={qs({ state: s })}
+ on={state === s}
+ label={`${s} ${nOf((p) => p.state === s)}`}
+ />
+ ))}
+
+ <span className="micro ml-3">decisions</span>
+ <Chip href={qs({ open: "" })} on={!open} label="any" />
+ <Chip
+ href={qs({ open: "blocking" })}
+ on={open === "blocking"}
+ label={`blocking ${nOf((p) => (counts.get(p.id)?.blocking ?? 0) > 0)}`}
+ tone="blocking"
+ />
+ <Chip
+ href={qs({ open: "1" })}
+ on={open === "1"}
+ label={`waiting ${nOf(
+ (p) => (counts.get(p.id)?.blocking ?? 0) + (counts.get(p.id)?.open ?? 0) > 0,
+ )}`}
+ />
+
+ <span className="micro ml-3">sort</span>
+ <Chip href={qs({ sort: "" })} on={sort !== "name"} label="recent" />
+ <Chip href={qs({ sort: "name" })} on={sort === "name"} label="name" />
+ </div>
+
<div className="mb-3 flex flex-wrap items-center gap-2">
+ {/* A GET form so the result is a pasteable URL, and hidden inputs so
+ searching does not throw away the filters you already set. */}
+ <form method="get" action="/browse" className="flex items-center gap-1.5">
+ {(["kind", "template", "state", "open", "sort"] as const).map((k) =>
+ sp[k] ? <input key={k} type="hidden" name={k} value={sp[k]} /> : null,
+ )}
+ <input
+ type="search"
+ name="q"
+ defaultValue={q ?? ""}
+ placeholder="title, id, source…"
+ aria-label="filter projects"
+ className="w-56 rounded border border-[var(--color-line)] bg-[var(--color-ink)] px-2 py-1 font-mono text-[12px] text-[var(--color-text)] placeholder:text-[var(--color-dim)]"
+ />
+ <button
+ type="submit"
+ className="rounded border border-[var(--color-line)] px-2 py-1 text-[11px] text-[var(--color-dim)] hover:text-[var(--color-text)]"
+ >
+ filter
+ </button>
+ {q && (
+ <Link href={qs({ q: "" })} className="micro hover:text-[var(--color-text)]">
+ clear
+ </Link>
+ )}
+ </form>
+
<NewSongForm />
<div className="ml-auto flex items-center gap-2">
- {/* One paste that describes the whole project: every song, what
- ships, every judgement with its reason, every mark resolved to
- its source moment. */}
+ <Link
+ href="/browse/decisions"
+ className="rounded border border-[var(--color-line)] px-2.5 py-1 text-[12px] text-[var(--color-dim)] hover:text-[var(--color-text)]"
+ >
+ decisions
+ </Link>
<CopyButton
label="copy the whole picture"
title="every song as markdown — spec, cuts, verdicts, notes and resolved marks"
@@ -38,69 +183,53 @@ export default async function BrowsePage() {
/>
</div>
</div>
- {songs.length === 0 ? (
+
+ {all.length === 0 ? (
<p className="text-[12px] text-[var(--color-dim)]">
- Nothing under <code className="font-mono">videos/</code>. Point{" "}
- <code className="font-mono">SONG_REPORTS_DIR</code> at a tree of songs.
+ No projects under <code className="font-mono">REPORTS_DIR</code>. A directory becomes
+ one by holding a <code className="font-mono">video.manifest.json</code>, a{" "}
+ <code className="font-mono">spec.json</code>, or a sweep report.
</p>
) : (
- <ul className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3 2xl:grid-cols-4">
- {songs.map((s) => (
- <li key={s.id}>
- <Link
- href={`/browse/${s.id}`}
- data-song={s.id}
- className="block overflow-hidden rounded border border-[var(--color-line)] bg-[var(--color-panel)] transition-colors hover:border-[var(--color-sel)]"
- >
- {/* eslint-disable-next-line @next/next/no-img-element */}
- <img
- src={`/api/browse/poster?song=${encodeURIComponent(s.id)}&w=640`}
- alt=""
- width={640}
- height={360}
- className="aspect-video w-full bg-[var(--color-panel-2)] object-cover"
- />
- <div className="space-y-1.5 p-3">
- <div className="truncate text-[13px] font-medium text-[var(--color-text)]">
- {s.title}
- </div>
- <div className="num flex flex-wrap items-center gap-x-3 gap-y-1 text-[11px] text-[var(--color-dim)]">
- <span data-cuts={s.present}>
- <span className="text-[var(--color-meter)]">{s.present}</span>/
- {CUT_NAMES.length} cuts
- </span>
- <span data-variants={s.variantCount}>
- <span className="text-[var(--color-meter)]">{s.variantCount}</span> variants
- </span>
- <span className="ml-auto">{fmtAgo(s.newestMtimeMs)}</span>
- </div>
- {/* The verdict tally is the only place these three hues
- appear outside a verdict control. Undecided is
- deliberately NOT coloured -- it is the absence of a
- judgement, not a third one. */}
- <div className="num flex items-center gap-2 text-[11px]">
- <span className="text-[var(--color-good)]" data-keep={s.counts.keep}>
- {s.counts.keep} keep
- </span>
- <span className="text-[var(--color-bad)]" data-reject={s.counts.reject}>
- {s.counts.reject} reject
- </span>
- <span className="text-[var(--color-dim)]" data-undecided={s.counts.undecided}>
- {s.counts.undecided} undecided
- </span>
- </div>
- {s.missing.length > 0 && (
- <div className="micro" data-missing={s.missing.join(",")}>
- no {s.missing.join(", ")}
- </div>
- )}
- </div>
- </Link>
- </li>
+ <div className="space-y-5">
+ {[...groups.entries()].map(([folderPath, list]) => (
+ <section key={folderPath || "_root"} data-folder={folderPath}>
+ {folderPath && (
+ <h2 className="micro mb-1.5">
+ {folders.get(folderPath)?.label ?? folderPath}
+ </h2>
+ )}
+ <ProjectGrid projects={list} counts={counts} />
+ </section>
))}
- </ul>
+ </div>
)}
</main>
</div>
);
}
+
+function Chip({
+ href,
+ on,
+ label,
+ tone,
+}: {
+ href: string;
+ on: boolean;
+ label: string;
+ tone?: BadgeVariants["variant"];
+}) {
+ return (
+ <Link
+ href={href}
+ // Set by hand, not by the variant. It is what the e2e suite asserts on,
+ // and it is the actual accessibility statement -- the colour is only the
+ // visible half of it.
+ aria-current={on ? "true" : undefined}
+ className={badgeVariants({ variant: on ? "on" : (tone ?? "neutral") })}
+ >
+ {label}
+ </Link>
+ );
+}
diff --git a/umtool/app/globals.css b/umtool/app/globals.css
@@ -35,6 +35,44 @@
/* measurement ONLY -- the pitch rail, and every number that is a reading */
--color-meter: #56d4c4;
+
+ /* ---------------------------------------------------------------------
+ shadcn/ui's token names, MAPPED ONTO THE PALETTE ABOVE. Not imported.
+
+ Its default theme ships `primary`, `accent` and `destructive` as their own
+ hues. Adopting those would walk straight back into the rule this file
+ opens with: a second green marked the active nav link while competing with
+ --color-good two inches away, so a navigation state wore a verdict's
+ clothes, and --color-accent was DELETED rather than retuned.
+
+ So every shadcn name is an alias for a colour that already means something
+ here, and `accent` is mapped to a SURFACE rather than to a hue -- shadcn
+ uses it for hover backgrounds, which is a surface change, not a statement.
+ No new colour enters the app, and `cn()` + cva give us the variants and the
+ class-merging without the theme.
+ --------------------------------------------------------------------- */
+ --color-background: var(--color-ink);
+ --color-foreground: var(--color-text);
+ --color-card: var(--color-panel);
+ --color-card-foreground: var(--color-text);
+ --color-popover: var(--color-panel);
+ --color-popover-foreground: var(--color-text);
+ --color-muted: var(--color-panel-2);
+ --color-muted-foreground: var(--color-dim);
+ /* A surface, never a hue. */
+ --color-accent: var(--color-panel-2);
+ --color-accent-foreground: var(--color-text);
+ --color-secondary: var(--color-panel-2);
+ --color-secondary-foreground: var(--color-text);
+ --color-border: var(--color-line);
+ --color-input: var(--color-line);
+ --color-primary: var(--color-sel);
+ --color-primary-foreground: var(--color-ink);
+ --color-ring: var(--color-sel);
+ --color-destructive: var(--color-bad);
+ --color-destructive-foreground: var(--color-ink);
+
+ --radius: 4px;
}
html,
diff --git a/umtool/components.json b/umtool/components.json
@@ -0,0 +1,21 @@
+{
+ "$schema": "https://ui.shadcn.com/schema.json",
+ "style": "new-york",
+ "rsc": true,
+ "tsx": true,
+ "tailwind": {
+ "config": "",
+ "css": "app/globals.css",
+ "baseColor": "slate",
+ "cssVariables": true,
+ "prefix": ""
+ },
+ "iconLibrary": "lucide",
+ "aliases": {
+ "components": "@/components",
+ "utils": "@/lib/utils",
+ "ui": "@/components/ui",
+ "lib": "@/lib",
+ "hooks": "@/hooks"
+ }
+}
diff --git a/umtool/components/MixBench.tsx b/umtool/components/MixBench.tsx
@@ -4,6 +4,7 @@ import { useCallback, useEffect, useRef, useState } from "react";
import MixLanes, { type Analysis, type Marker } from "./MixLanes";
type FileRow = { path: string; label: string; size: number; mtimeMs: number };
+type FileGroup = { project: string; label: string; kind: string; files: FileRow[] };
type Info = { path: string; label: string; duration: number; hasVideo: boolean; hasAudio: boolean };
type Track = { info: Info; analysis: Analysis | null };
@@ -60,7 +61,11 @@ export function bgGainAt(t: number, s: Pick<Spec, "handover" | "fade" | "bgGain"
}
export default function MixBench() {
- const [files, setFiles] = useState<FileRow[]>([]);
+ const [groups, setGroups] = useState<FileGroup[]>([]);
+ const [other, setOther] = useState<FileRow[]>([]);
+ // The only client state the picker adds. A twelve-project tree makes the
+ // select long enough that scrolling it is worse than typing three letters.
+ const [filter, setFilter] = useState("");
const [spec, setSpec] = useState<Spec>(BLANK);
const [body, setBody] = useState<Track | null>(null);
const [bg, setBg] = useState<Track | null>(null);
@@ -86,10 +91,11 @@ export default function MixBench() {
useEffect(() => {
void (async () => {
const [f, s] = await Promise.all([
- fetch("/api/mix/files", { cache: "no-store" }).then((r) => r.json()),
+ fetch("/api/mix/files?group=project", { cache: "no-store" }).then((r) => r.json()),
fetch("/api/mix/session", { cache: "no-store" }).then((r) => r.json()),
]);
- setFiles(f.files ?? []);
+ setGroups(f.groups ?? []);
+ setOther(f.other ?? []);
const last = s?.last ? s.byPair?.[s.last] : null;
if (last?.body) setSpec({ ...BLANK, ...last });
})();
@@ -328,21 +334,71 @@ export default function MixBench() {
setView({ from: Math.max(0, spec.handover - 3), to: Math.min(duration || spec.handover + 3, spec.handover + 3) });
}, [spec.handover, duration]);
+ // ---- the picker ----------------------------------------------------------
+ //
+ // Grouped by project, because a flat newest-first list with a cap could not
+ // show them all: four of six report deliverables fell off the end of a
+ // 600-entry list, and per-song cuts never appeared at all.
+ //
+ // The <option value> stays the ABSOLUTE path. That is what dodges the
+ // relative-binding hazard in resolveInRoots -- a relative label is tried
+ // against each root in order and never stats, so it binds to the first root it
+ // COULD live under whether or not it is there. Displaying the short label and
+ // sending the full path is what keeps that from mattering.
+ const needle = filter.trim().toLowerCase();
+ const keep = (f: FileRow) => !needle || f.label.toLowerCase().includes(needle);
+ const shownGroups = groups
+ .map((g) => ({ ...g, files: g.files.filter(keep) }))
+ .filter((g) => g.files.length > 0);
+ const shownOther = other.filter(keep);
+ const nShown = shownGroups.reduce((n, g) => n + g.files.length, 0) + shownOther.length;
+
const picker = (which: "body" | "bg") => (
<select
value={(which === "body" ? spec.body : spec.bg) ?? ""}
onChange={(e) => set(which === "body" ? { body: e.target.value } : { bg: e.target.value || null })}
+ data-picker={which}
className="w-full rounded border border-[var(--color-line)] bg-[var(--color-panel-2)] px-2 py-1.5 text-[13px] outline-none focus:border-[var(--color-sel)]"
>
<option value="">{which === "bg" ? "— none (song only) —" : "— choose a render —"}</option>
- {files.map((f) => (
- <option key={f.path} value={f.path}>
- {f.label}
- </option>
+ {shownGroups.map((g) => (
+ <optgroup key={g.project} label={g.label}>
+ {g.files.map((f) => (
+ <option key={f.path} value={f.path}>
+ {f.label}
+ </option>
+ ))}
+ </optgroup>
))}
+ {shownOther.length > 0 && (
+ <optgroup label="— other —">
+ {shownOther.map((f) => (
+ <option key={f.path} value={f.path}>
+ {f.label}
+ </option>
+ ))}
+ </optgroup>
+ )}
</select>
);
+ const pickerFilter = (
+ <label className="flex items-center gap-1.5">
+ <input
+ type="search"
+ value={filter}
+ onChange={(e) => setFilter(e.target.value)}
+ placeholder="filter files…"
+ aria-label="filter the file list"
+ data-picker-filter=""
+ className="w-full rounded border border-[var(--color-line)] bg-[var(--color-panel-2)] px-2 py-1 font-mono text-[11px] outline-none focus:border-[var(--color-sel)]"
+ />
+ <span className="micro whitespace-nowrap" data-picker-count={nShown}>
+ {nShown}
+ </span>
+ </label>
+ );
+
const field = (
label: string,
key: "handover" | "fade" | "bgGain" | "duck" | "start" | "end",
@@ -364,6 +420,7 @@ export default function MixBench() {
return (
<div className="mx-auto w-full max-w-6xl p-4">
+ <div className="mb-2">{pickerFilter}</div>
<div className="mb-3 grid gap-3 md:grid-cols-2">
<div>
<div className="micro mb-1">song — the render with the picture</div>
diff --git a/umtool/components/projects/CutPage.tsx b/umtool/components/projects/CutPage.tsx
@@ -0,0 +1,125 @@
+import Link from "next/link";
+import { notFound } from "next/navigation";
+import BrowseHeader from "@/components/BrowseHeader";
+import CutBench from "@/components/CutBench";
+import ProvenancePanel from "@/components/ProvenancePanel";
+import { isCutName, probeAll, readSong } from "@/lib/browse";
+import { fmtBytes, fmtDur } from "@/lib/format";
+import { buildStatus } from "@/lib/manifest";
+import { readProvenance } from "@/lib/provenance";
+import { readNotes } from "@/lib/notes";
+import { readCompares } from "@/lib/compares";
+import LoudnessTable from "@/components/LoudnessTable";
+import { cachedLoudness } from "@/lib/loudness";
+import { DEFAULT_TARGET } from "@/lib/loudness-types";
+import { readSpec } from "@/lib/spec";
+import { resolveRendition } from "@/lib/browse";
+
+// Moved out of app/browse/[song]/[cut]/page.tsx unchanged. `/browse/<song>/wide`
+// is now a project id plus a VIEW, resolved by the registry rather than by a
+// route segment -- the URL is byte-identical either way, which is the point.
+
+export default async function CutPage({
+ id,
+ cutName,
+ search,
+}: {
+ id: string;
+ cutName: string;
+ search: { v?: string; plan?: string; notes?: string };
+}) {
+ const { v, plan, notes: notesParam } = search;
+ if (!isCutName(cutName)) notFound();
+
+ const song = await readSong(id);
+ if (!song) notFound();
+ const cut = song.cuts.find((c) => c.name === cutName);
+ if (!cut) notFound();
+
+ const rels = [cut.shipped?.rel, ...cut.variants.map((x) => x.rel)].filter(
+ (r): r is string => !!r,
+ );
+ const info = await probeAll(id, rels);
+
+ const durations: Record<string, number> = {};
+ for (const [rel, i] of Object.entries(info)) durations[rel] = i.duration;
+
+ // The recipe is recorded per FILE, so the shipped cut is the one it describes.
+ // A named plan in build.json is a fact; the ?plan= override is the user's
+ // choice where no builder recorded one.
+ const build = cut.shipped
+ ? await buildStatus(id, cut.shipped.rel)
+ : { entry: null, stale: null };
+ const prov = await readProvenance(id, plan ?? build.entry?.plans[0] ?? null);
+ const notes = await readNotes(id);
+ const compares = await readCompares(id);
+
+ // Cached figures only. Measuring here would put an ffmpeg decode per file in
+ // front of every navigation to this page.
+ const spec = await readSpec(id);
+ const loudness = await Promise.all(
+ rels.map(async (rel) => {
+ const abs = resolveRendition(id, rel);
+ return { rel, loudness: abs ? await cachedLoudness(abs) : null };
+ }),
+ );
+ const target = {
+ lufs: spec.loudness?.targetLufs ?? DEFAULT_TARGET.lufs,
+ truePeak: spec.loudness?.truePeak ?? DEFAULT_TARGET.truePeak,
+ };
+
+ return (
+ <div className="flex h-full flex-col">
+ <BrowseHeader
+ crumbs={[
+ { href: "/browse", label: "songs" },
+ { href: `/browse/${song.id}`, label: song.id },
+ { label: cutName },
+ ]}
+ note={
+ cut.shipped
+ ? `${fmtDur(durations[cut.shipped.rel] ?? 0)} · ${fmtBytes(cut.shipped.size)}`
+ : "not built"
+ }
+ />
+ <main className="deck-main flex-1">
+ {!cut.shipped && cut.variants.length === 0 ? (
+ <div className="p-4 text-[12px] text-[var(--color-dim)]">
+ Nothing here yet — no <code className="font-mono">{cutName}.mp4</code> and no variants
+ named for it.{" "}
+ <Link href={`/browse/${song.id}`} className="text-[var(--color-sel)] underline">
+ back to {song.id}
+ </Link>
+ </div>
+ ) : (
+ <>
+ <CutBench
+ song={song.id}
+ cut={cutName}
+ shipped={cut.shipped}
+ variants={cut.variants}
+ durations={durations}
+ initialVariant={v ?? null}
+ notes={notes}
+ compares={compares}
+ />
+ <div className="px-4 pb-4">
+ <LoudnessTable song={song.id} initial={loudness} target={target} />
+ </div>
+ <div className="px-4 pb-4">
+ <ProvenancePanel
+ song={song.id}
+ cut={cutName}
+ prov={prov}
+ build={build}
+ planParam={plan ?? null}
+ notes={notes}
+ allNotes={notesParam === "all"}
+ />
+ </div>
+ </>
+ )}
+ </main>
+ </div>
+ );
+}
diff --git a/umtool/components/projects/ProjectGrid.tsx b/umtool/components/projects/ProjectGrid.tsx
@@ -0,0 +1,104 @@
+import Link from "next/link";
+import { fmtAgo } from "@/lib/format";
+import type { ProjectSummary } from "@/lib/project-types";
+
+// ---------------------------------------------------------------------------
+// One card per project, whatever kind it is.
+//
+// This file contains no kind ids and no per-kind branches, and that is the
+// design working rather than an omission: a kind's summariser already rendered
+// its own facts ("19 clips · 4m46s · 17 sources", "3/4 cuts · 2 variants") and
+// its own flags. The grid lays out strings. Adding a kind changes nothing here.
+// ---------------------------------------------------------------------------
+
+export default function ProjectGrid({
+ projects,
+ counts,
+}: {
+ projects: ProjectSummary[];
+ counts?: Map<string, { blocking: number; open: number }>;
+}) {
+ if (projects.length === 0) {
+ return (
+ <p className="text-[12px] text-[var(--color-dim)]">Nothing matches that filter.</p>
+ );
+ }
+
+ return (
+ <ul className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3 2xl:grid-cols-4">
+ {projects.map((p) => {
+ const c = counts?.get(p.id);
+ // An unroutable or shadowed project keeps its card and its link -- the
+ // link just goes to the escape hatch instead of to an address that
+ // renders something else.
+ const href = p.routing === "ok" ? `/browse/${p.id}` : `/browse/at?path=${encodeURIComponent(p.id)}`;
+ return (
+ <li key={p.id}>
+ <Link
+ href={href}
+ data-project={p.id}
+ data-kind={p.kind}
+ data-template={p.template}
+ data-state={p.state}
+ data-routing={p.routing}
+ className="block overflow-hidden rounded border border-[var(--color-line)] bg-[var(--color-panel)] transition-colors hover:border-[var(--color-sel)]"
+ >
+ {/* eslint-disable-next-line @next/next/no-img-element */}
+ <img
+ src={`/api/browse/poster?project=${encodeURIComponent(p.id)}&w=640`}
+ alt=""
+ width={640}
+ height={360}
+ className="aspect-video w-full bg-[var(--color-panel-2)] object-cover"
+ />
+ <div
+ className="space-y-1.5 p-3"
+ {...Object.fromEntries(
+ Object.entries(p.attrs ?? {}).map(([k, v]) => [`data-${k}`, v]),
+ )}
+ >
+ <div className="flex items-baseline gap-2">
+ <span className="rounded border border-[var(--color-line)] px-1.5 py-0.5 text-[10px] uppercase tracking-wider text-[var(--color-dim)]">
+ {p.badge}
+ </span>
+ <span className="truncate text-[13px] font-medium text-[var(--color-text)]">
+ {p.title}
+ </span>
+ </div>
+ {p.subtitle && (
+ <div className="truncate text-[11px] text-[var(--color-dim)]">{p.subtitle}</div>
+ )}
+ <div className="num flex flex-wrap items-center gap-x-3 gap-y-1 text-[11px] text-[var(--color-dim)]">
+ {p.facts.map((f) => (
+ <span key={f}>{f}</span>
+ ))}
+ <span className="ml-auto">{fmtAgo(p.newestMtimeMs)}</span>
+ </div>
+ <div className="flex flex-wrap items-center gap-2 text-[11px]">
+ <span className="micro" data-project-state={p.state}>
+ {p.state}
+ </span>
+ {c && c.blocking > 0 && (
+ <span className="text-[var(--color-bad)]" data-blocking={c.blocking}>
+ {c.blocking} blocking
+ </span>
+ )}
+ {c && c.open > 0 && (
+ <span className="text-[var(--color-dirty)]" data-open={c.open}>
+ {c.open} open
+ </span>
+ )}
+ </div>
+ {p.flags.length > 0 && (
+ <div className="text-[11px] text-[var(--color-bad)]" data-flags={p.flags.join(",")}>
+ {p.flags.join(" · ")}
+ </div>
+ )}
+ </div>
+ </Link>
+ </li>
+ );
+ })}
+ </ul>
+ );
+}
diff --git a/umtool/components/projects/ProjectView.tsx b/umtool/components/projects/ProjectView.tsx
@@ -0,0 +1,60 @@
+import { notFound } from "next/navigation";
+import type { ProjectRef } from "@/lib/project-types";
+import SongProject from "./SongProject";
+import CutPage from "./CutPage";
+import ReportProject from "./ReportProject";
+import SweepProject from "./SweepProject";
+
+// ---------------------------------------------------------------------------
+// The one place a kind id is matched against a component.
+//
+// It is HERE rather than in app/browse/[...path]/page.tsx deliberately: the
+// rule is that adding a kind costs a registry entry and one view, and an e2e
+// spec enforces it by failing when a kind id appears as a string literal
+// anywhere outside lib/projects/ and components/projects/. A page that grew an
+// `if (kind === "report-video")` would break that rule silently, so the page
+// never learns what a kind is -- it resolves a path and renders this.
+//
+// `rest` is the segments after the project. [] is the project itself; anything
+// else is a VIEW the kind declares, and a view a kind does not declare is a 404
+// rather than a page that ignores half its URL.
+// ---------------------------------------------------------------------------
+
+export default async function ProjectView({
+ project,
+ rest,
+ search,
+}: {
+ project: ProjectRef;
+ rest: string[];
+ search: Record<string, string | undefined>;
+}) {
+ switch (project.kind) {
+ case "song": {
+ if (rest.length === 0) return <SongProject id={project.name} />;
+ if (rest.length === 1) return <CutPage id={project.name} cutName={rest[0]} search={search} />;
+ return notFound();
+ }
+ case "report-video": {
+ if (rest.length === 0) return <ReportProject project={project} search={search} />;
+ // The clip bench lands here in its own phase; until then an unknown view
+ // is a 404 rather than a page that silently drops the rest of the URL.
+ return notFound();
+ }
+ case "sweep-report": {
+ if (rest.length === 0) return <SweepProject project={project} />;
+ return notFound();
+ }
+ default:
+ // A kind the registry knows and this file does not. That is exactly the
+ // state a half-added kind is in, so it says so instead of 404ing.
+ return (
+ <main className="deck-main flex-1 p-4">
+ <p className="text-[12px] text-[var(--color-dim)]">
+ <code className="font-mono">{project.kind}</code> is a registered kind with no view
+ yet. Add one in <code className="font-mono">components/projects/</code>.
+ </p>
+ </main>
+ );
+ }
+}
diff --git a/umtool/components/projects/ReportProject.tsx b/umtool/components/projects/ReportProject.tsx
@@ -0,0 +1,255 @@
+import Link from "next/link";
+import BrowseHeader from "@/components/BrowseHeader";
+import { fmtAgo, fmtBytes } from "@/lib/format";
+import { readClipDetail } from "@/lib/projects/report.mjs";
+import { decisionsForProject } from "@/lib/projects";
+import { badgeVariants, type BadgeVariants } from "@/components/ui/badge";
+import type { Severity } from "@/lib/decisions";
+import type { ProjectRef } from "@/lib/project-types";
+
+// ---------------------------------------------------------------------------
+// A report video, as a page.
+//
+// The manifest IS the cut -- array order, absolute source seconds, one entry per
+// clip -- so the page is the timeline, read back. What it adds is the three
+// things the JSON cannot show you: whether each clip's material is actually on
+// disk, whether its window ends where a sentence does, and what the widener
+// would do to it if you ran it.
+// ---------------------------------------------------------------------------
+
+const hms = (t: number) => {
+ const s = Math.max(0, Math.floor(t));
+ const h = Math.floor(s / 3600);
+ const m = Math.floor((s % 3600) / 60);
+ const sec = s % 60;
+ return h > 0
+ ? `${h}:${String(m).padStart(2, "0")}:${String(sec).padStart(2, "0")}`
+ : `${m}:${String(sec).padStart(2, "0")}`;
+};
+
+const TONE: Record<Severity, BadgeVariants["variant"]> = {
+ blocking: "blocking",
+ open: "open",
+ info: "info",
+};
+
+function Pill({ tone, children }: { tone?: BadgeVariants["variant"]; children: React.ReactNode }) {
+ return <span className={badgeVariants({ variant: tone ?? "info", size: "sm" })}>{children}</span>;
+}
+
+export default async function ReportProject({
+ project,
+ search,
+}: {
+ project: ProjectRef;
+ search: Record<string, string | undefined>;
+}) {
+ const detail = await readClipDetail(project.dir);
+ const decisions = await decisionsForProject(project);
+
+ if (!detail) {
+ return (
+ <div className="flex h-full flex-col">
+ <BrowseHeader crumbs={[{ href: "/browse", label: "projects" }, { label: project.id }]} />
+ <main className="deck-main flex-1 p-4">
+ <p className="text-[12px] text-[var(--color-bad)]">
+ <code className="font-mono">video.manifest.json</code> could not be parsed.
+ </p>
+ </main>
+ </div>
+ );
+ }
+
+ const { manifest: m, build, entries, channelsDir, shadowExists } = detail;
+ const clips = entries.filter((e) => e.kind === "clip");
+ const runtime = clips.reduce((n, e) => n + Math.max(0, e.end - e.start), 0);
+ const showAll = search.all === "1";
+ const p = m.provenance ?? {};
+
+ return (
+ <div className="flex h-full flex-col">
+ <BrowseHeader
+ crumbs={[{ href: "/browse", label: "projects" }, { label: project.id }]}
+ note={`${clips.length} clips · ${hms(runtime)} · ${build.built ? "built" : "not built"}`}
+ />
+
+ <div className="flex flex-wrap items-start gap-3 border-b border-[var(--color-line)] px-4 py-2">
+ <div className="min-w-0 flex-1">
+ <h1 className="truncate text-[15px] text-[var(--color-text)]">{m.title}</h1>
+ {m.subtitle && <p className="text-[12px] text-[var(--color-dim)]">{m.subtitle}</p>}
+ <div className="num mt-1 flex flex-wrap items-center gap-x-3 text-[11px] text-[var(--color-dim)]">
+ {p.channel && <span>{p.channel}</span>}
+ {m.generatedOn && <span>generated {m.generatedOn}</span>}
+ {typeof p.videosCited === "number" && <span>{p.videosCited} videos cited</span>}
+ {typeof p.enumeratedMatches === "number" && (
+ <span>{p.enumeratedMatches} enumerated matches</span>
+ )}
+ </div>
+ </div>
+ {build.built && (
+ <div className="text-right text-[11px] text-[var(--color-dim)]">
+ <div className="font-mono text-[var(--color-text)]">{build.slug}.mp4</div>
+ <div className="num">
+ {fmtBytes(build.finalSize)} · {fmtAgo(build.finalMtimeMs)}
+ </div>
+ </div>
+ )}
+ </div>
+
+ <main className="deck-main flex-1 space-y-4 p-4">
+ {/* --- what is wrong, first ------------------------------------- */}
+ {decisions.length > 0 && (
+ <section data-project={project.id}>
+ <h2 className="micro mb-1.5">
+ {decisions.filter((d) => d.severity !== "info").length} waiting of {decisions.length}
+ </h2>
+ <ul className="space-y-1">
+ {decisions.map((d, i) => (
+ <li
+ key={`${d.kind}-${d.target}-${i}`}
+ data-decision={d.kind}
+ data-severity={d.severity}
+ data-target={d.target}
+ className="flex flex-wrap items-baseline gap-2 rounded border border-[var(--color-line)] bg-[var(--color-panel)] px-3 py-1.5"
+ >
+ <span className={badgeVariants({ variant: TONE[d.severity], size: "sm" })}>
+ {d.severity}
+ </span>
+ <span className="font-mono text-[12px] text-[var(--color-text)]">{d.target}</span>
+ <span className="text-[12px] text-[var(--color-dim)]">{d.why}</span>
+ <span className="micro ml-auto">{d.kind}</span>
+ </li>
+ ))}
+ </ul>
+ </section>
+ )}
+
+ {/* --- where the sources are read from --------------------------- */}
+ <section className="rounded border border-[var(--color-line)] bg-[var(--color-panel)] px-3 py-2">
+ <div className="micro mb-1">sources</div>
+ <div className="num text-[11px] text-[var(--color-dim)]">
+ cues from <code className="font-mono">{channelsDir}</code>
+ {shadowExists && (
+ <span className="ml-2 text-[var(--color-meter)]">
+ (this project’s own shadow tree)
+ </span>
+ )}
+ </div>
+ <div className="num mt-1 text-[11px] text-[var(--color-dim)]">
+ QR codes resolve to{" "}
+ <code className="font-mono">{p.siteOrigin ?? "(nothing — siteOrigin is unset)"}</code>
+ </div>
+ </section>
+
+ {/* --- the timeline --------------------------------------------- */}
+ <section>
+ <h2 className="micro mb-1.5">
+ the cut — {entries.length} entries, in array order
+ </h2>
+ <ul className="space-y-1">
+ {entries.map((e) => {
+ if (e.kind === "card") {
+ return (
+ <li
+ key={e.id}
+ data-entry={e.id}
+ data-kind="card"
+ className="flex flex-wrap items-baseline gap-2 rounded border border-dashed border-[var(--color-line)] px-3 py-1.5 text-[12px]"
+ >
+ <span className="font-mono text-[var(--color-dim)]">{e.id}</span>
+ <Pill>card · {e.style}</Pill>
+ <span className="text-[var(--color-text)]">{e.heading ?? e.title ?? ""}</span>
+ <span className="num micro ml-auto">{e.seconds}s</span>
+ </li>
+ );
+ }
+ const midSentence = e.endsSentence === false && !e.lockEnd && !e.lock;
+ return (
+ <li
+ key={e.id}
+ data-entry={e.id}
+ data-kind="clip"
+ data-cached={e.cached ? "1" : "0"}
+ data-mid-sentence={midSentence ? "1" : "0"}
+ className="rounded border border-[var(--color-line)] bg-[var(--color-panel)] px-3 py-1.5"
+ >
+ <div className="flex flex-wrap items-baseline gap-2 text-[12px]">
+ <span className="font-mono text-[var(--color-sel)]">{e.id}</span>
+ <span className="num font-mono text-[11px] text-[var(--color-dim)]">
+ {e.video} {hms(e.start)}–{hms(e.end)} ({(e.end - e.start).toFixed(1)}s)
+ </span>
+ {e.lock && <Pill>locked</Pill>}
+ {!e.lock && e.lockStart && <Pill>start pinned</Pill>}
+ {!e.lock && e.lockEnd && <Pill>end pinned</Pill>}
+ {e.cached ? (
+ <Pill>cached</Pill>
+ ) : (
+ <Pill tone="open">
+ not fetched
+ </Pill>
+ )}
+ {e.segment && <Pill>segment built</Pill>}
+ {!e.hasCues && (
+ <Pill tone={TONE.blocking}>no cues</Pill>
+ )}
+ {midSentence && (
+ <Pill tone="open">
+ ends mid-sentence
+ </Pill>
+ )}
+ {e.proposed && (
+ <Pill tone="open">
+ widener would move it
+ </Pill>
+ )}
+ <span className="micro ml-auto">§{e.section ?? 0}</span>
+ </div>
+ {(showAll || midSentence || e.proposed) && e.quote && (
+ <p className="mt-1 text-[11px] leading-snug text-[var(--color-dim)]">
+ “{String(e.quote).slice(0, 240)}
+ {String(e.quote).length > 240 ? "…" : ""}”
+ </p>
+ )}
+ {midSentence && e.endCueText && (
+ <p className="num mt-1 text-[11px] text-[var(--color-dirty)]">
+ cut lands inside: “…{String(e.endCueText).trim().slice(-64)}”
+ </p>
+ )}
+ {e.proposed && (
+ <p className="num mt-1 text-[11px] text-[var(--color-dirty)]">
+ resolve-windows would make it {hms(e.proposed.start)}–{hms(e.proposed.end)} —
+ set <code className="font-mono">lock</code> if this window is deliberate
+ </p>
+ )}
+ </li>
+ );
+ })}
+ </ul>
+ <div className="mt-2">
+ <Link
+ href={`/browse/${project.id}${showAll ? "" : "?all=1"}`}
+ className="text-[11px] text-[var(--color-sel)] hover:underline"
+ >
+ {showAll ? "hide quotes" : "show every quote"}
+ </Link>
+ </div>
+ </section>
+
+ {/* --- provenance, as written ------------------------------------ */}
+ <section>
+ <h2 className="micro mb-1.5">provenance</h2>
+ <dl className="space-y-1.5 rounded border border-[var(--color-line)] bg-[var(--color-panel)] px-3 py-2 text-[11px]">
+ {Object.entries(p)
+ .filter(([, v]) => typeof v === "string" && v.length > 40)
+ .map(([k, v]) => (
+ <div key={k}>
+ <dt className="micro">{k}</dt>
+ <dd className="text-[var(--color-dim)]">{String(v)}</dd>
+ </div>
+ ))}
+ </dl>
+ </section>
+ </main>
+ </div>
+ );
+}
diff --git a/umtool/components/projects/SongProject.tsx b/umtool/components/projects/SongProject.tsx
@@ -0,0 +1,255 @@
+import Link from "next/link";
+import { notFound } from "next/navigation";
+import BrowseHeader from "@/components/BrowseHeader";
+import VerdictChip from "@/components/VerdictChip";
+import SpecSheet from "@/components/SpecSheet";
+import NoteField from "@/components/NoteField";
+import CopyButton from "@/components/CopyButton";
+import { readSong, probeAll, thumbAliasesFor, type Cut, type Song } from "@/lib/browse";
+import ThumbBench from "@/components/ThumbBench";
+import { thumbView } from "@/lib/thumbs";
+import { fmtAgo, fmtBytes, fmtDur } from "@/lib/format";
+import { Markdown } from "@/lib/markdown";
+import { operationsFor, readSpec, validateSpec } from "@/lib/spec";
+import { readNotes, type NoteMap } from "@/lib/notes";
+import { TRIM_SETS } from "@/lib/trim";
+
+// Moved out of app/browse/[song]/page.tsx unchanged, because [song] and
+// [...path] cannot both be dynamic segments at the same level. The dispatcher
+// in ProjectView.tsx hands it an id; nothing else about the page differs.
+
+export default async function SongProject({ id }: { id: string }) {
+ const song = await readSong(id);
+ if (!song) notFound();
+
+ // Durations are worth an ffprobe HERE but not on the index: this page is a
+ // handful of files and "which of these is the short cut" is exactly the
+ // question it answers. Memoised by mtime in lib/browse.ts.
+ const rels = [
+ ...song.cuts.flatMap((c) => [c.shipped?.rel, ...c.variants.map((v) => v.rel)]),
+ ...song.unattributed.map((v) => v.rel),
+ ].filter((r): r is string => !!r);
+ const info = await probeAll(id, rels);
+
+ const spec = await readSpec(id);
+ const problems = await validateSpec(spec);
+ const notes = await readNotes(id);
+ // Two small JSON reads, no probing -- the bench draws what the manifests say.
+ const thumbs = await thumbView(id, thumbAliasesFor(id));
+
+ return (
+ <div className="flex h-full flex-col">
+ <BrowseHeader
+ crumbs={[{ href: "/browse", label: "songs" }, { label: song.id }]}
+ note={`${song.present}/${song.cuts.length} cuts · ${song.variantCount} variants`}
+ />
+ <div className="flex flex-wrap items-start gap-3 border-b border-[var(--color-line)] px-4 py-2">
+ <div className="min-w-0 flex-1">
+ {/* The one obvious place to write about the song, open by default --
+ everything else on the page is collapsed until asked for. */}
+ <NoteField song={id} target="song" initial={notes.song ?? null} label="notes on this song" />
+ </div>
+ <CopyButton
+ label="copy this song"
+ title="this song as markdown — spec, cuts, verdicts, notes and resolved marks"
+ url={`/api/browse/context?song=${encodeURIComponent(id)}`}
+ />
+ </div>
+ <main className="deck-main flex-1">
+ <div className="grid gap-4 p-4 xl:grid-cols-[minmax(0,2fr)_minmax(0,1fr)]">
+ <div className="space-y-3">
+ {song.cuts.map((cut) => (
+ <CutCard key={cut.name} song={song} cut={cut} info={info} notes={notes} />
+ ))}
+
+ {song.unattributed.length > 0 && (
+ <section className="rounded border border-[var(--color-dirty)]/40 bg-[var(--color-panel)] p-3">
+ <div className="micro mb-2">
+ matches no cut name — attribute by renaming, never by guessing
+ </div>
+ <ul className="space-y-1">
+ {song.unattributed.map((v) => (
+ <li key={v.rel} className="num text-[12px] text-[var(--color-dim)]">
+ <span className="font-mono text-[var(--color-text)]">{v.rel}</span>{" "}
+ {fmtBytes(v.size)}
+ </li>
+ ))}
+ </ul>
+ </section>
+ )}
+ </div>
+
+ <aside className="space-y-4">
+ <ThumbBench song={song.id} view={thumbs} notes={notes} />
+ <SpecSheet
+ song={song.id}
+ initial={spec}
+ problems={problems}
+ operations={operationsFor(spec, song)}
+ plans={song.plans.map((p) => p.name)}
+ trimSets={Object.values(TRIM_SETS).map((t) => ({ id: t.id, label: t.label }))}
+ />
+ {song.readme && (
+ <section className="rounded border border-[var(--color-line)] bg-[var(--color-panel)] p-3">
+ <Markdown text={song.readme} />
+ </section>
+ )}
+ <section className="rounded border border-[var(--color-line)] bg-[var(--color-panel)] p-3">
+ <div className="micro mb-2">plans</div>
+ {song.plans.length === 0 ? (
+ <p className="text-[12px] text-[var(--color-dim)]">no plan/ directory</p>
+ ) : (
+ <ul className="space-y-0.5">
+ {song.plans.map((p) => (
+ <li key={p.name} className="num text-[11px] text-[var(--color-dim)]">
+ <span className="flex gap-2">
+ <span className="truncate font-mono text-[var(--color-text)]">{p.name}</span>
+ <span className="ml-auto shrink-0">{fmtBytes(p.size)}</span>
+ </span>
+ <NoteField
+ song={song.id}
+ target={`plan:${p.name}`}
+ initial={notes[`plan:${p.name}`] ?? null}
+ label="what this plan is"
+ rows={3}
+ />
+ </li>
+ ))}
+ </ul>
+ )}
+ {song.hasClipsCsv && <div className="micro mt-2">clips.csv present</div>}
+ </section>
+ </aside>
+ </div>
+ </main>
+ </div>
+ );
+}
+
+function CutCard({
+ song,
+ cut,
+ info,
+ notes,
+}: {
+ song: Song;
+ cut: Cut;
+ info: Record<string, { duration: number; width: number; height: number }>;
+ notes: NoteMap;
+}) {
+ const shipped = cut.shipped;
+ const live = cut.variants.filter((v) => !v.retired);
+ const retired = cut.variants.filter((v) => v.retired);
+
+ return (
+ <section
+ data-cut={cut.name}
+ data-present={shipped ? "1" : "0"}
+ className="rounded border border-[var(--color-line)] bg-[var(--color-panel)]"
+ >
+ <div className="flex items-start gap-3 p-3">
+ {shipped ? (
+ /* eslint-disable-next-line @next/next/no-img-element */
+ <img
+ src={`/api/browse/poster?song=${encodeURIComponent(song.id)}&rel=${encodeURIComponent(shipped.rel)}&w=320`}
+ alt=""
+ width={160}
+ height={90}
+ className="w-40 shrink-0 rounded bg-[var(--color-panel-2)] object-cover"
+ />
+ ) : (
+ <div className="flex h-[90px] w-40 shrink-0 items-center justify-center rounded border border-dashed border-[var(--color-line)] text-[11px] text-[var(--color-dim)]">
+ not built
+ </div>
+ )}
+
+ <div className="min-w-0 flex-1 space-y-1">
+ <div className="flex flex-wrap items-baseline gap-2">
+ <Link
+ href={`/browse/${song.id}/${cut.name}`}
+ className="font-mono text-[13px] text-[var(--color-text)] hover:text-[var(--color-sel)]"
+ >
+ {cut.name}
+ </Link>
+ {shipped ? (
+ <span className="num text-[11px] text-[var(--color-meter)]">
+ {fmtDur(info[shipped.rel]?.duration ?? 0)}
+ </span>
+ ) : (
+ <span className="text-[11px] text-[var(--color-dim)]">— a hole in the set</span>
+ )}
+ {shipped && info[shipped.rel] && (
+ <span className="num text-[11px] text-[var(--color-dim)]">
+ {info[shipped.rel].width}×{info[shipped.rel].height}
+ </span>
+ )}
+ <span className="num ml-auto text-[11px] text-[var(--color-dim)]">
+ {shipped ? `${fmtBytes(shipped.size)} · ${fmtAgo(shipped.mtimeMs)}` : ""}
+ </span>
+ </div>
+
+ {shipped && (
+ <VerdictChip song={song.id} rel={shipped.rel} initial={shipped.verdict} />
+ )}
+
+ {/* Two different notes, deliberately. The `cut:` one is about the SLOT
+ -- it survives a promote and can be written about a cut that has
+ not been built. The `file:` one is about these bytes. */}
+ <NoteField
+ song={song.id}
+ target={`cut:${cut.name}`}
+ initial={notes[`cut:${cut.name}`] ?? null}
+ label={`notes on ${cut.name}`}
+ rows={3}
+ />
+ {shipped && (
+ <NoteField
+ song={song.id}
+ target={`file:${shipped.rel}`}
+ initial={notes[`file:${shipped.rel}`] ?? null}
+ label="notes on this file"
+ rows={3}
+ />
+ )}
+
+ {live.length > 0 && (
+ <ul className="space-y-1 pt-1">
+ {live.map((v) => (
+ <li
+ key={v.rel}
+ data-variant={v.rel}
+ data-variant-tag={v.tag}
+ className="flex flex-wrap items-center gap-2 rounded bg-[var(--color-panel-2)] px-2 py-1"
+ >
+ <span className="font-mono text-[12px] text-[var(--color-text)]">{v.tag}</span>
+ <span className="num text-[11px] text-[var(--color-meter)]">
+ {fmtDur(info[v.rel]?.duration ?? 0)}
+ </span>
+ <span className="num text-[11px] text-[var(--color-dim)]">{fmtBytes(v.size)}</span>
+ <div className="ml-auto">
+ <VerdictChip song={song.id} rel={v.rel} initial={v.verdict} />
+ </div>
+ <div className="w-full">
+ <NoteField
+ song={song.id}
+ target={`file:${v.rel}`}
+ initial={notes[`file:${v.rel}`] ?? null}
+ label={`notes on ${v.tag}`}
+ rows={3}
+ />
+ </div>
+ </li>
+ ))}
+ </ul>
+ )}
+
+ {retired.length > 0 && (
+ <div className="micro pt-1" data-retired={retired.length}>
+ {retired.length} retired: {retired.map((v) => v.tag).join(", ")}
+ </div>
+ )}
+ </div>
+ </div>
+ </section>
+ );
+}
diff --git a/umtool/components/projects/SweepProject.tsx b/umtool/components/projects/SweepProject.tsx
@@ -0,0 +1,73 @@
+import Link from "next/link";
+import { readdir, readFile, stat } from "node:fs/promises";
+import path from "node:path";
+import BrowseHeader from "@/components/BrowseHeader";
+import { Markdown } from "@/lib/markdown";
+import { fmtAgo } from "@/lib/format";
+import type { ProjectRef } from "@/lib/project-types";
+
+// A report that is not yet a video. There is nothing to judge and nothing to
+// build, so the page is the report itself plus the one action that matters:
+// what it would take to make it a cut.
+
+const SWEEP_RE = /(^|[-_])sweep([-_]report)?\.md$|^sweep-report\.md$/i;
+
+export default async function SweepProject({ project }: { project: ProjectRef }) {
+ const names = await readdir(project.dir).catch(() => [] as string[]);
+ const reportName = names.find((n) => SWEEP_RE.test(n));
+ const file = reportName ? path.join(project.dir, reportName) : null;
+ const [text, st] = await Promise.all([
+ file ? readFile(file, "utf8").catch(() => null) : null,
+ file ? stat(file).catch(() => null) : null,
+ ]);
+ const citations = text ? (text.match(/\]\([^)]*[?&]v=/g) ?? []).length : 0;
+
+ return (
+ <div className="flex h-full flex-col">
+ <BrowseHeader
+ crumbs={[{ href: "/browse", label: "projects" }, { label: project.id }]}
+ note={`${citations} citation${citations === 1 ? "" : "s"} · no manifest`}
+ />
+ <main className="deck-main flex-1 space-y-4 p-4">
+ <section className="rounded border border-[var(--color-line)] bg-[var(--color-panel)] p-3">
+ <h2 className="mb-1 text-[13px] text-[var(--color-text)]">A report, not yet a video</h2>
+ <p className="text-[12px] text-[var(--color-dim)]">
+ Turning it into one means writing a{" "}
+ <code className="font-mono">video.manifest.json</code> beside it: one entry per
+ clip, each with a window in absolute source seconds taken from{" "}
+ <code className="font-mono">transcript.cues.json</code>. A report only records a
+ single start second per citation, so the windows cannot be recovered from it alone —
+ that matching is the work.
+ </p>
+ <p className="mt-1.5 text-[12px] text-[var(--color-dim)]">
+ {citations > 0
+ ? `${citations} citation${citations === 1 ? "" : "s"} to work from.`
+ : "No `?v=` citations found, so there is nothing to match windows against yet."}{" "}
+ See <code className="font-mono">umtool/docs/authoring.md</code>.
+ </p>
+ </section>
+
+ {text ? (
+ <section className="rounded border border-[var(--color-line)] bg-[var(--color-panel)] p-3">
+ <div className="mb-2 flex items-baseline gap-2">
+ <span className="font-mono text-[12px] text-[var(--color-text)]">{reportName}</span>
+ {st && <span className="micro">{fmtAgo(st.mtimeMs)}</span>}
+ </div>
+ <div className="max-h-[60vh] overflow-y-auto">
+ <Markdown text={text} />
+ </div>
+ </section>
+ ) : (
+ <p className="text-[12px] text-[var(--color-dim)]">
+ No report file found under{" "}
+ <code className="font-mono">{project.id}</code>.
+ </p>
+ )}
+
+ <Link href="/browse" className="text-[12px] text-[var(--color-sel)] hover:underline">
+ ← every project
+ </Link>
+ </main>
+ </div>
+ );
+}
diff --git a/umtool/components/ui/badge.tsx b/umtool/components/ui/badge.tsx
@@ -0,0 +1,49 @@
+import { cva, type VariantProps } from "class-variance-authority";
+import { cn } from "@/lib/utils";
+
+// ---------------------------------------------------------------------------
+// The first shadcn thing to land, and deliberately the smallest one.
+//
+// It is a CLASS HELPER, not a component: `badgeVariants({variant})` returns a
+// string, so a <Link> on a zero-client-JS page can wear it without becoming a
+// client component. /browse and /browse/decisions are server-rendered with
+// their filters as plain links, and a Radix component would quietly end that.
+//
+// The variants name PALETTE MEANINGS rather than shadcn's defaults, because the
+// palette's rule is that colour means something: `on` is the interaction colour
+// (the only one), and blocking/open/info are the three severities the decisions
+// inbox already speaks in. There is no `primary`/`secondary` pair here because
+// this app has no such distinction to make.
+// ---------------------------------------------------------------------------
+
+export const badgeVariants = cva(
+ "inline-flex items-center rounded border px-1.5 py-0.5 font-mono text-[11px] transition-colors",
+ {
+ variants: {
+ variant: {
+ neutral: "border-border text-muted-foreground hover:text-foreground",
+ on: "border-primary text-primary",
+ blocking: "border-destructive text-destructive",
+ open: "border-[var(--color-dirty)] text-[var(--color-dirty)]",
+ info: "border-border text-muted-foreground",
+ meter: "border-[var(--color-meter)] text-[var(--color-meter)]",
+ },
+ size: {
+ sm: "px-1.5 py-0.5 text-[10px] uppercase tracking-wider",
+ md: "",
+ },
+ },
+ defaultVariants: { variant: "neutral", size: "md" },
+ },
+);
+
+export type BadgeVariants = VariantProps<typeof badgeVariants>;
+
+export function Badge({
+ className,
+ variant,
+ size,
+ ...props
+}: React.ComponentProps<"span"> & BadgeVariants) {
+ return <span className={cn(badgeVariants({ variant, size }), className)} {...props} />;
+}
diff --git a/umtool/components/ui/button.tsx b/umtool/components/ui/button.tsx
@@ -0,0 +1,42 @@
+import { cva, type VariantProps } from "class-variance-authority";
+import { cn } from "@/lib/utils";
+
+// Same posture as badge.tsx: the variants are a class helper first, so a <Link>
+// can wear them without hydrating, and a <button> second.
+//
+// Hit sizes are the ones this app already uses. It is a bench somebody sits
+// with for hours, so nothing here is smaller than what the existing controls
+// are, and `sm` is the default because dense is the point.
+
+export const buttonVariants = cva(
+ "inline-flex items-center justify-center gap-1.5 rounded border transition-colors disabled:pointer-events-none disabled:opacity-50 focus-visible:outline-none focus-visible:border-primary",
+ {
+ variants: {
+ variant: {
+ outline: "border-border text-muted-foreground hover:text-foreground hover:bg-accent",
+ primary:
+ "border-primary text-primary hover:bg-[color-mix(in_srgb,var(--color-primary)_14%,transparent)]",
+ destructive:
+ "border-destructive text-destructive hover:bg-[color-mix(in_srgb,var(--color-destructive)_14%,transparent)]",
+ ghost: "border-transparent text-muted-foreground hover:text-foreground hover:bg-accent",
+ },
+ size: {
+ sm: "px-2 py-1 text-[11px]",
+ md: "px-2.5 py-1 text-[12px]",
+ lg: "px-3 py-1.5 text-[13px]",
+ },
+ },
+ defaultVariants: { variant: "outline", size: "md" },
+ },
+);
+
+export type ButtonVariants = VariantProps<typeof buttonVariants>;
+
+export function Button({
+ className,
+ variant,
+ size,
+ ...props
+}: React.ComponentProps<"button"> & ButtonVariants) {
+ return <button className={cn(buttonVariants({ variant, size }), className)} {...props} />;
+}
diff --git a/umtool/e2e/browse.spec.ts b/umtool/e2e/browse.spec.ts
@@ -13,16 +13,18 @@ const dur = (s: number) => `${Math.floor(s / 60)}:${String(s % 60).padStart(2, "
test("the index lists both songs, and says which cuts are missing", async ({ page }) => {
await page.goto("/browse");
- await expect(page.locator("[data-song=alpha]")).toBeVisible();
- await expect(page.locator("[data-song=beta]")).toBeVisible();
+ // A project id is a PATH now, so the suffix is what identifies a song
+ // regardless of how deep the fixture nests it.
+ await expect(page.locator("[data-project$='/alpha']")).toBeVisible();
+ await expect(page.locator("[data-project$='/beta']")).toBeVisible();
// beta has wide and wide-short only. A cut list derived from the directory
// would make it look complete; the list is fixed precisely so it cannot.
- await expect(page.locator("[data-song=beta] [data-missing]")).toHaveAttribute(
+ await expect(page.locator("[data-project$='/beta'] [data-missing]")).toHaveAttribute(
"data-missing",
"vertical,vertical-short",
);
- await expect(page.locator("[data-song=alpha] [data-missing]")).toHaveCount(0);
+ await expect(page.locator("[data-project$='/alpha'] [data-missing]")).toHaveCount(0);
});
// THE assertion this whole feature turns on. `wide-short-nokit` must attribute
diff --git a/umtool/e2e/deck.spec.ts b/umtool/e2e/deck.spec.ts
@@ -39,8 +39,10 @@ test("/browse/decisions is not swallowed by the [song] route", async ({ page })
const res = await page.goto("/browse/decisions");
expect(res?.status()).toBe(200);
await expect(page.getByRole("navigation", { name: "Breadcrumb" })).toContainText("decisions");
- // If [song] had caught it, this would be a 404 for a song called "decisions".
- await expect(page.locator("[data-project=deck]")).toBeVisible();
+ // If the catch-all had swallowed it, this would be a 404 for a project called
+ // "decisions". A decision's `project` is a PATH now, so the suffix identifies
+ // the song without hard-coding how deep the fixture nests it.
+ await expect(page.locator("[data-project$='/deck']")).toBeVisible();
});
test("deck's open decisions are exactly the three that were seeded", async ({ request }) => {
diff --git a/umtool/e2e/fixtures/make-fixture.mjs b/umtool/e2e/fixtures/make-fixture.mjs
@@ -586,6 +586,171 @@ writeFileSync(
),
);
+
+// -- PROJECTS: report videos, a sweep report, and the two routing traps --------
+//
+// REPORTS_ROOT defaults to dirname(SONG_REPORTS_DIR), so everything the project
+// walk sees is inside this fixture. Each of these exists to give one finding a
+// TRUE ANSWER rather than a plausible one:
+//
+// report-fixture a good manifest whose clips have known cue text, so
+// "ends mid-sentence" and "the widener would move this"
+// are checkable rather than believable
+// no-origin-fixture no siteOrigin at all -- the defect that shipped 19 dead
+// QR codes in a real cut
+// localhost-fixture siteOrigin http://localhost:3000 -- the defect that
+// shipped a real video whose codes resolve on nobody's phone
+// bike-fixture a sweep report with no manifest: the third kind, and the
+// proof that adding one costs a registry entry and a view
+// find/ a project named for a TOOL PAGE. It can never win the
+// route, and before this it failed silently
+// deep/nested/solo a pass-through folder chain, so collapsing has an answer
+const CHANNELS = path.join(dest, "channels");
+
+// Two sources, deliberately different in the one way that matters to widening.
+//
+// vid1 is PUNCTUATED and carries a run-on cue at 3-6s, so a clip ending at 6.0
+// ends mid-sentence and widen() must walk it out to 9.0 (the next cue that
+// closes one). vid2 has NO terminator anywhere, which is the real degradation
+// this corpus has -- widening cannot help there and the tool has to say so
+// instead of silently doing nothing.
+const CUES = {
+ vid1: [
+ [0, 3, "This is a complete sentence."],
+ [3, 6, "And this one runs on and because"],
+ [6, 9, "of that it finishes here."],
+ [9, 12, "Another whole sentence entirely."],
+ [12, 15, "A fourth one, done."],
+ [15, 18, "trailing off and then"],
+ [18, 21, "it lands at last."],
+ ],
+ vid2: [
+ [0, 3, "no punctuation anywhere in this upload"],
+ [3, 6, "the asr never emitted a full stop"],
+ [6, 9, "so every cue just runs into the next"],
+ [9, 12, "and widening has nothing to find"],
+ [12, 15, "which is a thing to say out loud"],
+ ],
+};
+for (const [vid, rows] of Object.entries(CUES)) {
+ const dir = path.join(CHANNELS, "testchan", "data", vid);
+ mkdirSync(dir, { recursive: true });
+ writeFileSync(
+ path.join(dir, "transcript.cues.json"),
+ JSON.stringify(
+ {
+ title: `Fixture source ${vid}`,
+ uploadDate: "20250101",
+ webpageUrl: `https://example.invalid/watch?v=${vid}`,
+ duration: rows[rows.length - 1][1],
+ cues: rows.map(([start, end, text]) => ({ start, end, text })),
+ },
+ null,
+ 1,
+ ),
+ );
+}
+
+const manifest = (slug, title, provenance, timeline) => ({
+ schemaVersion: 1,
+ slug,
+ title,
+ subtitle: "a fixture",
+ generatedOn: "2026-01-01",
+ provenance: { channelSlug: "testchan", ...provenance },
+ render: { width: 1920, height: 1080, fps: 30, audioRate: 48000, audioChannels: 2, fetchPad: 3 },
+ timelineNodes: [],
+ timeline,
+});
+
+const writeProject = (rel, doc, root = reports) => {
+ const dir = path.join(root, rel);
+ mkdirSync(dir, { recursive: true });
+ writeFileSync(path.join(dir, "video.manifest.json"), JSON.stringify(doc, null, 2) + "\n");
+ return dir;
+};
+
+const REPORT = writeProject(
+ "report-fixture",
+ manifest("report-fixture", "The Report Fixture", { siteOrigin: "https://archive.example" }, [
+ // Ends inside the run-on cue, and is not locked -> exactly one
+ // clip-mid-sentence decision in the whole fixture, and widen() would move
+ // its end from 6.00 to 9.00.
+ { type: "clip", id: "c01", video: "vid1", start: 3.0, end: 6.0, cite: 3, section: 0, quote: "and because" },
+ // Ends on a full stop -> clean, and widen() is a no-op.
+ { type: "clip", id: "c02", video: "vid1", start: 9.0, end: 12.0, cite: 9, section: 0, quote: "another whole sentence" },
+ // An unpunctuated source -> feeds the no-punctuation row, never the
+ // mid-sentence one.
+ { type: "clip", id: "c03", video: "vid2", start: 1.0, end: 4.0, cite: 1, section: 0, quote: "no punctuation" },
+ // Also ends mid-cue, but lockEnd ACKNOWLEDGES it, so it must stay silent.
+ { type: "clip", id: "c04", video: "vid1", start: 15.0, end: 18.0, cite: 15, section: 0, lockEnd: true, quote: "trailing off" },
+ ]),
+);
+
+writeProject(
+ "no-origin-fixture",
+ manifest("no-origin-fixture", "No Origin", {}, [
+ { type: "clip", id: "c01", video: "vid1", start: 0, end: 3, cite: 0, section: 0, lock: true, quote: "q" },
+ ]),
+);
+
+writeProject(
+ "localhost-fixture",
+ manifest("localhost-fixture", "Localhost Origin", { siteOrigin: "http://localhost:3000" }, [
+ { type: "clip", id: "c01", video: "vid1", start: 0, end: 3, cite: 0, section: 0, lock: true, quote: "q" },
+ ]),
+);
+
+// At the REPORTS_ROOT itself, not under reports/ -- shadowing is about the FIRST
+// path segment, because that is the one a static route under app/browse/ wins.
+// `reports/find` is perfectly routable; `find` can never be.
+writeProject(
+ "find",
+ manifest("find", "Shadowed By A Tool Page", { siteOrigin: "https://archive.example" }, [
+ { type: "clip", id: "c01", video: "vid1", start: 0, end: 3, cite: 0, section: 0, lock: true, quote: "q" },
+ ]),
+ dest,
+);
+
+writeProject(
+ path.join("deep", "nested", "solo-fixture"),
+ manifest("solo-fixture", "Down A Pass-Through Chain", { siteOrigin: "https://archive.example" }, [
+ { type: "clip", id: "c01", video: "vid1", start: 0, end: 3, cite: 0, section: 0, lock: true, quote: "q" },
+ ]),
+);
+
+mkdirSync(path.join(reports, "bike-fixture"), { recursive: true });
+writeFileSync(
+ path.join(reports, "bike-fixture", "sweep-report.md"),
+ [
+ "# The Bike Fixture",
+ "",
+ "A cited report that nobody has turned into a video yet.",
+ "",
+ '> "the first citation"',
+ "— [source @ 0:03](https://archive.example/?v=testchan%2Fvid1&t=3)",
+ "",
+ '> "the second citation"',
+ "— [source @ 0:09](https://archive.example/?v=testchan%2Fvid1&t=9)",
+ "",
+ ].join("\n"),
+);
+
+// One CACHED SOURCE WINDOW, so the bench and a --skip-fetch build have real
+// material without a network. It covers exactly the window c01 would fetch
+// (start-3 to end+3 = 0.00-9.00), and it is tone / silence / tone / silence /
+// tone with the silences centred on 3.0s and 6.0s -- the two cut points -- so
+// snapping has an exact answer instead of a plausible one.
+mkdirSync(path.join(REPORT, "out", "clips-raw"), { recursive: true });
+ff([
+ "-f", "lavfi", "-i", "color=c=darkgreen:size=320x180:rate=15:duration=9",
+ "-f", "lavfi", "-i",
+ "sine=frequency=440:duration=9,volume=enable='between(t,2.9,3.1)+between(t,5.9,6.1)':volume=0",
+ "-map", "0:v", "-map", "1:a", "-t", "9",
+ "-c:v", "libx264", "-pix_fmt", "yuv420p", "-c:a", "aac", "-ar", "48000", "-ac", "2",
+ path.join(REPORT, "out", "clips-raw", "vid1_0.00-9.00.mp4"),
+]);
+
console.log(`fixture at ${dest}`);
if (planned) console.log(` planned clip (used in a build): ${planned}`);
console.log(` videos/: alpha (4 cuts, 3 variants), beta (2 cuts), deck (1 cut, 2 variants)`);
@@ -600,4 +765,8 @@ console.log(` flagged source: ${flagged ? flagged.video : "none — no asr/"}`)
console.log(` SONG_CODE_DIR=${path.join(dest, "code")}`);
console.log(` SONG_DIR=${path.join(dest, "data")}`);
console.log(` SONG_REPORTS_DIR=${reports}`);
+console.log(` CHANNELS_DIR=${CHANNELS} (testchan/vid1 punctuated, vid2 not)`);
+console.log(` projects: report-fixture (4 clips, 1 mid-sentence), no-origin-fixture,`);
+console.log(` localhost-fixture, bike-fixture (sweep), find/ (shadowed),`);
+console.log(` deep/nested/solo-fixture (collapse case)`);
console.log(` ${taken} candidate files copied, 2 mix tracks synthesised`);
diff --git a/umtool/e2e/projects.spec.ts b/umtool/e2e/projects.spec.ts
@@ -0,0 +1,284 @@
+import { test, expect } from "@playwright/test";
+import { execFileSync } from "node:child_process";
+import { readdirSync } from "node:fs";
+import path from "node:path";
+import { fileURLToPath } from "node:url";
+
+// ---------------------------------------------------------------------------
+// The registry, and the claim that adding a kind is cheap.
+//
+// Most of this suite is the usual sort of test: render a page, assert what is
+// on it. Three of them are not -- they read the repo's own source at test time,
+// because the thing being asserted is a PROPERTY OF THE CODE ("a kind id never
+// appears outside the registry") rather than of any page. Those are the ones
+// that fail when somebody special-cases a kind in a page, which is the exact
+// regression this design exists to prevent and the one a page test cannot see.
+//
+// The fixture (make-fixture.mjs) holds, deliberately:
+// report-fixture 4 clips; c01 ends mid-sentence, c04 is lockEnd and must
+// stay silent, c03's source has no punctuation at all
+// no-origin-fixture no siteOrigin -> blocking
+// localhost-fixture localhost origin -> blocking
+// bike-fixture a sweep report with no manifest -- the third kind
+// find/ shadowed by the /browse/find tool page
+// deep/nested/solo a pass-through chain, for the collapse
+// ---------------------------------------------------------------------------
+
+const HERE = path.dirname(fileURLToPath(import.meta.url));
+const UMTOOL = path.join(HERE, "..");
+
+test("the index lists every kind, and says which state each project is in", async ({ page }) => {
+ await page.goto("/browse");
+
+ await expect(page.locator("[data-kind='report-video']")).toHaveCount(5);
+ // NOT an exact count: browse.spec.ts creates a song through /api/browse/init,
+ // so the number here depends on what else has run. What matters is that the
+ // kind is present and that the fixture's own songs are in it.
+ await expect(page.locator("[data-kind='song']").first()).toBeVisible();
+ await expect(page.locator("[data-project='reports/videos/alpha']")).toHaveAttribute(
+ "data-kind",
+ "song",
+ );
+ // The third kind, which exists to prove a kind with no decisions, no build
+ // and no rich read still gets a card, a chip and a page.
+ await expect(page.locator("[data-kind='sweep-report']")).toHaveCount(1);
+
+ await expect(page.locator("[data-project='reports/report-fixture']")).toHaveAttribute(
+ "data-state",
+ // It has a cached source window and no output: not just "windows written".
+ "fetched",
+ );
+ await expect(page.locator("[data-project='reports/bike-fixture']")).toHaveAttribute(
+ "data-state",
+ "draft",
+ );
+});
+
+test("a kind chip filters, and the counts do not move when it does", async ({ page }) => {
+ await page.goto("/browse");
+ const chip = page.getByRole("link", { name: /^report video \d+$/ });
+ const before = await chip.textContent();
+
+ await chip.click();
+ await expect(page).toHaveURL(/kind=report-video/);
+ await expect(page.locator("[data-kind='song']")).toHaveCount(0);
+ await expect(page.locator("[data-kind='report-video']")).toHaveCount(5);
+
+ // Counts come from the UNFILTERED set on purpose: a chip whose number changes
+ // when you click a different chip moves under the cursor.
+ await expect(page.getByRole("link", { name: /^report video \d+$/ })).toHaveText(before ?? "");
+ await expect(page.getByRole("link", { name: /^report video \d+$/ })).toHaveAttribute(
+ "aria-current",
+ "true",
+ );
+});
+
+test("?q= survives the filters it was typed under, and is one pasteable URL", async ({ page }) => {
+ await page.goto("/browse?kind=report-video");
+ await page.getByLabel("filter projects").fill("localhost");
+ await page.getByRole("button", { name: "filter" }).click();
+
+ await expect(page).toHaveURL(/kind=report-video/);
+ await expect(page).toHaveURL(/q=localhost/);
+ await expect(page.locator("[data-project]")).toHaveCount(1);
+ await expect(page.locator("[data-project='reports/localhost-fixture']")).toBeVisible();
+});
+
+test("the two siteOrigin defects are BLOCKING, and nothing else is", async ({ page }) => {
+ await page.goto("/browse/decisions?severity=blocking");
+
+ // These are the two that shipped in real videos: one manifest with no
+ // siteOrigin (19 QR codes reading `undefined/?v=…`) and one pointing at
+ // localhost (codes that resolve to nothing on a phone).
+ const origins = page.locator("[data-decision='manifest-invalid'][data-target='provenance.siteOrigin']");
+ await expect(origins).toHaveCount(2);
+
+ await expect(
+ page.locator("[data-project='reports/no-origin-fixture'] [data-decision='manifest-invalid']"),
+ ).toContainText("undefined");
+ await expect(
+ page.locator("[data-project='reports/localhost-fixture'] [data-decision='manifest-invalid']"),
+ ).toContainText("localhost");
+});
+
+test("a clip that ends mid-sentence is reported once, and lockEnd acknowledges it", async ({
+ page,
+}) => {
+ await page.goto("/browse/decisions?kind=clip-mid-sentence");
+
+ // c01 ends inside "And this one runs on and because". c04 ends inside
+ // "trailing off and then" but sets lockEnd -- which is the author saying "I
+ // meant to cut here", so it must NOT appear.
+ await expect(page.locator("[data-decision='clip-mid-sentence']")).toHaveCount(1);
+ await expect(page.locator("[data-decision='clip-mid-sentence']")).toHaveAttribute(
+ "data-target",
+ "c01",
+ );
+});
+
+test("an unpunctuated source is said ONCE, not once per clip", async ({ page }) => {
+ await page.goto("/browse/decisions?kind=no-punctuation");
+ // vid2 has no terminator anywhere. Six real projects produced forty-odd of
+ // these rows before they were collapsed, which is an inbox whose blocking
+ // rows have scrolled off the top.
+ await expect(page.locator("[data-decision='no-punctuation']")).toHaveCount(1);
+});
+
+test("a project named for a tool page is BLOCKING, and the tool page still wins", async ({
+ page,
+}) => {
+ await page.goto("/browse");
+ const card = page.locator("[data-project='find']");
+ await expect(card).toHaveAttribute("data-routing", "shadowed");
+ // Its link goes to the escape hatch, not to an address that renders something
+ // else. Before this it linked to /browse/find and failed silently.
+ await expect(card).toHaveAttribute("href", /\/browse\/at\?path=find/);
+
+ await page.goto("/browse/find");
+ // The phrase console, not the project.
+ await expect(page.locator("[data-project='find']")).toHaveCount(0);
+
+ await page.goto("/browse/at?path=find");
+ // The banner says it and so does the decision row -- both are correct, so the
+ // assertion takes the first rather than pretending only one exists.
+ await expect(page.getByText(/is a tool page/).first()).toBeVisible();
+ await expect(page.getByText("Shadowed By A Tool Page")).toBeVisible();
+});
+
+test("a pass-through folder chain collapses for DISPLAY and never in the URL", async ({ page }) => {
+ await page.goto("/browse");
+ // `deep` holds no projects and one child, so the heading reads as one label.
+ await expect(page.locator("[data-folder='reports/deep/nested']")).toContainText("deep / nested");
+
+ // The URL is not collapsed, and every level of it resolves.
+ await page.goto("/browse/reports/deep/nested/solo-fixture");
+ await expect(page.getByText("Down A Pass-Through Chain")).toBeVisible();
+ await page.goto("/browse/reports/deep/nested");
+ await expect(page.locator("[data-project='reports/deep/nested/solo-fixture']")).toBeVisible();
+});
+
+test("the song URLs that already existed still mean the same thing", async ({ page }) => {
+ // A project id is a path now, but /browse/alpha and /browse/alpha/wide are
+ // the URLs in every decision href, every spec, and whatever anybody has open.
+ await page.goto("/browse/alpha");
+ await expect(page.locator("[data-cut=wide]")).toBeVisible();
+ await page.goto("/browse/alpha/wide");
+ await expect(page).toHaveURL(/\/browse\/alpha\/wide/);
+ // The cut page's own breadcrumb, which is the thing that proves the VIEW
+ // resolved rather than the project page having been served for both URLs.
+ await expect(page.getByText(/^every note \(\d+\)$/)).toBeVisible();
+
+ // And the canonical path works too.
+ await page.goto("/browse/reports/videos/alpha");
+ await expect(page.locator("[data-cut=wide]")).toBeVisible();
+});
+
+test("every tool page under /browse still wins its route", async ({ page }) => {
+ for (const p of ["/browse/decisions", "/browse/find", "/browse/sources", "/browse/faces"]) {
+ const res = await page.goto(p);
+ expect(res?.status(), `${p} should still be 200`).toBe(200);
+ }
+});
+
+// ---------------------------------------------------------------------------
+// The three source-level assertions.
+// ---------------------------------------------------------------------------
+
+test("no page or lib outside the registry special-cases a kind", () => {
+ const ids = JSON.parse(
+ execFileSync(
+ "node",
+ ["-e", "import('./lib/projects/kinds.mjs').then(m=>console.log(JSON.stringify(m.PROJECT_KINDS.map(k=>k.id))))"],
+ { cwd: UMTOOL, encoding: "utf8" },
+ ).trim(),
+ ) as string[];
+
+ // What is being caught is a BRANCH ON A KIND -- `if (p.kind === "song")`,
+ // `kind: "report-video"`, a lookup keyed by one -- not the mere appearance of
+ // the word. That distinction has to be drawn: `song` is also a query
+ // parameter name in eight routes and a directory name in three modules, and a
+ // bare grep for it reports eleven files that are entirely correct.
+ //
+ // So a line is an offender when it carries a kind id AS A STRING and mentions
+ // `kind` on the same line. It is a heuristic and worth saying so: a
+ // sufficiently indirect special-case (assigning the id to a const first) would
+ // slip past. It catches the shape people actually write.
+ const offenders: string[] = [];
+ for (const id of ids) {
+ let out = "";
+ try {
+ out = execFileSync(
+ "grep",
+ [
+ "-rn",
+ "--include=*.ts",
+ "--include=*.tsx",
+ "--include=*.mjs",
+ "-e",
+ `"${id}"`,
+ "-e",
+ `'${id}'`,
+ "app",
+ "lib",
+ "components",
+ ],
+ { cwd: UMTOOL, encoding: "utf8" },
+ );
+ } catch {
+ out = ""; // grep exits 1 when it finds nothing, which is the good case
+ }
+ for (const line of out.split("\n").filter(Boolean)) {
+ const file = line.split(":")[0];
+ if (file.startsWith("lib/projects/") || file.startsWith("components/projects/")) continue;
+ if (!/kind/i.test(line.slice(file.length))) continue;
+ offenders.push(line);
+ }
+ }
+
+ // This is the mechanical form of "adding a kind costs a registry entry and one
+ // view". An `if (kind === "report-video")` in a page lands here.
+ expect(offenders, `kind ids leaked outside the registry:\n${offenders.join("\n")}`).toEqual([]);
+});
+
+test("the reserved names are exactly the static pages under app/browse", () => {
+ const real = readdirSync(path.join(UMTOOL, "app", "browse"), { withFileTypes: true })
+ .filter((e) => e.isDirectory() && !e.name.startsWith("["))
+ .map((e) => e.name)
+ .sort();
+
+ const declared = JSON.parse(
+ execFileSync(
+ "node",
+ ["-e", "import('./lib/projects/kinds.mjs').then(m=>console.log(JSON.stringify(m.RESERVED_BROWSE)))"],
+ { cwd: UMTOOL, encoding: "utf8" },
+ ).trim(),
+ ) as string[];
+
+ // A tenth tool page must not silently make a project unreachable.
+ expect([...declared].sort()).toEqual(real);
+});
+
+test("a kind the registry has never seen appears everywhere, with no code edit", () => {
+ // The extensibility claim, tested rather than asserted. UMTOOL_EXTRA_KINDS is
+ // read only by kinds.mjs; if a new kind needs an edit anywhere else, this
+ // fails.
+ const extra = JSON.stringify([
+ { id: "fixture-kind", template: "fixture", label: "fixture kind", badge: "fix", marker: "FIXTURE.marker" },
+ ]);
+ const out = execFileSync(
+ "node",
+ [
+ "-e",
+ "import('./lib/projects/kinds.mjs').then(m=>console.log(JSON.stringify({" +
+ "ids:m.PROJECT_KINDS.map(k=>k.id)," +
+ "meta:m.KIND_META().map(k=>k.badge)," +
+ "detected:m.detectKind(new Set(['FIXTURE.marker']))})))",
+ ],
+ { cwd: UMTOOL, encoding: "utf8", env: { ...process.env, UMTOOL_EXTRA_KINDS: extra } },
+ );
+ const j = JSON.parse(out.trim());
+
+ expect(j.ids).toContain("fixture-kind");
+ expect(j.meta).toContain("fix");
+ expect(j.detected).toEqual({ kind: "fixture-kind", template: "fixture" });
+});
diff --git a/umtool/lib/browse.ts b/umtool/lib/browse.ts
@@ -4,7 +4,7 @@ import { SONG_REPORTS, labelFor, resolveInRoots } from "./paths";
import { probeMedia, type MediaInfo } from "./media";
import { readJson } from "./state";
import { CUT_NAMES as CUT_NAMES_RAW } from "./projects/song.mjs";
-import { walkProjects } from "./projects/walk.mjs";
+import { songIdsUnder } from "./projects/song-ids.mjs";
import { REPORTS_ROOT } from "./paths";
import type { CutName } from "./project-types";
import { acceptedFor, readThumbAccepted, type ThumbDoc } from "./thumbs";
@@ -364,30 +364,8 @@ export async function readSong(id: string): Promise<Song | null> {
};
}
-/**
- * Every song, by the basename the song routes are keyed on.
- *
- * This is now a caller of the project walk rather than its own readdir, so
- * there is ONE enumerator and a song cannot be a project in one listing and
- * absent from the other.
- *
- * It is still filtered to songs that live directly under BROWSE_ROOT, because
- * every route in this file resolves through songDir() -- `path.join(BROWSE_ROOT,
- * id)`. A song project found anywhere else is listed and summarised on /browse
- * by the registry; returning it here would hand the routes an id that resolves
- * to a directory that is not it, which is the one thing worse than omitting it.
- */
-export async function songIds(): Promise<string[]> {
- const projects = await walkProjects(REPORTS_ROOT);
- return projects
- .filter((p) => p.kind === SONG_KIND && p.dir === path.join(BROWSE_ROOT, p.name))
- .map((p) => p.name)
- .sort();
-}
-
-// The one place this file names a kind. Everything else about a song here
-// predates the registry and is keyed by basename.
-const SONG_KIND = "song";
+/** Every song, by the basename the song routes are keyed on. See song.mjs. */
+export const songIds = (): Promise<string[]> => songIdsUnder(REPORTS_ROOT, BROWSE_ROOT);
/** Every song, newest first. Never probes -- the index must not shell out. */
export async function listSongs(): Promise<SongSummary[]> {
diff --git a/umtool/lib/media.ts b/umtool/lib/media.ts
@@ -5,7 +5,7 @@ import { promisify } from "node:util";
import { mkdir, readdir, readFile, rename, stat, writeFile } from "node:fs/promises";
import { existsSync } from "node:fs";
import path from "node:path";
-import { MEDIA_ROOTS, MIX_CACHE, labelFor } from "./paths";
+import { MEDIA_ROOTS, MIX_CACHE, REPORTS_ROOT, SONG_REPORTS, labelFor } from "./paths";
import { brightnessCurve, brightnessSteps } from "../song/flatness.mjs";
const run = promisify(execFile);
@@ -69,14 +69,65 @@ export async function probeMedia(abs: string): Promise<MediaInfo> {
// `poly-song-<name>.wav` beside it. Unfiltered they outnumbered the actual
// renders four to one in the picker, and they are all newest-first, so the
// deliverables were pushed off the end of the list.
-const SCRATCH_DIR = /^(polytmp-|frames?\d*$|snap\d*$|qrtest$|facedet$|models$|vtest?$|verify$|tism$|fr$|vt$)/;
+// The four added names are a report video's INTERMEDIATES. Its out/ directory
+// holds one deliverable and 40-60 working files -- the fetched source windows,
+// the per-clip segments, the card PNGs, the QR codes. Reaching the deliverable
+// means walking one level deeper, and walking one level deeper without these
+// would put ~260 intermediates in a picker that is already saturated.
+const SCRATCH_DIR = /^(polytmp-|frames?\d*$|snap\d*$|qrtest$|facedet$|models$|vtest?$|verify$|tism$|fr$|vt$|segments$|clips-raw$|cards$|qr$)/;
const SCRATCH_FILE = /^(poly-song-|polytmp-|seg_|i_|o_|ms\d?seg|out\.raw)/;
/** Below this is a fragment, a probe or a one-note extraction, not a track. */
const MIN_INTERESTING = 256 * 1024;
-/** Every media file under the roots, two directories deep, newest first. */
-export async function listMedia(limit = 400): Promise<{ path: string; label: string; size: number; mtimeMs: number }[]> {
- const out: { path: string; label: string; size: number; mtimeMs: number }[] = [];
+export type MediaRow = { path: string; label: string; size: number; mtimeMs: number };
+
+/**
+ * Every media file under one directory, newest first.
+ *
+ * Split out of listMedia() so a PROJECT can be enumerated directly. The picker
+ * needs that: `listMedia` is newest-first over the whole tree with a cap, and
+ * the cap is saturated -- measured, four of the six report deliverables fell off
+ * the end of a 600-entry list. Asking each project for its own files instead
+ * makes coverage a property of the enumeration rather than of the cap.
+ */
+export async function listMediaUnder(root: string, maxDepth = 2, limit = 200): Promise<MediaRow[]> {
+ const out: MediaRow[] = [];
+ const seen = new Set<string>();
+ const walk = async (dir: string, depth: number) => {
+ let entries;
+ try {
+ entries = await readdir(dir, { withFileTypes: true });
+ } catch {
+ return;
+ }
+ for (const e of entries) {
+ if (e.name.startsWith(".")) continue;
+ const abs = path.join(dir, e.name);
+ if (e.isDirectory()) {
+ if (depth > 0 && !SCRATCH_DIR.test(e.name)) await walk(abs, depth - 1);
+ continue;
+ }
+ if (!MEDIA_EXT.has(path.extname(e.name).toLowerCase())) continue;
+ if (SCRATCH_FILE.test(e.name)) continue;
+ if (seen.has(abs)) continue;
+ seen.add(abs);
+ try {
+ const st = await stat(abs);
+ if (st.size < MIN_INTERESTING) continue;
+ out.push({ path: abs, label: labelFor(abs), size: st.size, mtimeMs: st.mtimeMs });
+ } catch {
+ /* vanished between readdir and stat */
+ }
+ }
+ };
+ await walk(root, maxDepth);
+ out.sort((a, b) => b.mtimeMs - a.mtimeMs);
+ return out.slice(0, limit);
+}
+
+/** Every media file under the roots, newest first. */
+export async function listMedia(limit = 400): Promise<MediaRow[]> {
+ const out: MediaRow[] = [];
const seen = new Set<string>();
const walk = async (dir: string, depth: number) => {
let entries;
@@ -105,7 +156,19 @@ export async function listMedia(limit = 400): Promise<{ path: string; label: str
}
}
};
- for (const r of MEDIA_ROOTS) await walk(r, 1);
+
+ // Depth is PER ROOT, not one number.
+ //
+ // The project roots need two levels: a report video's deliverable is at
+ // <project>/out/<slug>.mp4, and a song's cut is at videos/<song>/<cut>.mp4.
+ // Both were invisible here before -- six of seven projects had no entry at all.
+ //
+ // SONG_DATA and SONG_SCRATCH stay at one. They are the 39 GB corpus and the
+ // render scratch; a second level there is thousands of stats of clip fragments
+ // to find nothing anybody would load, which is the opposite of the problem
+ // this is fixing.
+ const depthFor = (r: string) => (r === REPORTS_ROOT || r === SONG_REPORTS ? 2 : 1);
+ for (const r of MEDIA_ROOTS) await walk(r, depthFor(r));
out.sort((a, b) => b.mtimeMs - a.mtimeMs);
return out.slice(0, limit);
}
diff --git a/umtool/lib/project-types.ts b/umtool/lib/project-types.ts
@@ -77,6 +77,8 @@ export type ProjectRef = {
};
export type ProjectSummary = ProjectRef & {
+ /** The kind's short badge, copied in so a card needs no registry lookup. */
+ badge: string;
title: string;
/** One line under the title. Kind-specific. */
subtitle: string | null;
@@ -91,6 +93,16 @@ export type ProjectSummary = ProjectRef & {
posterRel: string | null;
/** Everything a `?q=` substring match should see. */
haystack: string;
+ /**
+ * `data-*` attributes the kind wants on its card.
+ *
+ * The grid renders strings and knows no kinds, so this is how a kind keeps an
+ * assertion surface of its own -- a song's missing cut list, a report's clip
+ * count -- without the grid growing a branch per kind. A key whose value is
+ * absent is OMITTED, so "nothing is missing" is the attribute not being there
+ * rather than an empty string.
+ */
+ attrs?: Record<string, string>;
};
export type FolderNode = {
diff --git a/umtool/lib/projects.ts b/umtool/lib/projects.ts
@@ -2,7 +2,10 @@ import path from "node:path";
import { REPORTS_ROOT } from "./paths";
import { KIND_META, PROJECT_KINDS, kindById } from "./projects/kinds.mjs";
import { collapseFolders, foldersFor, walkProjects } from "./projects/walk.mjs";
+import { SONG_KIND } from "./projects/song.mjs";
+import { BROWSE_ROOT } from "./browse";
import { decisionsForSong } from "./decisions";
+import { listMedia, listMediaUnder, type MediaRow } from "./media";
import type { Decision } from "./decisions";
import type {
FolderNode,
@@ -35,8 +38,11 @@ export const KINDS: ProjectKindMeta[] = KIND_META();
* kind. A closed union in lib/decisions.ts would put every kind's words in one
* shared file -- exactly the coupling the registry exists to remove.
*/
+/** Emitted by the walk rather than by any kind: how a project is reachable. */
+export const ROUTING_DECISION_KINDS = ["shadowed-name", "unroutable-name", "ambiguous-project"];
+
export const ALL_DECISION_KINDS: string[] = [
- ...new Set(KINDS.flatMap((k) => k.decisionKinds)),
+ ...new Set([...KINDS.flatMap((k) => k.decisionKinds), ...ROUTING_DECISION_KINDS]),
].sort();
// ---------------------------------------------------------------------------
@@ -82,6 +88,7 @@ type KindSummary = {
flags?: string[];
posterRel?: string | null;
haystack?: string;
+ attrs?: Record<string, string>;
};
type Ctx = ProjectRef & { root: string };
@@ -128,6 +135,7 @@ export async function summariseProject(p: ProjectRef): Promise<ProjectSummary> {
const value: ProjectSummary = {
...p,
+ badge: k?.badge ?? p.kind,
title: body.title ?? p.name,
subtitle: body.subtitle ?? null,
state: (body.state as ProjectState) ?? "draft",
@@ -136,6 +144,7 @@ export async function summariseProject(p: ProjectRef): Promise<ProjectSummary> {
flags,
posterRel: body.posterRel ?? null,
haystack: `${body.haystack ?? ""} ${p.id} ${p.kind} ${p.template}`.toLowerCase(),
+ attrs: body.attrs ?? {},
};
summaryCache.set(p.id, { sig, value });
return value;
@@ -162,8 +171,6 @@ export async function listFolders(): Promise<Map<string, FolderNode>> {
// it from this side keeps the import graph acyclic and keeps the one kind-id
// literal the app needs inside the registry's own module.
// ---------------------------------------------------------------------------
-const SONG_KIND = "song";
-
export async function decisionsForProject(p: ProjectRef): Promise<Decision[]> {
const sig = await signatureOf(p);
const hit = decisionCache.get(p.id);
@@ -172,12 +179,12 @@ export async function decisionsForProject(p: ProjectRef): Promise<Decision[]> {
let out: Decision[] = [];
try {
if (p.kind === SONG_KIND) {
- // Keyed by basename while the song routes still are. A song project found
- // anywhere else is listed and summarised, and says so rather than
- // resolving to the wrong directory.
- out = p.dir === path.join(REPORTS_ROOT, "quartering-uh-song", "videos", p.name)
- ? await decisionsForSong(p.name)
- : [];
+ // Keyed by BASENAME while the song routes still are -- and the test for
+ // "is this song reachable by basename" has to be the same one songIds()
+ // uses, which is BROWSE_ROOT. Writing the production path out by hand here
+ // meant the fixture (whose songs live elsewhere) silently produced no song
+ // decisions at all, and the inbox looked clean because it was empty.
+ out = p.dir === path.join(BROWSE_ROOT, p.name) ? await decisionsForSong(p.name) : [];
} else {
const k = kindById(p.kind);
if (k?.decisions) {
@@ -189,6 +196,44 @@ export async function decisionsForProject(p: ProjectRef): Promise<Decision[]> {
out = [];
}
+ // Routing failures are kind-independent, so they are added here rather than
+ // asked of every kind. Both of these used to be SILENT: a shadowed project was
+ // listed with a link that rendered a tool page, and an unroutable one simply
+ // did not appear. A thing that cannot be opened has to say so.
+ if (p.routing === "shadowed") {
+ out.push({
+ kind: "shadowed-name",
+ project: p.id,
+ target: p.id.split("/")[0],
+ why: `/browse/${p.id.split("/")[0]} is a tool page and always wins the route — rename the directory, or open it from here`,
+ href: `/browse/at?path=${encodeURIComponent(p.id)}`,
+ severity: "blocking",
+ at: Date.now(),
+ });
+ }
+ if (p.routing === "unroutable") {
+ out.push({
+ kind: "unroutable-name",
+ project: p.id,
+ target: p.name,
+ why: "this name cannot be a URL segment, so the project has no address of its own",
+ href: `/browse/at?path=${encodeURIComponent(p.id)}`,
+ severity: "info",
+ at: Date.now(),
+ });
+ }
+ if (p.ambiguousWith) {
+ out.push({
+ kind: "ambiguous-project",
+ project: p.id,
+ target: p.ambiguousWith.join(" + "),
+ why: "two kinds match this directory — it is read as the first, which is a bug rather than a choice",
+ href: `/browse/${p.id}`,
+ severity: "blocking",
+ at: Date.now(),
+ });
+ }
+
const title = (await summariseProject(p)).title;
const value = out.map((d) => ({ ...d, project: p.id, projectTitle: title, projectKind: p.kind }));
decisionCache.set(p.id, { sig, value });
@@ -227,8 +272,9 @@ export async function decisionCounts(): Promise<Map<string, { blocking: number;
// ---------------------------------------------------------------------------
export type Resolved =
- | { project: ProjectRef; rest: string[]; folder: null }
+ | { project: ProjectRef; rest: string[]; folder: null; via?: "alias" }
| { project: null; rest: []; folder: FolderNode }
+ | { project: null; rest: []; folder: null; ambiguous: ProjectRef[] }
| null;
/**
@@ -257,6 +303,28 @@ export async function resolveProjectPath(segments: string[]): Promise<Resolved>
const folders = await listFolders();
const folder = folders.get(id);
if (folder) return { project: null, rest: [], folder };
+
+ // ---- the basename alias -------------------------------------------------
+ //
+ // A project id is a PATH, so a song's canonical URL is now
+ // /browse/quartering-uh-song/videos/yoshi. But /browse/yoshi and
+ // /browse/yoshi/wide are the URLs that exist -- in every decision href, in
+ // the e2e suite, and in whatever anybody has open in a tab. A URL has to mean
+ // the same thing in six weeks, so the bare name keeps working as an alias.
+ //
+ // ONLY when it is unique. Two projects sharing a basename is precisely why an
+ // id is a path in the first place, and picking one of them would be the guess
+ // this whole design refuses to make -- so it reports the collision instead.
+ const [first, ...restSegs] = segments;
+ if (first) {
+ const named = refs.filter((p) => p.name === first && p.routing === "ok");
+ if (named.length === 1) {
+ return { project: named[0], rest: restSegs, folder: null, via: "alias" };
+ }
+ if (named.length > 1) {
+ return { project: null, rest: [], folder: null, ambiguous: named };
+ }
+ }
return null;
}
@@ -265,3 +333,48 @@ export const kindMetaOf = (id: string): ProjectKindMeta | null =>
KINDS.find((k) => k.id === id) ?? null;
export { PROJECT_KINDS };
+
+
+// ---------------------------------------------------------------------------
+// Media, grouped by the project that owns it.
+//
+// The mix picker was a single newest-first list with a cap, and the cap was
+// saturated: measured against the real tree, four of the six report deliverables
+// fell off the end of a 600-entry list, and per-song cuts never appeared at all.
+// Raising the cap only moves the cliff.
+//
+// So coverage becomes a property of the enumeration. Each project is asked for
+// its own files -- with its own small cap, so one busy project cannot push every
+// other project off the end -- and whatever is left over (the corpus, the render
+// scratch) becomes one final group. `<optgroup>` renders it with zero client JS
+// and the control stays a native select.
+// ---------------------------------------------------------------------------
+
+export type MediaGroup = {
+ project: string;
+ label: string;
+ kind: string;
+ files: MediaRow[];
+};
+
+const PER_PROJECT = 40;
+
+export async function mediaGroups(): Promise<{ groups: MediaGroup[]; other: MediaRow[] }> {
+ const refs = await projectRefs();
+ const groups: MediaGroup[] = [];
+ for (const p of refs) {
+ const files = await listMediaUnder(p.dir, 2, PER_PROJECT);
+ if (!files.length) continue;
+ const summary = await summariseProject(p);
+ groups.push({ project: p.id, label: `${p.id} (${summary.badge})`, kind: p.kind, files });
+ }
+ groups.sort((a, b) => a.project.localeCompare(b.project));
+
+ // Longest-prefix ownership, so a file inside a project never also appears in
+ // `other` -- and so nothing needs a second walk to work out who owns what.
+ const dirs = refs.map((p) => p.dir + path.sep);
+ const owned = (abs: string) => dirs.some((d) => abs.startsWith(d));
+ const other = (await listMedia(600)).filter((f) => !owned(f.path));
+
+ return { groups, other };
+}
diff --git a/umtool/lib/projects/report.mjs b/umtool/lib/projects/report.mjs
@@ -7,6 +7,14 @@
// and neither parses JSON.
import { readdir, readFile, stat } from "node:fs/promises";
import path from "node:path";
+import { cachedWindowsFor, findContainingWindow } from "report-to-video/build-video";
+import { widen } from "report-to-video/resolve-windows";
+
+// widen() and the cache's window naming are IMPORTED, never reimplemented. The
+// bench's "extend to sentence end" has to be the same function the CLI runs, or
+// the UI and `resolve-windows --write` will disagree about where a clip ends --
+// and the CLI is the one that wins, silently, on the next build.
+export { widen };
export const MANIFEST_NAME = "video.manifest.json";
@@ -147,13 +155,19 @@ export async function buildStateOf(dir, manifest) {
let rawCount = 0;
let segCount = 0;
+ let firstSeg = null;
+ let firstRaw = null;
if (!fin) {
const [raws, segs] = await Promise.all([
readdir(path.join(outDir, "clips-raw")).catch(() => []),
readdir(path.join(outDir, "segments")).catch(() => []),
]);
- rawCount = raws.filter((n) => n.endsWith(".mp4")).length;
- segCount = segs.filter((n) => n.endsWith(".mp4")).length;
+ const rawMp4 = raws.filter((n) => n.endsWith(".mp4")).sort();
+ const segMp4 = segs.filter((n) => n.endsWith(".mp4")).sort();
+ rawCount = rawMp4.length;
+ segCount = segMp4.length;
+ firstSeg = segMp4[0] ?? null;
+ firstRaw = rawMp4[0] ?? null;
}
const stale = !!(fin && man && fin.mtimeMs < man.mtimeMs);
@@ -168,6 +182,8 @@ export async function buildStateOf(dir, manifest) {
manifestMtimeMs: man?.mtimeMs ?? 0,
rawCount,
segCount,
+ firstSeg,
+ firstRaw,
};
}
@@ -242,7 +258,16 @@ export async function summariseReport(ctx) {
newestMtimeMs: Math.max(build.manifestMtimeMs, build.finalMtimeMs),
facts,
flags,
- posterRel: build.built ? path.posix.join("out", `${build.slug}.mp4`) : null,
+ // A card should look like the video as soon as anything of it exists. A
+ // built SEGMENT already carries the chrome, so it is the better mid-build
+ // poster than a raw clip; a raw clip is better than a blank tile.
+ posterRel: build.built
+ ? path.posix.join("out", `${build.slug}.mp4`)
+ : build.firstSeg
+ ? path.posix.join("out", "segments", build.firstSeg)
+ : build.firstRaw
+ ? path.posix.join("out", "clips-raw", build.firstRaw)
+ : null,
haystack: [
ctx.id, m?.title, m?.subtitle, m?.slug,
m?.provenance?.channel, m?.provenance?.channelSlug,
@@ -251,6 +276,14 @@ export async function summariseReport(ctx) {
.filter(Boolean)
.join(" ")
.toLowerCase(),
+ attrs: {
+ clips: String(clips.length),
+ cards: String(cards.length),
+ sources: String(sources),
+ locked: String(locked),
+ ...(build.built ? { built: "1" } : {}),
+ ...(isDeadOrigin(m?.provenance?.siteOrigin) ? { "dead-origin": "1" } : {}),
+ },
// Kept for the project page and the decisions pass, so neither re-reads.
manifest: m,
build,
@@ -471,3 +504,110 @@ export async function reportDecisions(ctx, summary) {
return out;
}
+
+
+// ---------------------------------------------------------------------------
+// Per-clip detail: what the project page lists and the clip bench edits.
+//
+// This is the expensive read -- one cue file per distinct source -- so it is
+// NOT what the index calls. summariseReport() is.
+// ---------------------------------------------------------------------------
+export async function readClipDetail(dir, { manifest = null } = {}) {
+ const m = manifest ?? (await readManifest(dir));
+ if (!m) return null;
+
+ const shadowExists = await hasShadowChannels(dir);
+ const channelsDir = channelsDirFor(dir, m, { shadowExists });
+ const build = await buildStateOf(dir, m);
+ const rawDir = path.join(dir, "out", "clips-raw");
+ const segDir = path.join(dir, "out", "segments");
+
+ const segNames = new Set(await readdir(segDir).catch(() => []));
+ const cueCache = new Map();
+
+ const entries = [];
+ for (const e of m.timeline ?? []) {
+ if (e.type === "card") {
+ entries.push({ ...e, kind: "card" });
+ continue;
+ }
+ const chan = channelFor(m, e);
+ const key = `${chan}/${e.video}`;
+ if (!cueCache.has(key)) {
+ const file = cuePathFor(m, e, channelsDir);
+ cueCache.set(key, file ? await readCues(file) : null);
+ }
+ const doc = cueCache.get(key);
+
+ // The pad the build would use, so "is this clip cached" answers the same
+ // question the build will ask.
+ const pad = m.render?.fetchPad ?? 3.0;
+ const from = Math.max(0, e.start - pad);
+ const to = e.end + pad;
+ const cached = await findContainingWindow(rawDir, e.video, from, to);
+ const allWindows = await cachedWindowsFor(rawDir, e.video);
+ // The bench wants the WIDEST containing file (room to drag); the build wants
+ // the tightest (least to decode). They are different questions.
+ const widest = allWindows
+ .filter((w) => w.from <= e.start && w.to >= e.end)
+ .sort((a, b) => b.to - b.from - (a.to - a.from))[0] ?? null;
+
+ const endCue = doc ? cueAt(doc.cues, e.end, "end") : null;
+ const endsSentence = endCue ? ENDS_SENTENCE.test(endCue.text ?? "") : null;
+ // What resolve-windows WOULD do, computed in-process because widen() is pure
+ // once the cues are read. It is the difference between "run the widener and
+ // see" and knowing before you touch anything.
+ let proposed = null;
+ if (doc?.cues?.length && !e.lock) {
+ const w = widen(doc.cues, e.start, e.end);
+ if (e.lockStart) w.start = e.start;
+ if (e.lockEnd) w.end = e.end;
+ const moved = Math.abs(w.start - e.start) > 0.05 || Math.abs(w.end - e.end) > 0.05;
+ proposed = moved ? { start: Number(w.start.toFixed(2)), end: Number(w.end.toFixed(2)) } : null;
+ }
+
+ entries.push({
+ ...e,
+ kind: "clip",
+ channel: chan,
+ cueFile: cuePathFor(m, e, channelsDir),
+ hasCues: !!doc,
+ duration: doc?.duration ?? null,
+ sourceTitle: doc?.title ?? null,
+ punctuationRate: doc?.punctuationRate ?? null,
+ endCueText: endCue?.text ?? null,
+ endsSentence,
+ proposed,
+ cached: cached ? { name: cached.name, from: cached.from, to: cached.to } : null,
+ widest: widest ? { name: widest.name, from: widest.from, to: widest.to } : null,
+ segment: segNames.has(`${e.id}.mp4`) ? path.posix.join("out", "segments", `${e.id}.mp4`) : null,
+ wantFrom: from,
+ wantTo: to,
+ });
+ }
+
+ return { manifest: m, build, channelsDir, shadowExists, entries };
+}
+
+/** The cues a clip bench draws, trimmed to a window. Absolute source seconds. */
+export async function cuesInWindow(dir, clipId, from, to) {
+ const m = await readManifest(dir);
+ const e = clipsOf(m).find((x) => x.id === clipId);
+ if (!e) return null;
+ const shadowExists = await hasShadowChannels(dir);
+ const file = cuePathFor(m, e, channelsDirFor(dir, m, { shadowExists }));
+ const doc = file ? await readCues(file) : null;
+ if (!doc) return null;
+ return {
+ duration: doc.duration ?? null,
+ punctuationRate: doc.punctuationRate,
+ cues: doc.cues
+ .filter((c) => c.end >= from && c.start <= to)
+ .map((c) => ({
+ start: c.start,
+ end: c.end,
+ text: c.text,
+ endsSentence: ENDS_SENTENCE.test(c.text ?? "") && !IS_FILLER.test(c.text ?? ""),
+ })),
+ };
+}
diff --git a/umtool/lib/projects/song-ids.mjs b/umtool/lib/projects/song-ids.mjs
@@ -0,0 +1,35 @@
+// songIds(), in its own module for one specific reason.
+//
+// It needs the WALK, and the walk needs the registry, and the registry needs
+// this kind's cut list -- so putting it in song.mjs closes the cycle
+// song -> walk -> kinds -> song. Under plain node that survives (the back edge
+// is only used at call time), but Turbopack evaluates the bundle in an order
+// where kinds.mjs reads CUT_NAMES while song.mjs is still initialising, and
+// every song page 500s with "Cannot access 'CUT_NAMES' before initialization".
+//
+// `pnpm build` does not catch it, because nothing prerenders. A page render
+// does. Hence a third module, which only the app's edge imports.
+import path from "node:path";
+import { walkProjects } from "./walk.mjs";
+import { SONG_KIND } from "./song.mjs";
+
+/**
+ * Every song, by the BASENAME the song routes are keyed on.
+ *
+ * A caller of the project walk rather than its own readdir, so there is one
+ * enumerator and a song cannot be a project in one listing and absent from the
+ * other.
+ *
+ * Filtered to songs that live directly under `browseRoot`, because every song
+ * route resolves through songDir() -- path.join(BROWSE_ROOT, id). A song project
+ * found anywhere else is still listed and summarised on /browse by the registry;
+ * returning it here would hand those routes an id that resolves to a directory
+ * that is not it, which is worse than omitting it.
+ */
+export async function songIdsUnder(reportsRoot, browseRoot) {
+ const projects = await walkProjects(reportsRoot);
+ return projects
+ .filter((p) => p.kind === SONG_KIND && p.dir === path.join(browseRoot, p.name))
+ .map((p) => p.name)
+ .sort();
+}
diff --git a/umtool/lib/projects/song.mjs b/umtool/lib/projects/song.mjs
@@ -7,6 +7,8 @@
// terminal with no server, and because the index pays this per project.
import { readdir, readFile, stat } from "node:fs/promises";
import path from "node:path";
+/** This kind's registry id. The one place the string lives. */
+export const SONG_KIND = "song";
// The cut list is FIXED, not derived from the directory: mortal-kombat and
// mario-rpg have no vertical.mp4, and a scan-derived list would render those
@@ -78,9 +80,22 @@ export async function summariseSong(ctx) {
flags: [],
posterRel: present.length ? `${present[0]}.mp4` : null,
haystack: [ctx.id, title, ...bases].join(" ").toLowerCase(),
+ attrs: {
+ song: name,
+ cuts: String(present.length),
+ variants: String(variants.length),
+ keep: String(counts.keep),
+ reject: String(counts.reject),
+ undecided: String(counts.undecided),
+ // Omitted entirely when the set is complete: a hole in the deliverables
+ // is information, and its ABSENCE has to be readable as "nothing missing"
+ // rather than as an empty list.
+ ...(missing.length ? { missing: missing.join(",") } : {}),
+ },
counts,
present,
missing,
variantCount: variants.length,
};
}
+
diff --git a/umtool/lib/utils.ts b/umtool/lib/utils.ts
@@ -0,0 +1,14 @@
+import { clsx, type ClassValue } from "clsx";
+import { twMerge } from "tailwind-merge";
+
+/**
+ * The class merger shadcn's variants are written against.
+ *
+ * twMerge is the part that earns its place: it resolves CONFLICTS by Tailwind's
+ * own semantics, so a variant's `px-2` and a caller's `px-4` do not both end up
+ * in the class list with the winner decided by stylesheet order. Without it,
+ * every override has to be a longer selector or a `!`.
+ */
+export function cn(...inputs: ClassValue[]): string {
+ return twMerge(clsx(inputs));
+}
diff --git a/umtool/package.json b/umtool/package.json
@@ -11,9 +11,13 @@
"e2e": "node ../scripts/queue-lock.mjs --ports UMTOOL_E2E_PORT:3051 -- playwright test"
},
"dependencies": {
+ "class-variance-authority": "^0.7.1",
+ "clsx": "^2.1.1",
"next": "16.2.3",
"react": "19.2.4",
"react-dom": "19.2.4",
+ "report-to-video": "workspace:*",
+ "tailwind-merge": "^3.6.0",
"yt-dlp-transcript-common": "workspace:*"
},
"devDependencies": {
diff --git a/umtool/playwright.config.ts b/umtool/playwright.config.ts
@@ -33,6 +33,11 @@ export default defineConfig({
command:
`node e2e/fixtures/make-fixture.mjs ${FIXTURE} && ` +
`SONG_CODE_DIR=${FIXTURE}/code SONG_DIR=${FIXTURE}/data SONG_REPORTS_DIR=${FIXTURE}/reports ` +
+ // The project walk reads REPORTS_ROOT, which defaults to
+ // dirname(SONG_REPORTS_DIR) -- so the fixture is confined with no new env
+ // var. CHANNELS_DIR has to be said explicitly: it is where a report
+ // video's cue files live, and its default is the real 3 GB corpus.
+ `CHANNELS_DIR=${FIXTURE}/channels ` +
`NEXT_DIST_DIR=.next-e2e pnpm exec next dev --port ${PORT}`,
port: PORT,
reuseExistingServer: false,