Archilyzer · Source

archilyzer

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

commit df2ea2d0d65781848d6117719cb447ecc7e861bc
parent cf15ea46a152a45c0a96d718399bf906db011b5b
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date:   Tue, 18 Aug 2026 21:13:24 -0400

umtool: a project is a project, and ~/reports is the tree

The registry and the walk, behind the unchanged UI. Report videos and um-songs
become the same kind of thing -- a PROJECT with a state, open decisions and a
build -- and ~/reports becomes the tree they hang off instead of the um-song
subtree being the widest root there was.

A KIND is what a thing is; a TEMPLATE is which configuration of it. `um-song` is
not a kind, it is the one template of the `song` kind that exists so far, and the
distinction is the point: the next thing this holds will be a new template of an
existing kind at least as often as a new kind. Three ship: report-video (marker:
video.manifest.json), song (spec.json / verdicts.json / any cut), and
sweep-report -- a report that is not yet a video, which earns a kind on day one
because ~/reports/hasan-bike is one and because a kind with no decisions, no
build and no rich read is the cheapest proof the registry is extensible.

The walk rests on two rules. A PROJECT IS A LEAF, which is what keeps out/ (1,210
files, 3.1 GB) out of it entirely. A FOLDER WITH NO PROJECT BENEATH IT DOES NOT
EXIST, which drops ~40 loose test directories under quartering-uh-song with no
denylist to maintain. Measured: 12 projects in 25 ms, and the 3.1 GB never
touched. Identity is the POSIX path relative to REPORTS_ROOT, because bare names
collide across folders.

Two silent failures became states. A directory whose name isSegment() dislikes
used to vanish from the listing; one called `find` or `trim` used to be listed
with a link that rendered the tool page instead. They are now `unroutable` and
`shadowed`, each with a flag and a decision.

READ_ROOTS vs WRITE_ROOTS. resolveInRoots() guarded what may be opened AND what
may be rendered to, so widening the read root to reach report videos would in the
same stroke have made every report's out/ a legal render target -- a 46 MB
deliverable that cost an hour of fetches, one typo from being overwritten.
Reports are now readable and mixable and NOTHING new is writable. SONG_REPORTS
stays first in the read list so every existing relative label round-trips
unchanged.

The acceptance test, run against the real ~/reports: it reports exactly the two
defects that already shipped -- quartering-employee-count has no siteOrigin (19
QR codes encoding `undefined/?v=…`) and ferret-rescue has http://localhost:3000
(codes that resolve to nothing on a phone) -- plus one stale build, and nothing
else blocking.

Two findings from that first real run, both fixed rather than accepted:

  - It confidently reported three sources of quartering-flagging-takedowns as
    having no cue file. False. That project cites three DELETED YouTube uploads,
    cuts them from live Rumble mirrors, and builds against a shadow CHANNELS_DIR
    its own make-shadow-channels.sh writes. A project now says which channels
    directory it reads -- provenance.channelsDir, or the .shadow-channels
    convention that already existed -- and neither is a guess: both are things
    the project itself wrote down. When the builder is present but unrun, the
    decision says to run it rather than declaring the sources gone.

  - It emitted forty-odd identical "this upload has no punctuation" rows. True,
    useful, and forty times over it is an inbox whose blocking rows scroll off
    the top. One row per project now.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

Diffstat:
Mumtool/app/api/browse/decisions/route.ts | 3++-
Mumtool/app/browse/decisions/page.tsx | 2+-
Mumtool/lib/browse.ts | 42++++++++++++++++++++++++++++++------------
Mumtool/lib/decisions.ts | 46++++++++++++++++++++++++++--------------------
Mumtool/lib/mix.ts | 11++++++++---
Aumtool/lib/paths.mjs | 122+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mumtool/lib/paths.ts | 108++++++++++++++++++++++++++-----------------------------------------------------
Aumtool/lib/project-types.ts | 118+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aumtool/lib/projects.ts | 267+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aumtool/lib/projects/kinds.mjs | 191+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aumtool/lib/projects/report.mjs | 473+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aumtool/lib/projects/song.mjs | 86+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aumtool/lib/projects/sweep.mjs | 49+++++++++++++++++++++++++++++++++++++++++++++++++
Aumtool/lib/projects/walk.mjs | 195+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
14 files changed, 1603 insertions(+), 110 deletions(-)

diff --git a/umtool/app/api/browse/decisions/route.ts b/umtool/app/api/browse/decisions/route.ts @@ -1,4 +1,5 @@ -import { countBySeverity, decisionsForSong, openDecisions } from "@/lib/decisions"; +import { countBySeverity, decisionsForSong } from "@/lib/decisions"; +import { openDecisions } from "@/lib/projects"; import { isSegment } from "@/lib/browse"; export const dynamic = "force-dynamic"; diff --git a/umtool/app/browse/decisions/page.tsx b/umtool/app/browse/decisions/page.tsx @@ -5,10 +5,10 @@ import { SEVERITIES, countBySeverity, openCount, - openDecisions, type Decision, type Severity, } from "@/lib/decisions"; +import { openDecisions } from "@/lib/projects"; import { fmtAgo } from "@/lib/format"; export const dynamic = "force-dynamic"; diff --git a/umtool/lib/browse.ts b/umtool/lib/browse.ts @@ -3,6 +3,10 @@ import path from "node:path"; 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 { REPORTS_ROOT } from "./paths"; +import type { CutName } from "./project-types"; import { acceptedFor, readThumbAccepted, type ThumbDoc } from "./thumbs"; // --------------------------------------------------------------------------- @@ -29,8 +33,11 @@ export const BROWSE_ROOT = path.join(SONG_REPORTS, "videos"); // mortal-kombat and mario-rpg have no vertical.mp4, and a scan-derived list // would render those songs as complete. A hole in the deliverable set is // information -- it is the thing the judging loop is meant to surface. -export const CUT_NAMES = ["wide", "wide-short", "vertical", "vertical-short"] as const; -export type CutName = (typeof CUT_NAMES)[number]; +// +// The list itself moved to lib/projects/song.mjs so `umtool ls` shares it; this +// re-export is what every existing caller still imports. +export const CUT_NAMES = CUT_NAMES_RAW as readonly CutName[]; +export type { CutName }; export function isCutName(v: string): v is CutName { return (CUT_NAMES as readonly string[]).includes(v); @@ -357,20 +364,31 @@ export async function readSong(id: string): Promise<Song | null> { }; } -/** Every song directory under videos/, sorted. One readdir, no stats. */ +/** + * 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[]> { - let entries; - try { - entries = await readdir(BROWSE_ROOT, { withFileTypes: true }); - } catch { - return []; - } - return entries - .filter((e) => e.isDirectory() && isSegment(e.name)) - .map((e) => e.name) + 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, newest first. Never probes -- the index must not shell out. */ export async function listSongs(): Promise<SongSummary[]> { const ids = await songIds(); diff --git a/umtool/lib/decisions.ts b/umtool/lib/decisions.ts @@ -1,4 +1,4 @@ -import { readSong, resolveRendition, songIds, thumbAliasesFor } from "./browse"; +import { readSong, resolveRendition, thumbAliasesFor } from "./browse"; import { cachedLoudness } from "./loudness"; import { DEFAULT_TARGET, loudnessVerdict } from "./loudness-types"; import { buildStatus, readManifest } from "./manifest"; @@ -27,19 +27,24 @@ import { acceptedFor, readThumbAccepted, readThumbManifest, thumbNamesFor } from // song, a cut or a plan, and that is deliberate. // --------------------------------------------------------------------------- -export type DecisionKind = - | "unjudged-variant" - | "missing-cut" - | "no-recipe" - | "stale-recipe" - | "spec-problem" - | "no-plan" - | "unattributed" - | "thumb-unaccepted" - // Reported from the loudness CACHE only. This reducer must never measure -- - // an inbox that shells out to ffmpeg once per rendition is an inbox that - // takes a minute to open, which is the one thing it cannot afford to be. - | "loudness"; +/** + * A decision kind, as a plain string. + * + * It used to be a closed union of the nine the song reducer emits. It cannot + * stay one: a kind's vocabulary belongs to that kind, and a union here would + * mean every future kind -- report video, supercut, cover set -- editing this + * shared file to say a word only it uses. The real list is assembled from the + * registry as `ALL_DECISION_KINDS` in lib/projects.ts, and an e2e spec asserts + * the ids are unique across kinds. + * + * The song kind's own nine are below, for reference and for the registry entry: + * unjudged-variant, missing-cut, no-recipe, stale-recipe, spec-problem, + * no-plan, unattributed, thumb-unaccepted, and loudness -- which is reported + * from the loudness CACHE only, because this reducer must never measure. An + * inbox that shells out to ffmpeg once per rendition is an inbox that takes a + * minute to open, which is the one thing it cannot afford to be. + */ +export type DecisionKind = string; /** * `blocking` something downstream would LIE if you acted on it -- a spec error @@ -55,7 +60,11 @@ export type Severity = "blocking" | "open" | "info"; export type Decision = { kind: DecisionKind; + /** The project's id -- a POSIX path relative to REPORTS_ROOT. */ project: string; + /** Filled by the dispatcher, so a list can name a project without re-reading it. */ + projectTitle?: string; + projectKind?: string; /** A rel, a cut name, a spec field -- whatever the decision is about. */ target: string; /** One line, already human. */ @@ -276,12 +285,9 @@ export async function decisionsForSong(id: string): Promise<Decision[]> { return sortDecisions(out); } -/** Every open decision, every project, worst first. */ -export async function openDecisions(): Promise<Decision[]> { - const ids = await songIds(); - const per = await Promise.all(ids.map((id) => decisionsForSong(id))); - return sortDecisions(per.flat()); -} +// openDecisions() moved to lib/projects.ts, which is where the enumerator lives +// now: it walks every project of every kind and dispatches to that kind's own +// provider. This file kept the song reducer, which is what it always was. /** The decisions in one song, as the paste already renders everything else. */ export function decisionsMarkdown(items: Decision[]): string[] { diff --git a/umtool/lib/mix.ts b/umtool/lib/mix.ts @@ -1,7 +1,7 @@ import { spawn } from "node:child_process"; import { mkdir } from "node:fs/promises"; import path from "node:path"; -import { MEDIA_ROOTS, SONG_REPORTS, labelFor, resolveInRoots } from "./paths"; +import { SONG_REPORTS, WRITE_ROOTS, labelFor, resolveInRoots } from "./paths"; import { readJson, writeJsonAtomic, withStateLock } from "./state"; import { stateFile } from "./paths"; import { probeMedia } from "./media"; @@ -128,8 +128,13 @@ export async function resolveMix(raw: Partial<MixSpec>): Promise<ResolvedMix> { if (!outName) throw new Error("an output name is required"); if (outName.includes("..")) throw new Error("output name may not contain .."); const outAbs = path.isAbsolute(outName) ? outName : path.join(SONG_REPORTS, outName); - const outPath = resolveInRoots(outAbs); - if (!outPath) throw new Error(`output would land outside ${MEDIA_ROOTS.join(", ")}`); + // The WRITE roots, deliberately narrower than the read roots. Report videos + // became readable so their clips could be mixed; their out/ directories hold + // deliverables that cost an hour of network fetches each, and one typo here + // would overwrite one. Refuse rather than clamp: a refused path must never + // silently become a different path. + const outPath = resolveInRoots(outAbs, "write"); + if (!outPath) throw new Error(`output would land outside ${WRITE_ROOTS.join(", ")}`); if (outPath === bodyPath || (bgPath && outPath === bgPath)) { throw new Error("refusing to write the output over one of its own inputs"); } diff --git a/umtool/lib/paths.mjs b/umtool/lib/paths.mjs @@ -0,0 +1,122 @@ +// Where things live, in plain ESM so the CLI and the app share one definition. +// +// This is the same trick song/paths.mjs plays: the values that decide which +// directory is read and which is written must not be able to differ between +// `umtool ls` and the page it is supposed to describe. lib/paths.ts re-exports +// everything here with types; nothing computes a root twice. +import os from "node:os"; +import path from "node:path"; +import { SONG_DATA } from "../song/paths.mjs"; + +export { SONG_DATA }; + +// Derived output (sliced mp3s, waveform peaks, the project index). Lives with +// the data, not in the repo, and is safe to delete at any time. +export const CACHE_DIR = path.join(SONG_DATA, ".cache", "umtool"); + +// Render scratch: the body render and the cut background sit in the job temp +// dir ABOVE SONG_DATA, not inside it, because render-poly.mjs writes them next +// to its logs. +export const SONG_SCRATCH = path.dirname(SONG_DATA); + +/** The um-song deliverables tree. Also BROWSE_ROOT's parent. */ +export const SONG_REPORTS = path.resolve( + process.env.SONG_REPORTS_DIR ?? path.join(os.homedir(), "reports", "quartering-uh-song"), +); + +// --------------------------------------------------------------------------- +// REPORTS_ROOT -- the tree every PROJECT hangs off. +// +// ~/reports holds an um-song project tree AND six report videos AND a report +// with no video yet. Before this, only the um-song subtree was reachable: six +// of seven projects resolved to null in the mix bench, because SONG_REPORTS was +// the widest root there was. +// +// The e2e default is dirname(SONG_REPORTS_DIR) rather than a new env var, so a +// fixture that sets SONG_REPORTS_DIR=<fixture>/reports gets REPORTS_ROOT= +// <fixture> for free and stays confined. The walk skips `data`, which is where +// that fixture symlinks 39 GB of audio. +// --------------------------------------------------------------------------- +export const REPORTS_ROOT = path.resolve( + process.env.REPORTS_DIR ?? + (process.env.SONG_REPORTS_DIR + ? path.dirname(SONG_REPORTS) + : path.join(os.homedir(), "reports")), +); + +const dedupe = (list) => [...new Set(list.map((p) => path.resolve(p)))]; + +// --------------------------------------------------------------------------- +// READ vs WRITE, and why they are two lists. +// +// resolveInRoots() guards both what may be OPENED and what may be RENDERED TO. +// Those were the same list, which meant widening the read root to reach report +// videos would in the same stroke have made every report's out/ a legal render +// target -- a 46 MB deliverable that cost an hour of fetches, one typo from +// being overwritten by /api/mix/render. +// +// So: reports become readable and mixable, and NOTHING new becomes writable. +// A mix of a report clip still lands in SONG_REPORTS, and a hand-typed path +// outside the write set is refused exactly as it was before. +// +// SONG_REPORTS stays FIRST. It is a subdirectory of REPORTS_ROOT, so whichever +// comes first decides every relative label -- and putting REPORTS_ROOT first +// would silently rewrite every existing `videos/<song>/wide.mp4` label into +// `quartering-uh-song/videos/<song>/wide.mp4`. labelFor and resolveInRoots read +// the same ordered list, which is what keeps a label a round-trip. +// --------------------------------------------------------------------------- +export const READ_ROOTS = dedupe( + process.env.MIX_ROOTS + ? process.env.MIX_ROOTS.split(":").filter(Boolean) + : [SONG_REPORTS, REPORTS_ROOT, SONG_DATA, SONG_SCRATCH], +); + +export const WRITE_ROOTS = dedupe( + process.env.MIX_WRITE_ROOTS + ? process.env.MIX_WRITE_ROOTS.split(":").filter(Boolean) + : [SONG_REPORTS, SONG_SCRATCH], +); + +/** Back-compat alias. Every existing caller means "may this be read". */ +export const MEDIA_ROOTS = READ_ROOTS; + +/** Analysis caches for the mix bench (envelopes, brightness curves). */ +export const MIX_CACHE = path.join(CACHE_DIR, "mix"); + +/** Where the project index lives. Under CACHE_DIR, so a fixture gets its own. */ +export const INDEX_DIR = process.env.UMTOOL_INDEX_DIR + ? path.resolve(process.env.UMTOOL_INDEX_DIR) + : path.join(CACHE_DIR, "index"); + +export const inside = (root, abs) => abs === root || abs.startsWith(root + path.sep); + +const rootsFor = (mode) => (mode === "write" ? WRITE_ROOTS : READ_ROOTS); + +/** + * Resolve a client-supplied media path to an absolute one inside a known root, + * or null. Relative paths are tried against each root in order, so the UI can + * pass the short label it displays. + * + * It does not stat, so a relative path binds to the FIRST root it could live + * under whether or not it is there. That is survivable because every path that + * crosses the wire from a picker or a project link is absolute; a relative one + * is a display label being handed back, and those were produced by labelFor + * against this same ordered list. + */ +export function resolveInRoots(p, mode = "read") { + if (!p) return null; + const roots = rootsFor(mode); + const candidates = path.isAbsolute(p) ? [path.resolve(p)] : roots.map((r) => path.resolve(r, p)); + for (const abs of candidates) { + if (roots.some((r) => inside(r, abs))) return abs; + } + return null; +} + +/** The shortest root-relative label for an absolute path, for display. */ +export function labelFor(abs) { + for (const r of READ_ROOTS) { + if (inside(r, abs)) return path.relative(r, abs) || path.basename(abs); + } + return abs; +} diff --git a/umtool/lib/paths.ts b/umtool/lib/paths.ts @@ -1,11 +1,38 @@ -import os from "node:os"; import path from "node:path"; -// Two roots, and keeping them apart is the whole point of the rescue: +// --------------------------------------------------------------------------- +// The roots, and keeping them apart is the whole point of the rescue: // -// SONG_CODE umtool/song -- the tooling and the small JSON state, in the repo -// SONG_DATA wav48/, media/, cand2/, asr/ -- 39 GB, re-derivable, not in the repo +// SONG_CODE umtool/song -- the tooling and the small JSON state, in the repo +// SONG_DATA wav48/, media/, cand2/, asr/ -- 39 GB, re-derivable, not in the repo +// SONG_REPORTS the um-song deliverables -- quartering-*.mp4 and their .plan.json +// SONG_SCRATCH render scratch, ABOVE SONG_DATA +// REPORTS_ROOT the tree every PROJECT hangs off -- songs AND report videos // +// Everything the bench reads or writes must resolve inside one of these. Not +// because this is exposed -- it is a local tool on a loopback port -- but +// because it RENDERS to a path the client names, and a typo that escapes the +// tree would overwrite something that took an hour to make. +// +// The values themselves live in ./paths.mjs, in plain ESM, so `umtool` on the +// command line and this app can never disagree about which directory is which. +// --------------------------------------------------------------------------- +export { + CACHE_DIR, + INDEX_DIR, + MEDIA_ROOTS, + MIX_CACHE, + READ_ROOTS, + REPORTS_ROOT, + SONG_DATA, + SONG_REPORTS, + SONG_SCRATCH, + WRITE_ROOTS, + inside, + labelFor, + resolveInRoots, +} from "./paths.mjs"; + // Resolved from cwd rather than import.meta.url: Turbopack rewrites module URLs // into .next/server/chunks, so a path derived from one points at the build // output instead of the source tree. `next dev` and `next start` both run with @@ -14,74 +41,9 @@ export const SONG_CODE = process.env.SONG_CODE_DIR ? path.resolve(process.env.SONG_CODE_DIR) : path.join(process.cwd(), "song"); -// song/paths.mjs owns the SONG_DATA default so the CLI scripts and this app can -// never disagree about where the audio is. Same env var, same fallback. -export const SONG_DATA = path.resolve( - process.env.SONG_DIR ?? "/home/user/.claude/jobs/efbe67a7/tmp/song", -); - -// Derived output (sliced mp3s, waveform peaks). Lives with the data, not in the -// repo, and is safe to delete at any time. -export const CACHE_DIR = path.join(SONG_DATA, ".cache", "umtool"); - export const stateFile = (name: string) => path.join(SONG_CODE, name); -export const dataFile = (...parts: string[]) => path.join(SONG_DATA, ...parts); - -// --------------------------------------------------------------------------- -// Where finished and in-progress RENDERS live, for the mix bench. -// -// Three roots, and they are genuinely three different things: -// -// SONG_REPORTS the deliverables -- quartering-*.mp4 and their .plan.json -// SONG_DATA the bulk corpus (wav48/, media/, cand2/) -// SONG_SCRATCH render scratch: the body render and the cut background sit -// in the job temp dir ABOVE SONG_DATA, not inside it, because -// render-poly.mjs writes them next to its logs -// -// Everything the bench reads or writes must resolve inside one of these. Not -// because this is exposed -- it is a local tool on a loopback port -- but -// because it RENDERS to a path the client names, and a typo that escapes the -// tree would overwrite something that took an hour to make. -// --------------------------------------------------------------------------- - -export const SONG_SCRATCH = path.dirname(SONG_DATA); - -export const SONG_REPORTS = path.resolve( - process.env.SONG_REPORTS_DIR ?? path.join(os.homedir(), "reports", "quartering-uh-song"), -); - -export const MEDIA_ROOTS: string[] = [ - ...new Set( - (process.env.MIX_ROOTS - ? process.env.MIX_ROOTS.split(":").filter(Boolean) - : [SONG_REPORTS, SONG_DATA, SONG_SCRATCH] - ).map((p) => path.resolve(p)), - ), -]; - -/** Analysis caches for the mix bench (envelopes, brightness curves). */ -export const MIX_CACHE = path.join(CACHE_DIR, "mix"); - -const inside = (root: string, abs: string) => abs === root || abs.startsWith(root + path.sep); - -/** - * Resolve a client-supplied media path to an absolute one inside a known root, - * or null. Relative paths are tried against each root in order, so the UI can - * pass the short label it displays. - */ -export function resolveInRoots(p: string | null | undefined): string | null { - if (!p) return null; - const candidates = path.isAbsolute(p) ? [path.resolve(p)] : MEDIA_ROOTS.map((r) => path.resolve(r, p)); - for (const abs of candidates) { - if (MEDIA_ROOTS.some((r) => inside(r, abs))) return abs; - } - return null; -} -/** The shortest root-relative label for an absolute path, for display. */ -export function labelFor(abs: string): string { - for (const r of MEDIA_ROOTS) { - if (inside(r, abs)) return path.relative(r, abs) || path.basename(abs); - } - return abs; -} +// dataFile needs SONG_DATA at module scope, which the re-export above does not +// bind locally -- so it is imported again rather than duplicated. +import { SONG_DATA as DATA } from "./paths.mjs"; +export const dataFile = (...parts: string[]) => path.join(DATA, ...parts); diff --git a/umtool/lib/project-types.ts b/umtool/lib/project-types.ts @@ -0,0 +1,118 @@ +// Types only. No `node:` import, ever. +// +// This file exists because of a failure this repo has already had: a value +// import that dragged `node:fs` into a client component passed `tsc --noEmit` +// and then 500'd every page. The registry is exactly that hazard -- a client +// component wants the kind labels for its chips, and the registry also reads +// the disk. So the shapes live here, the reading lives in lib/projects/*.mjs, +// and a component that only needs a label never touches the latter. + +/** A registry id. Deliberately `string`: kinds are data, not a closed union. */ +export type ProjectKindId = string; + +/** + * The song kind's four deliverables. + * + * The VALUE lives in lib/projects/song.mjs, because `umtool ls` needs it from a + * terminal; the TYPE lives here, because a .mjs export infers as string[] and + * every existing caller wants the literal union. One list, two shapes -- which + * is the only place in the registry that duplication is unavoidable. + */ +export type CutName = "wide" | "wide-short" | "vertical" | "vertical-short"; + +export type ProjectStage = { + id: string; + label: string; +}; + +/** + * The shared state vocabulary, across every kind. + * + * One vocabulary rather than per-kind words, because the whole point of the + * filter is to ask "what is half-done" without first asking "half-done at + * what". A kind maps its own situation onto these; it does not invent a sixth. + */ +export type ProjectState = "draft" | "windows" | "fetched" | "built" | "shipped" | "stale"; + +export const PROJECT_STATES: ProjectState[] = [ + "draft", + "windows", + "fetched", + "built", + "shipped", + "stale", +]; + +export const STATE_LABEL: Record<ProjectState, string> = { + draft: "draft", + windows: "windows", + fetched: "fetched", + built: "built", + shipped: "shipped", + stale: "stale", +}; + +/** How a project is reachable, and whether it is reachable at all. */ +export type Routing = "ok" | "shadowed" | "unroutable"; + +export type ProjectRef = { + /** + * The POSIX path relative to REPORTS_ROOT -- `ferret-rescue`, or + * `quartering-uh-song/videos/yoshi`. NOT the basename: bare names collide + * across folders (a second `pokemon` is a matter of time) and the path is + * what makes a link stable. + */ + id: string; + /** Absolute. */ + dir: string; + /** The last segment, for display. */ + name: string; + /** The POSIX path of the containing folder, or "" at the root. */ + folder: string; + kind: ProjectKindId; + template: string; + routing: Routing; + /** Set when two kinds matched. Never resolved by picking one. */ + ambiguousWith?: ProjectKindId[]; +}; + +export type ProjectSummary = ProjectRef & { + title: string; + /** One line under the title. Kind-specific. */ + subtitle: string | null; + state: ProjectState; + /** Newest mtime of anything the summary looked at. Drives `sort=recent`. */ + newestMtimeMs: number; + /** Small facts for the card, already rendered as text. */ + facts: string[]; + /** Loud, short, and only when something is wrong. */ + flags: string[]; + /** Project-relative path of a poster candidate, or null. */ + posterRel: string | null; + /** Everything a `?q=` substring match should see. */ + haystack: string; +}; + +export type FolderNode = { + /** POSIX path relative to REPORTS_ROOT. "" is the root itself. */ + path: string; + /** + * The display label. A pass-through chain (`quartering-uh-song/videos`) is + * collapsed to one label; the URL is never collapsed. + */ + label: string; + /** The segments folded into `label`, oldest first. Display only. */ + collapsedFrom: string[]; + projects: string[]; + children: string[]; +}; + +export type ProjectKindMeta = { + id: ProjectKindId; + template: string; + label: string; + /** Two to six characters. Goes on the card. */ + badge: string; + stages: ProjectStage[]; + decisionKinds: string[]; +}; diff --git a/umtool/lib/projects.ts b/umtool/lib/projects.ts @@ -0,0 +1,267 @@ +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 { decisionsForSong } from "./decisions"; +import type { Decision } from "./decisions"; +import type { + FolderNode, + ProjectKindMeta, + ProjectRef, + ProjectState, + ProjectSummary, +} from "./project-types"; + +export type { FolderNode, ProjectKindMeta, ProjectRef, ProjectState, ProjectSummary }; +export { PROJECT_STATES, STATE_LABEL } from "./project-types"; + +// --------------------------------------------------------------------------- +// The app's view of the registry. +// +// SERVER ONLY. lib/projects/kinds.mjs reaches the disk through its per-kind +// modules, so importing it from a client component drags `node:fs` into the +// browser bundle -- which this repo has already been bitten by once: it passed +// `tsc --noEmit` and then 500'd every page. A client component that wants kind +// labels or state names imports lib/project-types.ts and is handed the rest as +// props. `pnpm build`, not typecheck, is what catches a regression here. +// --------------------------------------------------------------------------- + +export const KINDS: ProjectKindMeta[] = KIND_META(); + +/** + * Which kinds emit which decisions, and the union of all of them. + * + * Assembled rather than hand-written, so a kind's vocabulary lives with the + * 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. + */ +export const ALL_DECISION_KINDS: string[] = [ + ...new Set(KINDS.flatMap((k) => k.decisionKinds)), +].sort(); + +// --------------------------------------------------------------------------- +// Caching. +// +// The WALK is memoised for a second: it is 25 ms on the real tree, and a page +// render asks for it two or three times. +// +// A SUMMARY is memoised against its own kind's signature -- a tuple of mtimes +// and sizes, never the bytes -- so it survives for as long as the project has +// not changed and is thrown away the moment it has. That is the same rule the +// export build's incremental signatures follow, and it is the reason no index +// is needed yet at twelve projects. +// --------------------------------------------------------------------------- +let walkCache: { at: number; refs: ProjectRef[] } | null = null; +const summaryCache = new Map<string, { sig: string; value: ProjectSummary }>(); +const decisionCache = new Map<string, { sig: string; value: Decision[] }>(); + +/** Drop every cache. The CLI's `scan` and the e2e suite want this. */ +export function invalidateProjects(): void { + walkCache = null; + summaryCache.clear(); + decisionCache.clear(); +} + +export async function projectRefs(): Promise<ProjectRef[]> { + if (walkCache && Date.now() - walkCache.at < 1000) return walkCache.refs; + const refs = (await walkProjects(REPORTS_ROOT)) as ProjectRef[]; + walkCache = { at: Date.now(), refs }; + return refs; +} + +export async function projectRef(id: string): Promise<ProjectRef | null> { + return (await projectRefs()).find((p) => p.id === id) ?? null; +} + +type KindSummary = { + title?: string; + subtitle?: string | null; + state?: string; + newestMtimeMs?: number; + facts?: string[]; + flags?: string[]; + posterRel?: string | null; + haystack?: string; +}; + +type Ctx = ProjectRef & { root: string }; +const ctxFor = (p: ProjectRef): Ctx => ({ ...p, root: REPORTS_ROOT }); + +async function signatureOf(p: ProjectRef): Promise<string> { + const k = kindById(p.kind); + if (!k?.signature) return "0"; + try { + return String(await k.signature(p.dir)); + } catch { + return "0"; + } +} + +/** The card for one project. Never probes; never shells out. */ +export async function summariseProject(p: ProjectRef): Promise<ProjectSummary> { + const sig = await signatureOf(p); + const hit = summaryCache.get(p.id); + if (hit && hit.sig === sig) return hit.value; + + const k = kindById(p.kind); + // The boundary between a .mjs summariser and a typed summary. The modules + // return more than the card needs (a song's verdict tally, a report's parsed + // manifest) and their `state` infers as string, so it is narrowed once here + // rather than asserted at every read. + let body: KindSummary = {}; + if (k?.summarise) { + try { + body = await k.summarise(ctxFor(p)); + } catch { + // A project that cannot be read is still a project. Saying so beats + // dropping it from a list whose whole job is to be complete. + body = { flags: ["could not be read"] }; + } + } + + const flags = [...(body.flags ?? [])]; + // Routing failures are kind-independent, so they are added here rather than + // in any kind's summariser. + if (p.routing === "shadowed") flags.push(`/browse/${p.id.split("/")[0]} is a tool page`); + if (p.routing === "unroutable") flags.push("name will not route"); + if (p.ambiguousWith) flags.push(`two kinds match: ${p.ambiguousWith.join(", ")}`); + + const value: ProjectSummary = { + ...p, + title: body.title ?? p.name, + subtitle: body.subtitle ?? null, + state: (body.state as ProjectState) ?? "draft", + newestMtimeMs: body.newestMtimeMs ?? 0, + facts: body.facts ?? [], + flags, + posterRel: body.posterRel ?? null, + haystack: `${body.haystack ?? ""} ${p.id} ${p.kind} ${p.template}`.toLowerCase(), + }; + summaryCache.set(p.id, { sig, value }); + return value; +} + +/** Every project, newest first. */ +export async function listProjects(): Promise<ProjectSummary[]> { + const refs = await projectRefs(); + const out = await Promise.all(refs.map(summariseProject)); + return out.sort((a, b) => b.newestMtimeMs - a.newestMtimeMs || a.id.localeCompare(b.id)); +} + +/** The folder tree, collapsed for display. URLs are never collapsed. */ +export async function listFolders(): Promise<Map<string, FolderNode>> { + return collapseFolders(foldersFor(await projectRefs())) as Map<string, FolderNode>; +} + +// --------------------------------------------------------------------------- +// Decisions. +// +// The dispatch is here rather than in lib/decisions.ts because the song reducer +// IS lib/decisions.ts -- it reads verdicts, plans, recipes, loudness and the +// accepted cover set, all of which live in TypeScript beside readSong(). Naming +// 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); + if (hit && hit.sig === sig) return hit.value; + + 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) + : []; + } else { + const k = kindById(p.kind); + if (k?.decisions) { + const summary = k.summarise ? await k.summarise(ctxFor(p)) : null; + out = (await k.decisions(ctxFor(p), summary)) as Decision[]; + } + } + } catch { + out = []; + } + + 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 }); + return value; +} + +const RANK: Record<string, number> = { blocking: 0, open: 1, info: 2 }; + +/** Every open decision, every project, worst first. */ +export async function openDecisions(): Promise<Decision[]> { + const refs = await projectRefs(); + const per = await Promise.all(refs.map(decisionsForProject)); + return per + .flat() + .sort((a, b) => RANK[a.severity] - RANK[b.severity] || b.at - a.at); +} + +/** blocking + open counts per project id, for the index's chips. */ +export async function decisionCounts(): Promise<Map<string, { blocking: number; open: number }>> { + const refs = await projectRefs(); + const out = new Map<string, { blocking: number; open: number }>(); + await Promise.all( + refs.map(async (p) => { + const ds = await decisionsForProject(p); + out.set(p.id, { + blocking: ds.filter((d) => d.severity === "blocking").length, + open: ds.filter((d) => d.severity === "open").length, + }); + }), + ); + return out; +} + +// --------------------------------------------------------------------------- +// Routing. +// --------------------------------------------------------------------------- + +export type Resolved = + | { project: ProjectRef; rest: string[]; folder: null } + | { project: null; rest: []; folder: FolderNode } + | null; + +/** + * Resolve a `/browse/...` path to the LONGEST prefix that is a project, and + * hand the remaining segments to that kind's view. + * + * Longest-prefix rather than "first project found" because a project id is a + * path, and `a/b` being a project must not stop `a/b/c` from being one too. + */ +export async function resolveProjectPath(segments: string[]): Promise<Resolved> { + const refs = await projectRefs(); + const id = segments.join("/"); + + let best: ProjectRef | null = null; + for (const p of refs) { + if (p.routing !== "ok") continue; + if (id === p.id || id.startsWith(`${p.id}/`)) { + if (!best || p.id.length > best.id.length) best = p; + } + } + if (best) { + const rest = id === best.id ? [] : id.slice(best.id.length + 1).split("/"); + return { project: best, rest, folder: null }; + } + + const folders = await listFolders(); + const folder = folders.get(id); + if (folder) return { project: null, rest: [], folder }; + return null; +} + +/** Kind metadata by id, for a page that has a summary and wants its label. */ +export const kindMetaOf = (id: string): ProjectKindMeta | null => + KINDS.find((k) => k.id === id) ?? null; + +export { PROJECT_KINDS }; diff --git a/umtool/lib/projects/kinds.mjs b/umtool/lib/projects/kinds.mjs @@ -0,0 +1,191 @@ +// The project registry. +// +// A KIND is what a thing is; a TEMPLATE is which configuration of that kind it +// is. `um-song` is not a kind -- it is the one template of the `song` kind that +// exists so far, and the distinction is the whole point: the next thing this +// tool has to hold (a supercut, a cover set, a vertical short) will be a new +// template of an existing kind at least as often as a new kind. +// +// Plain ESM and NO `node:fs`, so `umtool` on the command line, a server +// component and a client component that only wants the chip labels all read one +// definition. The reading each kind does lives in its own module. +// +// TO ADD A KIND: add an entry here and a view. Nothing else. There is an e2e +// spec that fails if a kind id appears as a string literal anywhere outside +// lib/projects/ and components/projects/ -- that is the mechanical form of this +// rule, and it is what stops `if (kind === "report-video")` appearing in a page. +import { MANIFEST_NAME, REPORT_DECISION_KINDS, reportDecisions, reportSignature, summariseReport } from "./report.mjs"; +import { CUT_NAMES, songSignature, summariseSong } from "./song.mjs"; +import { sweepSignature, summariseSweep } from "./sweep.mjs"; + +/** + * Static children of app/browse/. A project whose FIRST path segment is one of + * these can never be opened at its own URL -- a static segment beats a dynamic + * one, so `/browse/find` renders the phrase console no matter what is on disk. + * + * Before this it failed SILENTLY, which is the worst available outcome: the + * project is listed, the link is there, and it goes somewhere else entirely. + * e2e/projects.spec.ts asserts this list equals the real directory listing, so + * a tenth tool page cannot quietly invalidate it. + */ +export const RESERVED_BROWSE = ["at", "decisions", "faces", "find", "sources", "trim"]; + +/** Directory names the walk never descends into. */ +export const SKIP_DIRS = new Set([ + "node_modules", + // A project is a LEAF, so these are only ever reached inside one -- but a + // half-built tree can have them at folder level too, and none of them can + // contain a project. + "out", + "variants", + "plan", + "thumbs", + // The e2e fixture symlinks 39 GB of audio here. + "data", +]); + +const has = (names, n) => names.has(n); +const someMatch = (names, re) => [...names].some((n) => re.test(n)); + +const SWEEP_RE = /(^|[-_])sweep([-_]report)?\.md$|^sweep-report\.md$/i; + +export const PROJECT_KINDS = [ + { + id: "report-video", + template: "cited-timeline", + label: "report video", + badge: "video", + // The manifest is both the marker and the edit decision list. Nothing else + // needs to exist for a directory to be a report video. + detect: (names) => has(names, MANIFEST_NAME), + stages: [ + { id: "windows", label: "windows" }, + { id: "fetched", label: "clips fetched" }, + { id: "built", label: "built" }, + ], + decisionKinds: REPORT_DECISION_KINDS, + summarise: summariseReport, + signature: reportSignature, + decisions: reportDecisions, + // Views reachable under the project's own URL: /browse/<project>/clip/<id>. + views: ["clip"], + }, + { + id: "song", + template: "um-song", + label: "song", + badge: "song", + detect: (names) => + has(names, "spec.json") || + has(names, "verdicts.json") || + CUT_NAMES.some((c) => has(names, `${c}.mp4`)), + stages: [ + { id: "draft", label: "no cuts" }, + { id: "built", label: "some cuts" }, + { id: "shipped", label: "all cuts" }, + ], + // Assembled from lib/decisions.ts, which still owns the song reducer -- it + // reads verdicts, plans, recipes, loudness and the accepted cover set, all + // of which live in TypeScript beside readSong(). Listed here so the union + // of every kind's vocabulary is computable without importing any of it. + decisionKinds: [ + "unjudged-variant", "missing-cut", "no-recipe", "stale-recipe", + "spec-problem", "no-plan", "unattributed", "thumb-unaccepted", "loudness", + ], + summarise: summariseSong, + signature: songSignature, + // Wired in lib/projects.ts, because the reducer is TypeScript. + decisions: null, + // `/browse/<song>/wide` -- today's cut page, unchanged. + views: CUT_NAMES, + }, + { + id: "sweep-report", + template: "sweep", + label: "sweep report", + badge: "report", + // A report that is not yet a video. It earns a kind on day one because one + // exists (~/reports/hasan-bike), and because a kind with no decisions, no + // build and no rich read is the cheapest possible proof that the registry + // is extensible. + detect: (names) => !has(names, MANIFEST_NAME) && someMatch(names, SWEEP_RE), + stages: [{ id: "draft", label: "no manifest" }], + decisionKinds: [], + summarise: summariseSweep, + signature: sweepSignature, + decisions: null, + views: [], + }, +]; + +// An escape hatch for the extensibility spec: it injects a fourth kind and +// asserts the index, the chips, the CLI and the project page all pick it up +// with no code edit anywhere. If adding a kind needs an edit outside this file, +// that spec fails loudly. +if (process.env.UMTOOL_EXTRA_KINDS) { + try { + for (const k of JSON.parse(process.env.UMTOOL_EXTRA_KINDS)) { + PROJECT_KINDS.push({ + stages: [{ id: "draft", label: "draft" }], + decisionKinds: [], + summarise: null, + signature: null, + decisions: null, + views: [], + ...k, + detect: (names) => has(names, k.marker), + }); + } + } catch { + /* a malformed override must not take the app down */ + } +} + +export const kindById = (id) => PROJECT_KINDS.find((k) => k.id === id) ?? null; + +/** What a client component needs, with none of what it must not have. */ +export const kindMeta = (k) => ({ + id: k.id, + template: k.template, + label: k.label, + badge: k.badge, + stages: k.stages, + decisionKinds: k.decisionKinds, +}); + +export const KIND_META = () => PROJECT_KINDS.map(kindMeta); + +/** Every decision kind any registered kind can emit. */ +export const ALL_DECISION_KINDS = () => [ + ...new Set(PROJECT_KINDS.flatMap((k) => k.decisionKinds)), +]; + +/** + * Which kind a directory is, from its entry names alone. + * + * `project.json` wins outright, so a directory can always declare itself and + * nothing on disk has to move to adopt the registry. Otherwise every kind's + * detect() runs and TWO matches is an error, never a guess -- a directory that + * is two kinds is a bug, and picking one would hide it. + */ +export function detectKind(names, declared = null) { + if (declared?.kind) { + const k = kindById(declared.kind); + if (k) return { kind: k.id, template: declared.template ?? k.template, declared: true }; + // A declared kind nobody registers is still a statement of intent; keep it + // so the index can say so rather than silently falling back to a guess. + return { kind: declared.kind, template: declared.template ?? "unknown", unknownKind: true }; + } + const hits = PROJECT_KINDS.filter((k) => { + try { + return k.detect(names); + } catch { + return false; + } + }); + if (!hits.length) return null; + if (hits.length > 1) { + return { kind: hits[0].id, template: hits[0].template, ambiguousWith: hits.map((k) => k.id) }; + } + return { kind: hits[0].id, template: hits[0].template }; +} diff --git a/umtool/lib/projects/report.mjs b/umtool/lib/projects/report.mjs @@ -0,0 +1,473 @@ +// The report-video kind's own reading: the manifest, what is wrong with it, and +// what state the build is in. +// +// Plain ESM, no TypeScript and no app imports, because `umtool check` has to run +// this from a terminal with no server. It is also the only place that knows the +// manifest's shape -- the walk knows a marker file, the page knows a summary, +// and neither parses JSON. +import { readdir, readFile, stat } from "node:fs/promises"; +import path from "node:path"; + +export const MANIFEST_NAME = "video.manifest.json"; + +const GLOBAL_CHANNELS_DIR = () => + process.env.CHANNELS_DIR ?? + "/home/user/Projects/yt-dlp-transcript-browser/transcripts/channels"; + +/** The conventional name make-shadow-channels.sh builds. */ +export const SHADOW_CHANNELS = ".shadow-channels"; + +// --------------------------------------------------------------------------- +// Which channels directory THIS project reads. +// +// Not always the global one, and assuming it was produced three confident, +// wrong "the build dies here" findings on the first run against real data. +// quartering-flagging-takedowns cites three deleted YouTube uploads and cuts +// them from live Rumble mirrors the corpus has no entry for; rather than write +// into transcripts/channels/ (which would perturb the export build's change +// detection) it builds a SHADOW tree that symlinks every real channel and adds +// just those three. Its documented build command sets CHANNELS_DIR to it. +// +// So the project gets to say. `provenance.channelsDir` is the explicit form and +// is resolved relative to the project; `.shadow-channels/` is the convention, +// detected because it already exists and nothing had to change on disk for it +// to work. Neither is a guess: both are things the project itself wrote down. +// --------------------------------------------------------------------------- +export function channelsDirFor(dir, manifest, { shadowExists = false } = {}) { + const declared = manifest?.provenance?.channelsDir; + if (declared) return path.resolve(dir, declared); + if (shadowExists) return path.join(dir, SHADOW_CHANNELS); + return GLOBAL_CHANNELS_DIR(); +} + +export const hasShadowChannels = (dir) => + stat(path.join(dir, SHADOW_CHANNELS)).then((s) => s.isDirectory(), () => false); + +/** The same regex resolve-windows.mjs uses. Imported there, duplicated nowhere. */ +export const ENDS_SENTENCE = /[.!?]["'”’)\]]*\s*$/; +export const IS_FILLER = /^\s*(\[[^\]]*\]|>>|♪|—|-)*\s*$/; + +// A QR built from any of these resolves to nothing on somebody else's phone. +// Both of the real defects this catches had already shipped: one manifest has no +// siteOrigin at all (19 codes reading `undefined/?v=…`) and one has localhost. +const DEAD_ORIGIN = + /^https?:\/\/(localhost|127\.|0\.0\.0\.0|\[::1\]|10\.|192\.168\.|172\.(1[6-9]|2\d|3[01])\.)/i; + +export const isDeadOrigin = (o) => !o || typeof o !== "string" || DEAD_ORIGIN.test(o); + +const stat0 = (p) => stat(p).then((s) => s, () => null); + +export const manifestPath = (dir) => path.join(dir, MANIFEST_NAME); + +export async function readManifest(dir) { + try { + return JSON.parse(await readFile(manifestPath(dir), "utf8")); + } catch { + return null; + } +} + +/** Clips only, in timeline order. Array order IS the cut; nothing sorts. */ +export const clipsOf = (m) => (m?.timeline ?? []).filter((e) => e?.type === "clip"); +export const cardsOf = (m) => (m?.timeline ?? []).filter((e) => e?.type === "card"); + +export const channelFor = (m, e) => e.channel ?? m?.provenance?.channelSlug ?? null; + +export const cuePathFor = (m, e, channelsDir) => { + const chan = channelFor(m, e); + if (!chan) return null; + return path.join(channelsDir ?? GLOBAL_CHANNELS_DIR(), chan, "data", e.video, "transcript.cues.json"); +}; + +// --------------------------------------------------------------------------- +// Cue files are the expensive input: a nine-hour stream's cues run to megabytes, +// and six reports citing thirteen videos each would be a hundred-odd megabytes of +// JSON on every index load. So the DERIVED answers are memoised against the +// file's own mtime and size -- a cue file is an archive artefact and does not +// change under us, so a hit is permanent in practice. +// --------------------------------------------------------------------------- +const cueMemo = new Map(); + +export async function readCues(file) { + const st = await stat0(file); + if (!st) return null; + const key = `${file}|${Math.round(st.mtimeMs)}|${st.size}`; + const hit = cueMemo.get(key); + if (hit) return hit; + let doc; + try { + doc = JSON.parse(await readFile(file, "utf8")); + } catch { + return null; + } + const cues = doc.cues ?? []; + const value = { + cues, + title: doc.title, + uploadDate: doc.uploadDate, + webpageUrl: doc.webpageUrl, + duration: doc.duration, + // What fraction of cues close a sentence. Below ~10% the upload's ASR + // carries no punctuation worth the name, and sentence-widening cannot help + // -- which is a thing to SAY, not a thing to let somebody rediscover. + punctuationRate: + cues.length === 0 + ? 0 + : cues.filter((c) => ENDS_SENTENCE.test(c.text ?? "")).length / cues.length, + }; + cueMemo.set(key, value); + return value; +} + +/** The cue whose span contains t, else the nearest on the right. */ +export function cueAt(cues, t, which = "start") { + const EPS = 0.02; + if (!cues.length) return null; + if (which === "end") { + const j = cues.findIndex((c) => c.end >= t - EPS); + return cues[j < 0 ? cues.length - 1 : j]; + } + const i = cues.findIndex((c) => c.end > t); + return cues[i < 0 ? cues.length - 1 : i]; +} + +// --------------------------------------------------------------------------- +// The build's own state, read from the output directory. +// +// Deliberately NOT a readdir of out/ -- it holds 43 to 63 files per project and +// the index would pay for all of them. One stat for the deliverable, one readdir +// of clips-raw (a few dozen names) to tell "windows written" from "clips +// fetched", and nothing else. +// --------------------------------------------------------------------------- +export async function buildStateOf(dir, manifest) { + const outDir = path.join(dir, "out"); + const slug = manifest?.slug ?? path.basename(dir); + const finalPath = path.join(outDir, `${slug}.mp4`); + const [fin, man] = await Promise.all([stat0(finalPath), stat0(manifestPath(dir))]); + + let rawCount = 0; + let segCount = 0; + 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 stale = !!(fin && man && fin.mtimeMs < man.mtimeMs); + return { + outDir, + slug, + finalPath, + built: !!fin, + stale, + finalSize: fin?.size ?? 0, + finalMtimeMs: fin?.mtimeMs ?? 0, + manifestMtimeMs: man?.mtimeMs ?? 0, + rawCount, + segCount, + }; +} + +/** The signature the summary and the decisions are cached against. */ +export async function reportSignature(dir) { + const [man, out] = await Promise.all([ + stat0(manifestPath(dir)), + stat0(path.join(dir, "out")), + ]); + return [ + Math.round(man?.mtimeMs ?? 0), + man?.size ?? 0, + Math.round(out?.mtimeMs ?? 0), + ].join(":"); +} + +const fmtDur = (s) => { + const t = Math.round(s); + const m = Math.floor(t / 60); + return m >= 60 + ? `${Math.floor(m / 60)}h${String(m % 60).padStart(2, "0")}m` + : `${m}m${String(t % 60).padStart(2, "0")}s`; +}; + +/** + * The card. Cheap by construction: the manifest, one stat, one readdir. + * + * Runtime is SUMMED FROM THE WINDOWS, not probed. It is the right number anyway + * -- it is what the cut will be if it is built -- and a probe per project would + * put a second in front of the index. + */ +export async function summariseReport(ctx) { + const { dir } = ctx; + const m = await readManifest(dir); + const clips = clipsOf(m); + const cards = cardsOf(m); + const build = await buildStateOf(dir, m); + + const runtime = clips.reduce((n, e) => n + Math.max(0, (e.end ?? 0) - (e.start ?? 0)), 0); + const sources = new Set(clips.map((e) => `${channelFor(m, e)}/${e.video}`)).size; + const locked = clips.filter((e) => e.lock).length; + + const state = !m + ? "draft" + : build.stale + ? "stale" + : build.built + ? "built" + : build.segCount > 0 || build.rawCount > 0 + ? "fetched" + : clips.length + ? "windows" + : "draft"; + + const facts = []; + if (clips.length) facts.push(`${clips.length} clip${clips.length === 1 ? "" : "s"}`); + if (cards.length) facts.push(`${cards.length} card${cards.length === 1 ? "" : "s"}`); + if (runtime > 0) facts.push(fmtDur(runtime)); + if (sources) facts.push(`${sources} source${sources === 1 ? "" : "s"}`); + if (locked) facts.push(`${locked} locked`); + if (build.built) facts.push(`${(build.finalSize / 1e6).toFixed(0)} MB`); + + const flags = []; + if (isDeadOrigin(m?.provenance?.siteOrigin)) flags.push("dead QR origin"); + if (!m?.provenance?.channelSlug) flags.push("no channelSlug"); + if (build.stale) flags.push("older than its manifest"); + + return { + title: m?.title ?? ctx.name, + subtitle: m?.subtitle ?? m?.generatedOn ?? null, + state, + newestMtimeMs: Math.max(build.manifestMtimeMs, build.finalMtimeMs), + facts, + flags, + posterRel: build.built ? path.posix.join("out", `${build.slug}.mp4`) : null, + haystack: [ + ctx.id, m?.title, m?.subtitle, m?.slug, + m?.provenance?.channel, m?.provenance?.channelSlug, + ...clips.map((e) => e.video), + ] + .filter(Boolean) + .join(" ") + .toLowerCase(), + // Kept for the project page and the decisions pass, so neither re-reads. + manifest: m, + build, + }; +} + +// --------------------------------------------------------------------------- +// Decisions. +// +// Severity is EARNED. `blocking` means something downstream would LIE or die if +// you acted on it: a QR that resolves nowhere, a clip whose cue file is absent +// (the build dies there), a source that is gone. Everything else is `open` at +// most, and a manifest with no build yet is `info` -- that is a normal state to +// be in, not a decision anybody is waiting on. +// --------------------------------------------------------------------------- +export const REPORT_DECISION_KINDS = [ + "manifest-invalid", + "clip-no-cues", + "clip-unfetchable", + "clip-mid-sentence", + "window-overlap", + "no-punctuation", + "stale-build", + "unbuilt", +]; + +export async function reportDecisions(ctx, summary) { + const { id, dir } = ctx; + const s = summary ?? (await summariseReport(ctx)); + const m = s.manifest; + const href = `/browse/${id}`; + const clipHref = (cid) => `/browse/${id}/clip/${cid}`; + const at = s.newestMtimeMs; + const out = []; + if (!m) return out; + + const add = (kind, target, why, severity, extra = {}) => + out.push({ kind, project: id, target, why, href, severity, at, ...extra }); + + const shadowExists = await hasShadowChannels(dir); + const channelsDir = channelsDirFor(dir, m, { shadowExists }); + // A project that ships a builder for its shadow tree but has not run it is a + // DIFFERENT problem from one whose sources are gone, and the fix is one + // command rather than an editorial decision. + const shadowBuilder = + !shadowExists && + (await stat(path.join(dir, "make-shadow-channels.sh")).then(() => true, () => false)); + + // --- the manifest itself ------------------------------------------------- + const origin = m.provenance?.siteOrigin; + if (isDeadOrigin(origin)) { + add( + "manifest-invalid", + "provenance.siteOrigin", + origin + ? `\`${origin}\` — every QR in this cut resolves to nothing on anyone else's phone` + : "missing — every QR in this cut encodes `undefined/?v=…`", + "blocking", + ); + } + if (!m.provenance?.channelSlug) { + add( + "manifest-invalid", + "provenance.channelSlug", + "missing — a clip with no `channel` of its own has no cue file to find", + "blocking", + ); + } + + const clips = clipsOf(m); + const seen = new Map(); + for (const e of m.timeline ?? []) { + if (!e?.id) continue; + seen.set(e.id, (seen.get(e.id) ?? 0) + 1); + } + for (const [eid, n] of seen) { + if (n > 1) { + add( + "manifest-invalid", + eid, + `${n} entries share the id \`${eid}\` — segments overwrite each other`, + "blocking", + ); + } + } + + const nodeCount = (m.timelineNodes ?? []).length; + if (nodeCount > 0) { + for (const e of clips) { + if (typeof e.section === "number" && (e.section < 0 || e.section >= nodeCount)) { + add( + "manifest-invalid", + e.id, + `section ${e.section} but only ${nodeCount} timeline node(s) — the footer marker would run off the track`, + "blocking", + { href: clipHref(e.id) }, + ); + } + } + } + + // --- availability, from the recorded check (never a live one) ------------ + // The reducer must not shell out: an inbox that runs yt-dlp once per source is + // an inbox that takes a minute to open. So it reads what the preflight wrote, + // and says when nobody has run one. + const avail = await readFile(path.join(dir, "out", "availability.json"), "utf8").then( + (t) => JSON.parse(t), + () => null, + ); + if (avail) { + for (const src of avail.sources ?? []) { + if (src.ok) continue; + add( + src.state === "no-cues" ? "clip-no-cues" : "clip-unfetchable", + src.key, + `${src.state} — ${src.clips?.length ?? 0} clip(s) cite it; the build dies here`, + "blocking", + ); + } + } + + // --- per clip, against the cues ----------------------------------------- + const byVideo = new Map(); + for (const e of clips) { + const key = `${channelFor(m, e)}/${e.video}`; + if (!byVideo.has(key)) byVideo.set(key, []); + byVideo.get(key).push(e); + } + + const unpunctuated = []; + for (const [key, list] of byVideo) { + const file = cuePathFor(m, list[0], channelsDir); + const doc = file ? await readCues(file) : null; + + if (!doc) { + // Reported once per source, not once per clip. Almost always the Rumble + // two-ids trap: the manifest names the site/MCP id while the cue file + // lives under the URL slug. + if (!avail?.sources?.some((s) => s.key === key && !s.ok)) { + add( + "clip-no-cues", + key, + shadowBuilder + ? "no transcript.cues.json — but this project ships make-shadow-channels.sh, " + + `which is what puts it there. Run it before building ${list.map((e) => e.id).join(", ")}` + : `no transcript.cues.json — the build dies at ${list.map((e) => e.id).join(", ")}. ` + + "On Rumble, check `video` is the URL slug and not the site id", + "blocking", + ); + } + continue; + } + + if (doc.punctuationRate < 0.1) unpunctuated.push(key); + + for (const e of list) { + // The standing rule, mechanised. One 14-clip cut shipped with 8 clips + // ending mid-thought. `lockEnd` is the ACKNOWLEDGEMENT -- setting it is + // the author saying "I meant to cut here" -- so it clears this. + if (!e.lockEnd && !e.lock && doc.punctuationRate >= 0.1) { + const c = cueAt(doc.cues, e.end, "end"); + if (c && !ENDS_SENTENCE.test(c.text ?? "") && !IS_FILLER.test(c.text ?? "")) { + add( + "clip-mid-sentence", + e.id, + `ends mid-sentence: “…${String(c.text ?? "").trim().slice(-48)}”`, + "open", + { href: clipHref(e.id) }, + ); + } + } + } + + // Two clips from one source that overlap play as the same footage twice. + // resolve-windows de-overlaps them -- unless the earlier one has lockEnd, + // where it warns and refuses. That refusal is the decision. + const sorted = [...list].sort((a, b) => a.start - b.start); + for (let i = 0; i < sorted.length - 1; i += 1) { + const a = sorted[i]; + const b = sorted[i + 1]; + if (a.end <= b.start) continue; + add( + "window-overlap", + a.id, + `overlaps ${b.id} by ${(a.end - b.start).toFixed(1)}s` + + (a.lockEnd ? " and has lockEnd, so nothing will trim it" : ""), + a.lockEnd ? "open" : "info", + { href: clipHref(a.id) }, + ); + } + } + + // One row, not one per source. Six real projects produce forty-odd of these + // between them, and an inbox that says the same true thing forty times is an + // inbox whose blocking rows have scrolled off the top. + if (unpunctuated.length) { + add( + "no-punctuation", + unpunctuated.length === 1 ? unpunctuated[0] : `${unpunctuated.length} sources`, + `unpunctuated ASR in ${unpunctuated.slice(0, 3).join(", ")}` + + `${unpunctuated.length > 3 ? ` and ${unpunctuated.length - 3} more` : ""}` + + " — widening cannot help there; set those edges by ear and lock them", + "info", + ); + } + + // --- the build ----------------------------------------------------------- + if (s.build.stale) { + add( + "stale-build", + `out/${s.build.slug}.mp4`, + "older than the manifest that describes it — the file is a lead, not a fact", + "open", + ); + } else if (!s.build.built) { + add("unbuilt", `out/${s.build.slug}.mp4`, "never built", "info"); + } + + return out; +} diff --git a/umtool/lib/projects/song.mjs b/umtool/lib/projects/song.mjs @@ -0,0 +1,86 @@ +// The song kind, read cheaply. +// +// The RICH read is lib/browse.ts's readSong(): verdict tallies against thumb +// aliases, plan provenance, spec validation, the accepted-cover set. That stays +// where it is and the project page still uses it. This is the index's version -- +// two readdirs and one small JSON -- because `umtool ls` has to work from a +// 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"; + +// 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 +// songs as complete. A hole in the deliverable set is information. +// +// Defined here rather than in lib/browse.ts so the CLI and the app share one +// list; lib/browse.ts re-exports it. +export const CUT_NAMES = ["wide", "wide-short", "vertical", "vertical-short"]; + +const MEDIA_RE = /\.(mp4|mkv|webm|mov|m4v)$/i; + +const stat0 = (p) => stat(p).then((s) => s, () => null); + +const listMediaIn = (dir) => + readdir(dir, { withFileTypes: true }).then( + (es) => es.filter((e) => e.isFile() && MEDIA_RE.test(e.name) && !e.name.startsWith(".")).map((e) => e.name), + () => [], + ); + +export async function songSignature(dir) { + const [d, v] = await Promise.all([stat0(dir), stat0(path.join(dir, "verdicts.json"))]); + return [Math.round(d?.mtimeMs ?? 0), Math.round(v?.mtimeMs ?? 0), v?.size ?? 0].join(":"); +} + +export async function summariseSong(ctx) { + const { dir, name } = ctx; + const [top, variants, readme, verdicts] = await Promise.all([ + listMediaIn(dir), + listMediaIn(path.join(dir, "variants")), + readFile(path.join(dir, "README.md"), "utf8").catch(() => null), + readFile(path.join(dir, "verdicts.json"), "utf8").then( + (t) => JSON.parse(t), + () => ({}), + ), + ]); + + const bases = new Set(top.map((n) => n.replace(MEDIA_RE, ""))); + const present = CUT_NAMES.filter((c) => bases.has(c)); + const missing = CUT_NAMES.filter((c) => !bases.has(c)); + + const counts = { keep: 0, reject: 0, undecided: 0 }; + const all = [...top, ...variants.map((n) => path.posix.join("variants", n))]; + for (const rel of all) { + const v = verdicts[rel]?.verdict; + counts[v === "keep" || v === "reject" ? v : "undecided"] += 1; + } + + let newest = 0; + for (const rel of all) { + const st = await stat0(path.join(dir, rel)); + if (st) newest = Math.max(newest, st.mtimeMs); + } + + const title = readme?.match(/^#\s+(.+)$/m)?.[1]?.trim() ?? name; + const state = present.length === 0 ? "draft" : present.length === CUT_NAMES.length ? "shipped" : "built"; + + const facts = [ + `${present.length}/${CUT_NAMES.length} cuts`, + `${variants.length} variant${variants.length === 1 ? "" : "s"}`, + ]; + if (counts.undecided) facts.push(`${counts.undecided} undecided`); + + return { + title, + subtitle: missing.length ? `no ${missing.join(", ")}` : null, + state, + newestMtimeMs: newest, + facts, + flags: [], + posterRel: present.length ? `${present[0]}.mp4` : null, + haystack: [ctx.id, title, ...bases].join(" ").toLowerCase(), + counts, + present, + missing, + variantCount: variants.length, + }; +} diff --git a/umtool/lib/projects/sweep.mjs b/umtool/lib/projects/sweep.mjs @@ -0,0 +1,49 @@ +// The sweep-report kind: a cited report that is not yet a video. +// +// It has no decisions, no build and no rich read, which is exactly why it is +// here on day one: it is the cheapest possible proof that adding a kind costs a +// registry entry and nothing else. +import { readdir, readFile, stat } from "node:fs/promises"; +import path from "node:path"; + +const stat0 = (p) => stat(p).then((s) => s, () => null); + +const SWEEP_RE = /(^|[-_])sweep([-_]report)?\.md$|^sweep-report\.md$/i; + +export async function sweepSignature(dir) { + const st = await stat0(dir); + return String(Math.round(st?.mtimeMs ?? 0)); +} + +export async function summariseSweep(ctx) { + const { dir, name } = ctx; + const names = await readdir(dir).catch(() => []); + const reportName = names.find((n) => SWEEP_RE.test(n)); + const file = reportName ? path.join(dir, reportName) : null; + const [text, st] = await Promise.all([ + file ? readFile(file, "utf8").catch(() => null) : null, + file ? stat0(file) : null, + ]); + + // A citation in these reports is a markdown link carrying a `?v=` moment. + // Counting them is the one number worth putting on the card: it is how much + // work a manifest would be. + const citations = text ? (text.match(/\]\([^)]*[?&]v=/g) ?? []).length : 0; + const title = text?.match(/^#\s+(.+)$/m)?.[1]?.trim() ?? name; + + const facts = []; + if (citations) facts.push(`${citations} citation${citations === 1 ? "" : "s"}`); + facts.push("no manifest"); + + return { + title, + subtitle: "a report, not yet a video", + state: "draft", + newestMtimeMs: st?.mtimeMs ?? 0, + facts, + flags: [], + posterRel: null, + haystack: [ctx.id, title, reportName].filter(Boolean).join(" ").toLowerCase(), + reportName, + }; +} diff --git a/umtool/lib/projects/walk.mjs b/umtool/lib/projects/walk.mjs @@ -0,0 +1,195 @@ +// The folder walk. +// +// Two rules do almost all the work here: +// +// A PROJECT IS A LEAF. Detection stops the descent, which is what keeps out/ +// (1,210 files, 3.1 GB across ~/reports) out of the walk entirely. Nothing +// here ever sees a clip, a segment, a card PNG or a variant. +// +// A FOLDER WITH NO PROJECT BENEATH IT DOES NOT EXIST. That is what silently +// drops ~40 loose test directories under quartering-uh-song -- alarm-tests, +// chop-tests, run-visual-tests, sfx, pipeline -- with no denylist to maintain +// and nothing to update when the 41st appears. +// +// Measured cost on the real tree: ~25 readdirs, no stats of anything inside a +// project. The 3.1 GB is never touched. +import { readdir, readFile, realpath, stat } from "node:fs/promises"; +import path from "node:path"; +import { RESERVED_BROWSE, SKIP_DIRS, detectKind } from "./kinds.mjs"; + +/** A single safe path segment: no separators, no traversal, no dotfiles. */ +export const isSegment = (v) => /^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(v) && !v.includes(".."); + +/** + * Four levels from REPORTS_ROOT. `quartering-uh-song/videos/yoshi` is two, so + * this is two levels of headroom and a cheap guard against an accident -- a + * symlink into a home directory, say -- turning the index into a filesystem + * crawl. + */ +export const MAX_DEPTH = 4; + +/** + * How a project is reachable. + * + * Both failures were SILENT before. A directory whose name isSegment() dislikes + * (a space is enough) simply vanished from the listing; a directory called + * `find` was listed with a link that rendered the phrase console instead. Each + * is now a state the project carries, an entry in the index and a decision -- + * never a disappearance. + */ +export function routingFor(id) { + const segs = id.split("/"); + if (!segs.every(isSegment)) return "unroutable"; + if (RESERVED_BROWSE.includes(segs[0])) return "shadowed"; + return "ok"; +} + +async function readDeclared(dir, names) { + if (!names.has("project.json")) return null; + try { + return JSON.parse(await readFile(path.join(dir, "project.json"), "utf8")); + } catch { + return null; + } +} + +/** + * Every project under `root`, and the folders that contain them. + * + * Symlinked directories ARE followed -- a project symlinked into the tree is a + * reasonable thing to do -- but every real path is visited once, so a link that + * points at an ancestor terminates instead of spinning. + */ +export async function walkProjects(root, { maxDepth = MAX_DEPTH } = {}) { + const projects = []; + const visited = new Set(); + + const visit = async (abs, rel, depth) => { + let real; + try { + real = await realpath(abs); + } catch { + return; + } + if (visited.has(real)) return; + visited.add(real); + + let entries; + try { + entries = await readdir(abs, { withFileTypes: true }); + } catch { + return; + } + + const names = new Set(entries.map((e) => e.name)); + const hit = detectKind(names, await readDeclared(abs, names)); + if (hit) { + const id = rel; + projects.push({ + id, + dir: abs, + name: path.basename(abs), + folder: path.posix.dirname(id) === "." ? "" : path.posix.dirname(id), + kind: hit.kind, + template: hit.template, + routing: routingFor(id), + ...(hit.ambiguousWith ? { ambiguousWith: hit.ambiguousWith } : {}), + ...(hit.unknownKind ? { unknownKind: true } : {}), + }); + return; // a project is a leaf + } + + if (depth >= maxDepth) return; + + for (const e of entries) { + if (e.name.startsWith(".")) continue; + if (SKIP_DIRS.has(e.name)) continue; + let isDir = e.isDirectory(); + if (!isDir && e.isSymbolicLink()) { + isDir = await stat(path.join(abs, e.name)).then((s) => s.isDirectory(), () => false); + } + if (!isDir) continue; + await visit(path.join(abs, e.name), rel ? `${rel}/${e.name}` : e.name, depth + 1); + } + }; + + await visit(root, "", 0); + projects.sort((a, b) => a.id.localeCompare(b.id)); + return projects; +} + +// --------------------------------------------------------------------------- +// Folders, derived from the project paths rather than recorded during the walk. +// +// Deriving them is what makes "a folder with no project beneath it is invisible" +// true by construction rather than by a filter somebody has to remember. +// --------------------------------------------------------------------------- +export function foldersFor(projects) { + const nodes = new Map(); + const node = (p) => { + let n = nodes.get(p); + if (!n) { + n = { + path: p, + label: p === "" ? "" : p.split("/").pop(), + collapsedFrom: [], + projects: [], + children: [], + }; + nodes.set(p, n); + } + return n; + }; + node(""); + + for (const pr of projects) { + const parts = pr.id.split("/").slice(0, -1); + let acc = ""; + node("").children; + for (const seg of parts) { + const parent = acc; + acc = acc ? `${acc}/${seg}` : seg; + node(acc); + const pn = node(parent); + if (!pn.children.includes(acc)) pn.children.push(acc); + } + node(acc).projects.push(pr.id); + } + return nodes; +} + +/** + * Collapse pass-through folders FOR DISPLAY. + * + * `quartering-uh-song` holds no projects and exactly one child that matters + * (`videos`), so the index shows one heading, `quartering-uh-song / videos`. + * + * The URL is never collapsed -- /browse/quartering-uh-song/videos/yoshi stays + * the one true address. A URL has to mean the same thing in six weeks, and a + * display convenience is not allowed to decide what a link is. + */ +export function collapseFolders(nodes) { + const out = new Map(nodes); + let changed = true; + while (changed) { + changed = false; + for (const [p, n] of [...out]) { + if (p === "") continue; + if (n.projects.length !== 0 || n.children.length !== 1) continue; + const childPath = n.children[0]; + const child = out.get(childPath); + if (!child) continue; + child.label = `${n.label} / ${child.label}`; + child.collapsedFrom = [...n.collapsedFrom, n.path]; + // Re-parent: whoever pointed at n now points at the child. + for (const other of out.values()) { + const i = other.children.indexOf(p); + if (i >= 0) other.children[i] = childPath; + } + out.delete(p); + changed = true; + break; + } + } + return out; +}