Archilyzer · Source

archilyzer

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

commit 4a6fbf4758ed0c8ca3e55ea91cc5c8bacfa770cc
parent 9523390daaede5c14a4fda344fbc1f7a6487435b
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date:   Tue, 18 Aug 2026 22:50:39 -0400

umtool: a clip reaches /mix, and a bad link is refused rather than redirected

The last of the four decisions taken with the user: "a clip must reach /mix from
the browse menu". Six of seven projects used to resolve to null there.

The link is resolved SERVER-SIDE, in app/mix/page.tsx, which is why this needs
no useSearchParams and no Suspense boundary -- that page already ran on the
server, it simply never read its own searchParams.

REFUSE, NEVER CLAMP. A body outside the roots, or one with no audio track,
produces a bench with no preset and a visible reason. Silently opening a
DIFFERENT file than the link named is the one outcome worse than an error, and
lib/mix.ts already takes this line for the same reason. Three shapes are tested:
an absolute path outside the roots, a traversal, and a plausible in-root name
that is not there.

What a clip's link opens is the widest cached RAW window, not the built segment.
Three reasons: it exists as soon as the clip has been fetched once (segments
only exist after a build), it has NO CHROME burned in -- which is what a mix is
looking at -- and it is the file the bench already has peaks for. The built
segment is the fallback, and a clip with neither renders no link at all rather
than a dead one that 400s.

start/end are the clip's window minus the file's own start, computed server-side
because the arithmetic is exact and known there; making the client do it would
be a second place to get it wrong. The bench then says "this window came from
c01 — the file itself runs 0.00–9.00s", so the material outside the window does
not look unreachable. Precedence on load is preset > the saved pair > blank: a
link that named a file and a window must not lose to whatever the bench was last
pointed at, though the per-pair knobs (handover, fade, gain, duck) are still
adopted, because those are things learned about that pair.

Two fixture facts the specs turned up:

  - Every fixture clip was under listMedia's 256 KB "is this a real track" floor,
    so the grouped-picker spec saw an empty list while the code under it was
    correct. `-b:v` does nothing about it -- libx264 defaults to CRF and ignores
    it on a flat colour -- so the fixture now encodes CBR. Real deliverables are
    26 to 157 MB.
  - A report project whose only media is out/clips-raw correctly offers the
    picker NOTHING, because those are intermediates excluded by name. One
    fixture therefore ships an actual deliverable, so that path is testable
    without depending on the build specs having run first.

e2e: 141 passed.

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

Diffstat:
Mumtool/app/mix/page.tsx | 49++++++++++++++++++++++++++++++++++++++++++++-----
Mumtool/components/MixBench.tsx | 48++++++++++++++++++++++++++++++++++++++++++++++--
Mumtool/components/projects/ClipBench.tsx | 15+++++++++++++++
Mumtool/components/projects/ClipBenchPage.tsx | 14++++++++++++++
Mumtool/components/projects/ReportProject.tsx | 75+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--------
Mumtool/e2e/fixtures/make-fixture.mjs | 36+++++++++++++++++++++++++++++++++---
Mumtool/e2e/mix.spec.ts | 79+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aumtool/lib/mix-preset.ts | 91+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
8 files changed, 389 insertions(+), 18 deletions(-)

diff --git a/umtool/app/mix/page.tsx b/umtool/app/mix/page.tsx @@ -1,25 +1,64 @@ +import Link from "next/link"; import AppNav from "@/components/AppNav"; import MixBench from "@/components/MixBench"; import { MEDIA_ROOTS } from "@/lib/paths"; +import { isRefusal, resolvePreset } from "@/lib/mix-preset"; export const dynamic = "force-dynamic"; // A STATIC segment, so it wins over app/[mode]/page.tsx -- which would // otherwise catch /mix, fail isMode(), and 404. -export default function MixPage() { +// +// The deep link (?body=&start=&end=&from=&clip=) is resolved HERE, server-side. +// That is what lets a clip reach the bench with no useSearchParams and no +// Suspense boundary: this page already ran on the server, it just never read its +// own searchParams. +export default async function MixPage({ + searchParams, +}: { + searchParams: Promise<{ body?: string; start?: string; end?: string; from?: string; clip?: string }>; +}) { + const sp = await searchParams; + const resolved = await resolvePreset(sp); + const refused = isRefusal(resolved) ? resolved : null; + const preset = isRefusal(resolved) ? null : resolved; + return ( <div className="flex h-full flex-col"> <header className="flex flex-wrap items-center gap-3 border-b border-[var(--color-line)] bg-[var(--color-panel)] px-4 py-2"> <AppNav active="mix" /> - <div className="text-[12px] text-[var(--color-dim)]"> - set the handover and the end point, hear it, then render it - </div> + {preset?.fromHref ? ( + <Link + href={preset.fromHref} + className="text-[12px] text-[var(--color-sel)] hover:underline" + data-mix-from={preset.from ?? ""} + > + ← {preset.from} + {preset.clip ? ` / ${preset.clip}` : ""} + </Link> + ) : ( + <div className="text-[12px] text-[var(--color-dim)]"> + set the handover and the end point, hear it, then render it + </div> + )} <div className="num ml-auto hidden text-[10px] text-[var(--color-dim)] lg:block"> {MEDIA_ROOTS.length} roots </div> </header> + + {refused && ( + // Refused, not clamped. A link that quietly opened a DIFFERENT file than + // it named would be worse than this message. + <p + data-mix-refused="" + 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)]" + > + that link was refused — {refused.reason} + </p> + )} + <main className="deck-main flex-1"> - <MixBench /> + <MixBench preset={preset} /> </main> </div> ); diff --git a/umtool/components/MixBench.tsx b/umtool/components/MixBench.tsx @@ -60,7 +60,19 @@ export function bgGainAt(t: number, s: Pick<Spec, "handover" | "fade" | "bgGain" return s.bgGain + (s.duck - s.bgGain) * ((t - s.handover) / s.fade); } -export default function MixBench() { +export type MixPresetProp = { + body: string; + label: string; + start: number; + end: number; + duration: number; + from: string | null; + clip: string | null; + fileFrom: number; + fileTo: number; +} | null; + +export default function MixBench({ preset = null }: { preset?: MixPresetProp }) { const [groups, setGroups] = useState<FileGroup[]>([]); const [other, setOther] = useState<FileRow[]>([]); // The only client state the picker adds. A twelve-project tree makes the @@ -96,10 +108,30 @@ export default function MixBench() { ]); setGroups(f.groups ?? []); setOther(f.other ?? []); + + // PRECEDENCE: preset > the saved pair > BLANK. `last` is used only when + // there is no preset -- a link that named a file and a window must not + // lose to whatever this bench was last pointed at. + if (preset) { + // The per-pair knobs are still worth having: handover, fade, gain and + // duck are things learned about THIS pair, while body/start/end came + // from the link. + const pair = s?.byPair?.[`${preset.body}|`] ?? {}; + setSpec({ + ...BLANK, + ...pair, + body: preset.body, + bg: null, + start: preset.start, + end: preset.end, + }); + return; + } const last = s?.last ? s.byPair?.[s.last] : null; if (last?.body) setSpec({ ...BLANK, ...last }); })(); - }, []); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [preset?.body, preset?.start, preset?.end]); const loadTrack = useCallback(async (which: "body" | "bg", path: string | null) => { if (!path) { @@ -420,6 +452,18 @@ export default function MixBench() { return ( <div className="mx-auto w-full max-w-6xl p-4"> + {preset && ( + // The window came from a clip, but the FILE is wider than the clip -- + // saying so is what stops the material outside it looking unreachable. + <p className="mb-2 text-[11px] text-[var(--color-dim)]" data-mix-preset={preset.clip ?? ""}> + this window came from{" "} + <span className="font-mono text-[var(--color-text)]"> + {preset.from} + {preset.clip ? ` / ${preset.clip}` : ""} + </span>{" "} + — the file itself runs {preset.fileFrom.toFixed(2)}–{preset.fileTo.toFixed(2)}s + </p> + )} <div className="mb-2">{pickerFilter}</div> <div className="mb-3 grid gap-3 md:grid-cols-2"> <div> diff --git a/umtool/components/projects/ClipBench.tsx b/umtool/components/projects/ClipBench.tsx @@ -53,6 +53,8 @@ export type ClipBenchData = { cues: Cue[]; token: string | null; fetchPad: number; + /** A /mix deep link for this clip's cached window, or null. Server-built. */ + mixHref: string | null; }; const hms = (t: number) => { @@ -349,6 +351,19 @@ export default function ClipBench({ data }: { data: ClipBenchData }) { > reset </button> + {data.mixHref && ( + // Built SERVER-SIDE, because a mix link carries an absolute path + // and this component has no business constructing one. It also + // carries the SAVED window rather than the current selection: a mix + // of an unsaved edit is a mix of something not in the cut. + <Link + href={data.mixHref} + data-mix-link={clip.id} + className={buttonVariants({ size: "sm" })} + > + mix + </Link> + )} </div> <div className="micro"> diff --git a/umtool/components/projects/ClipBenchPage.tsx b/umtool/components/projects/ClipBenchPage.tsx @@ -1,3 +1,4 @@ +import path from "node:path"; import { notFound } from "next/navigation"; import BrowseHeader from "@/components/BrowseHeader"; import ClipBench, { type ClipBenchData } from "./ClipBench"; @@ -63,6 +64,19 @@ export default async function ClipBenchPage({ cues: cues?.cues ?? [], token: await manifestToken(project.dir), fetchPad: manifest.render?.fetchPad ?? 3, + // The widest cached RAW window: it has no chrome burned in, which is what a + // mix is looking at, and it exists as soon as the clip has been fetched + // once. start/end are the window minus the file's own start, because a mix + // spec is relative to the file it names. + mixHref: widest + ? `/mix?${new URLSearchParams({ + body: path.join(project.dir, "out", "clips-raw", widest.name), + start: (entry.start - widest.from).toFixed(2), + end: (entry.end - widest.from).toFixed(2), + from: project.id, + clip: clipId, + })}` + : null, }; const clips = detail.entries.filter((e: { kind: string }) => e.kind === "clip"); diff --git a/umtool/components/projects/ReportProject.tsx b/umtool/components/projects/ReportProject.tsx @@ -1,3 +1,4 @@ +import path from "node:path"; import Link from "next/link"; import BrowseHeader from "@/components/BrowseHeader"; import { fmtAgo, fmtBytes } from "@/lib/format"; @@ -67,6 +68,39 @@ export default async function ReportProject({ const showAll = search.all === "1"; const p = m.provenance ?? {}; + // ---- what a clip's "mix" link opens --------------------------------------- + // + // The widest cached RAW window, not the built segment. Three reasons: it + // exists as soon as a clip has been fetched once (segments only exist after a + // build), it has NO CHROME burned in -- which is what a mix is looking at -- + // and it is the file the bench already has peaks for. The built segment is the + // fallback, and the link says which it is. + // + // start/end are the clip's window MINUS the file's own start, because a mix + // spec is relative to the file it names. The arithmetic is exact and known + // here; making the client do it would be a second place to get it wrong. + const mixHref = (e: { + id: string; + start: number; + end: number; + widest: { name: string; from: number } | null; + segment: string | null; + }): string | null => { + const q = new URLSearchParams(); + if (e.widest) { + q.set("body", path.join(project.dir, "out", "clips-raw", e.widest.name)); + q.set("start", (e.start - e.widest.from).toFixed(2)); + q.set("end", (e.end - e.widest.from).toFixed(2)); + } else if (e.segment) { + q.set("body", path.join(project.dir, e.segment)); + } else { + return null; + } + q.set("from", project.id); + q.set("clip", e.id); + return `/mix?${q}`; + }; + return ( <div className="flex h-full flex-col"> <BrowseHeader @@ -93,6 +127,14 @@ export default async function ReportProject({ <div className="num"> {fmtBytes(build.finalSize)} · {fmtAgo(build.finalMtimeMs)} </div> + {/* No window: the whole thing is the point of a deliverable link. */} + <Link + href={`/mix?body=${encodeURIComponent(build.finalPath)}&from=${encodeURIComponent(project.id)}`} + data-mix-deliverable="" + className="text-[var(--color-sel)] hover:underline" + > + open in mix + </Link> </div> )} </div> @@ -208,14 +250,31 @@ export default async function ReportProject({ widener would move it </Pill> )} - <Link - href={`/browse/${project.id}/clip/${e.id}`} - data-bench-link={e.id} - className="ml-auto text-[11px] text-[var(--color-sel)] hover:underline" - > - bench → - </Link> - <span className="micro">§{e.section ?? 0}</span> + <span className="ml-auto flex items-center gap-2"> + {mixHref(e) ? ( + <Link + href={mixHref(e)!} + data-mix-link={e.id} + className="text-[11px] text-[var(--color-sel)] hover:underline" + > + mix + </Link> + ) : ( + // Never a dead link that 400s: a clip with nothing + // fetched has nothing to open. + <span className="micro" data-mix-link-disabled={e.id}> + fetch it first + </span> + )} + <Link + href={`/browse/${project.id}/clip/${e.id}`} + data-bench-link={e.id} + className="text-[11px] text-[var(--color-sel)] hover:underline" + > + bench → + </Link> + <span className="micro">§{e.section ?? 0}</span> + </span> </div> {(showAll || midSentence || e.proposed) && e.quote && ( <p className="mt-1 text-[11px] leading-snug text-[var(--color-dim)]"> diff --git a/umtool/e2e/fixtures/make-fixture.mjs b/umtool/e2e/fixtures/make-fixture.mjs @@ -263,14 +263,32 @@ if (copied.length) { // beta two cuts and nothing else -- the hole in the cut set, which a // scan-derived cut list would render as complete const VIDEOS = path.join(reports, "videos"); -/** A silent video of a known length, at a colour that identifies it on sight. */ +/** + * A silent video of a known length, at a colour that identifies it on sight. + * + * CBR is not cosmetic. listMedia() drops anything under 256 KB as "a fragment, + * a probe or a one-note extraction", and a flat colour encodes to about eight + * kilobytes -- so an unpadded fixture cut is invisible to the mix picker, and + * the grouped-picker spec sees an empty list while the code under it is + * perfectly correct. Real deliverables are 26 to 157 MB. + * + * `-b:v` alone does nothing here: libx264 defaults to CRF and ignores it on + * content this compressible. Constant bitrate forces the padding, and leaves + * the duration exactly as asked. + */ +const BULK = [ + "-c:v", "libx264", + "-b:v", "1500k", "-minrate", "1500k", "-maxrate", "1500k", "-bufsize", "3000k", + "-x264-params", "nal-hrd=cbr:force-cfr=1", +]; + const clip = (file, seconds, colour) => { mkdirSync(path.dirname(file), { recursive: true }); ff([ "-f", "lavfi", "-i", `color=c=${colour}:size=320x180:rate=15:duration=${seconds}`, "-f", "lavfi", "-i", `anullsrc=r=48000:cl=stereo:d=${seconds}`, "-t", String(seconds), - "-c:v", "libx264", "-pix_fmt", "yuv420p", "-c:a", "aac", "-ar", "48000", + ...BULK, "-pix_fmt", "yuv420p", "-c:a", "aac", "-ar", "48000", file, ]); }; @@ -910,9 +928,17 @@ ff([ "-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", + // Past listMedia's 256 KB floor, so the mix picker can see it. See clip(). + ...BULK, "-pix_fmt", "yuv420p", "-c:a", "aac", "-ar", "48000", "-ac", "2", path.join(REPORT, "out", "clips-raw", "vid1_0.00-9.00.mp4"), ]); +// One report project ships a DELIVERABLE, so "a report video's finished file +// appears in the mix picker" is testable without depending on the build specs +// having run first. The others deliberately have none: a project whose only +// media is out/clips-raw has nothing to offer a picker, because those are +// intermediates and are excluded by name. +mkdirSync(path.join(reports, "no-origin-fixture", "out"), { recursive: true }); + for (const dir of [BENCH, BUILD]) { mkdirSync(path.join(dir, "out", "clips-raw"), { recursive: true }); copyFileSync( @@ -920,6 +946,10 @@ for (const dir of [BENCH, BUILD]) { path.join(dir, "out", "clips-raw", "vid1_0.00-9.00.mp4"), ); } +copyFileSync( + path.join(REPORT, "out", "clips-raw", "vid1_0.00-9.00.mp4"), + path.join(reports, "no-origin-fixture", "out", "no-origin-fixture.mp4"), +); console.log(`fixture at ${dest}`); if (planned) console.log(` planned clip (used in a build): ${planned}`); diff --git a/umtool/e2e/mix.spec.ts b/umtool/e2e/mix.spec.ts @@ -128,3 +128,82 @@ test("choosing a song seeds the handover where the song starts", async ({ page } const handover = page.locator('input[type="number"]').first(); await expect.poll(async () => Number(await handover.inputValue())).toBeGreaterThan(1.9); }); + +// --------------------------------------------------------------------------- +// Reaching the bench from a project. +// +// Six of seven projects used to resolve to null here: MEDIA_ROOTS was the +// um-song subtree, so no report video's media was openable at all. These assert +// that the way in is a link, and that a bad one is REFUSED rather than quietly +// opening something else. +// --------------------------------------------------------------------------- + +test("the picker is grouped by project, and every project's media is in it", async ({ request }) => { + const j = (await (await request.get("/api/mix/files?group=project")).json()) as { + groups: { project: string; label: string; files: { label: string }[] }[]; + other: { label: string }[]; + }; + + // Coverage is a property of the ENUMERATION, not of the cap. A flat + // newest-first list with a 600-entry limit dropped four of six real report + // deliverables off the end, and per-song cuts never appeared at all. + const ids = j.groups.map((g) => g.project); + // A song's cuts, three levels down, which listMedia never reached before. + expect(ids).toContain("reports/videos/alpha"); + // A report video's DELIVERABLE. + expect(ids).toContain("reports/no-origin-fixture"); + + // And the intermediates stay out. out/ holds one deliverable and 40 to 60 + // working files, so reaching one level deeper without excluding these by name + // would put ~260 of them in a picker that is already saturated. A report + // project whose only media is clips-raw correctly offers NOTHING. + const all = [...j.groups.flatMap((g) => g.files), ...j.other]; + expect(all.filter((f) => f.label.includes("segments/"))).toHaveLength(0); + expect(all.filter((f) => f.label.includes("cards/"))).toHaveLength(0); + expect(all.filter((f) => f.label.includes("clips-raw/"))).toHaveLength(0); +}); + +test("a clip row links into the bench with the window already set", async ({ page }) => { + await page.goto("/browse/reports/report-fixture"); + const link = page.locator("[data-mix-link=c01]"); + await expect(link).toBeVisible(); + + const href = (await link.getAttribute("href"))!; + // The window minus the cached file's own start. c01 is 3.00-6.00 and the file + // begins at 0.00, so the arithmetic is visible and exact. + expect(href).toContain("start=3.00"); + expect(href).toContain("end=6.00"); + // ABSOLUTE, like the picker's own <option value>. A relative path 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. + expect(decodeURIComponent(href)).toContain("/out/clips-raw/vid1_0.00-9.00.mp4"); + + await link.click(); + await expect(page).toHaveURL(/\/mix\?/); + await expect(page.locator("[data-mix-refused]")).toHaveCount(0); + // The crumb back to where the link came from. + await expect(page.locator("[data-mix-from]")).toHaveText(/report-fixture/); + // And the fact that the FILE is wider than the clip, so the material outside + // the window does not look unreachable. + await expect(page.locator("[data-mix-preset=c01]")).toContainText("0.00"); +}); + +test("a deep link outside the roots is REFUSED, not clamped to something else", async ({ page }) => { + for (const bad of ["/etc/passwd", "../../../../etc/hosts", "/tmp/nope.mp4"]) { + await page.goto(`/mix?body=${encodeURIComponent(bad)}`); + // Silently opening a DIFFERENT file than the link named is the one outcome + // worse than an error. + await expect(page.locator("[data-mix-refused]")).toBeVisible(); + await expect(page.locator("[data-mix-preset]")).toHaveCount(0); + } +}); + +test("a clip with nothing fetched offers no link at all, rather than a dead one", async ({ + page, +}) => { + await page.goto("/browse/reports/report-fixture"); + // c02's window has no cached file covering it, and a 400 on click would be + // worse than saying so. + await expect(page.locator("[data-mix-link-disabled=c02]")).toBeVisible(); + await expect(page.locator("[data-mix-link=c02]")).toHaveCount(0); +}); diff --git a/umtool/lib/mix-preset.ts b/umtool/lib/mix-preset.ts @@ -0,0 +1,91 @@ +import path from "node:path"; +import { labelFor, resolveInRoots } from "./paths"; +import { probeMedia } from "./media"; +import { projectRef } from "./projects"; + +// --------------------------------------------------------------------------- +// A deep link into /mix. +// +// Resolved SERVER-SIDE, in the page, which is why this feature needs no +// useSearchParams and no Suspense boundary -- app/mix/page.tsx is a server +// component that simply did not read searchParams before. +// +// REFUSE, NEVER CLAMP. A body outside the roots produces a bench with no preset +// and a visible reason. Silently opening a DIFFERENT file than the link named is +// the one outcome worse than an error, and lib/mix.ts already takes this line +// for the same reason. +// --------------------------------------------------------------------------- + +export type MixPreset = { + body: string; + label: string; + start: number; + end: number; + duration: number; + hasAudio: boolean; + /** Where the link came from, for the back-crumb. Never used to resolve media. */ + from: string | null; + fromHref: string | null; + clip: string | null; + /** The file's own span, so "there is more material here" is visible. */ + fileFrom: number; + fileTo: number; +}; + +export type MixRefusal = { reason: string }; + +export async function resolvePreset(params: { + body?: string; + start?: string; + end?: string; + from?: string; + clip?: string; +}): Promise<MixPreset | MixRefusal | null> { + if (!params.body) return null; + + const abs = resolveInRoots(params.body); + if (!abs) return { reason: `that file is not inside any known root: ${params.body}` }; + + let info; + try { + info = await probeMedia(abs); + } catch (e) { + return { reason: `could not read it: ${e instanceof Error ? e.message : String(e)}` }; + } + // A body with no audio fails at render time anyway (lib/mix.ts throws), so + // say it now rather than after the numbers have been set. + if (!info.hasAudio) return { reason: `${labelFor(abs)} has no audio track` }; + + const num = (v: string | undefined) => { + const n = Number(v); + return Number.isFinite(n) && n >= 0 ? n : 0; + }; + + // `from` and `clip` are PROVENANCE, for the crumb and the header line. They + // are deliberately not used to resolve the media -- that would be a second, + // divergent resolver for the thing this one already did. + const project = params.from ? await projectRef(params.from) : null; + + // The file's own span, when its name carries one. A raw clip is + // <video>_<from>-<to>.mp4, so the bench can say "this file runs 0–31.4s" + // rather than leaving the material outside the window looking unreachable. + const m = /_(\d+(?:\.\d+)?)-(\d+(?:\.\d+)?)\.mp4$/.exec(path.basename(abs)); + const fileFrom = m ? Number(m[1]) : 0; + + return { + body: abs, + label: labelFor(abs), + start: num(params.start), + end: num(params.end), + duration: info.duration, + hasAudio: info.hasAudio, + from: project?.id ?? null, + fromHref: project ? `/browse/${project.id}` : null, + clip: params.clip ?? null, + fileFrom, + fileTo: fileFrom + info.duration, + }; +} + +export const isRefusal = (v: MixPreset | MixRefusal | null): v is MixRefusal => + !!v && "reason" in v;