Archilyzer · Source

archilyzer

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

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:
M.gitignore | 9++++++++-
Mpnpm-lock.yaml | 14++++++++++++++
Mscripts/report-to-video/resolve-windows.mjs | 0
Mumtool/app/api/browse/poster/route.ts | 30++++++++++++++++++++++++++++++
Mumtool/app/api/mix/files/route.ts | 23++++++++++++++++++-----
Aumtool/app/browse/[...path]/page.tsx | 88+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Dumtool/app/browse/[song]/[cut]/page.tsx | 122-------------------------------------------------------------------------------
Dumtool/app/browse/[song]/page.tsx | 254-------------------------------------------------------------------------------
Aumtool/app/browse/at/page.tsx | 53+++++++++++++++++++++++++++++++++++++++++++++++++++++
Mumtool/app/browse/decisions/page.tsx | 27++++++++++++++-------------
Mumtool/app/browse/page.tsx | 267++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---------------------
Mumtool/app/globals.css | 38++++++++++++++++++++++++++++++++++++++
Aumtool/components.json | 21+++++++++++++++++++++
Mumtool/components/MixBench.tsx | 71++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-------
Aumtool/components/projects/CutPage.tsx | 125+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aumtool/components/projects/ProjectGrid.tsx | 104+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aumtool/components/projects/ProjectView.tsx | 60++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aumtool/components/projects/ReportProject.tsx | 255+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aumtool/components/projects/SongProject.tsx | 255+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aumtool/components/projects/SweepProject.tsx | 73+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aumtool/components/ui/badge.tsx | 49+++++++++++++++++++++++++++++++++++++++++++++++++
Aumtool/components/ui/button.tsx | 42++++++++++++++++++++++++++++++++++++++++++
Mumtool/e2e/browse.spec.ts | 10++++++----
Mumtool/e2e/deck.spec.ts | 6++++--
Mumtool/e2e/fixtures/make-fixture.mjs | 169+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aumtool/e2e/projects.spec.ts | 284+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mumtool/lib/browse.ts | 28+++-------------------------
Mumtool/lib/media.ts | 75+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++------
Mumtool/lib/project-types.ts | 12++++++++++++
Mumtool/lib/projects.ts | 133+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++------
Mumtool/lib/projects/report.mjs | 146+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--
Aumtool/lib/projects/song-ids.mjs | 35+++++++++++++++++++++++++++++++++++
Mumtool/lib/projects/song.mjs | 15+++++++++++++++
Aumtool/lib/utils.ts | 14++++++++++++++
Mumtool/package.json | 4++++
Mumtool/playwright.config.ts | 5+++++
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&rsquo;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)]"> + &ldquo;{String(e.quote).slice(0, 240)} + {String(e.quote).length > 240 ? "…" : ""}&rdquo; + </p> + )} + {midSentence && e.endCueText && ( + <p className="num mt-1 text-[11px] text-[var(--color-dirty)]"> + cut lands inside: &ldquo;…{String(e.endCueText).trim().slice(-64)}&rdquo; + </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,