Archilyzer · Source

archilyzer

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

commit 79df947924d5ea3bdab9234b011501e9e5fa011c
parent 42dbdd30808fd0a85229a70584fab6e22cc48be7
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date:   Sat, 19 Sep 2026 03:04:02 -0400

fetch one edge at a time, and make Shift actually nudge

Extending a window is nearly always one-sided: you want the sentence that
follows, not another twenty seconds of the lead-in you already heard. A
symmetric pad made every "20 more" twice what was asked for, and on a clip
near the start of a recording half of it was unreachable anyway.

So `--fetch-only` takes `--pad-before` and `--pad-after` (`--pad` stays the
shorthand that sets both), the route takes `padBefore`/`padAfter` and checks
the cache per side, and the bench has two controls that each compute their own
side from what is cached there and pass the other side through unchanged --
so the file that lands still contains the one it replaces, and `windowsFor`
keeps picking it. A side that cannot grow says which reason it is, "the
recording starts here" or the pad cap, instead of offering a press that cannot
help; the note after a fetch names the side that actually moved; and clicking
a dimmed peek cue extends only the side that cue is on.

Also: `Shift+.` arrives as ">", not ".", so the 0.5 s coarse nudge the
keyboard hint has always advertised fell through to default and did nothing on
any of the four edge keys. Every shifted character is handled now.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

Diffstat:
Mumtool/app/api/report/fetch/route.ts | 37+++++++++++++++++++++++++++----------
Mumtool/components/projects/ClipBench.tsx | 125++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-------------------
Mumtool/docs/clip-bench.md | 16++++++++++++++--
Mumtool/lib/report/driver.mjs | 14++++++++++----
Mumtool/report-to-video/build-video.mjs | 17++++++++++++++---
5 files changed, 160 insertions(+), 49 deletions(-)

diff --git a/umtool/app/api/report/fetch/route.ts b/umtool/app/api/report/fetch/route.ts @@ -22,7 +22,14 @@ export async function POST(request: Request) { const body = (await request.json().catch(() => ({}))) as Record<string, unknown>; const projectId = String(body.project ?? ""); const clipId = String(body.clip ?? ""); + // ONE EDGE AT A TIME. `pad` stays the symmetric shorthand; the bench sends + // the side it is extending and the side it already has, so a fetch never + // quietly re-downloads twenty seconds of lead-in nobody asked for. + const side = (v: unknown, fallback: number) => + Math.max(0, Math.min(MAX_PAD, Number(v ?? fallback))); const pad = Math.max(1, Math.min(MAX_PAD, Number(body.pad ?? 20))); + const padBefore = side(body.padBefore, pad); + const padAfter = side(body.padAfter, pad); const r = await resolveClip(projectId, clipId); if ("error" in r) return Response.json({ error: r.error }, { status: r.status }); @@ -36,19 +43,23 @@ export async function POST(request: Request) { // the bench reported "fetched" over a cache that had not moved. A no-op is // not a success, and saying so here is cheaper than a job that proves it. const windows = (await windowsFor(r.project, r.clip)) as { from: number; to: number }[]; - const want = { from: Number(r.clip.start) - pad, to: Number(r.clip.end) + pad }; + const want = { from: Number(r.clip.start) - padBefore, to: Number(r.clip.end) + padAfter }; const holds = windows.find((w) => w.from <= want.from + WIN_EPS && w.to >= want.to - WIN_EPS); if (holds) { - // What is already there, as the pad the bench speaks in. - const have = Math.floor( - Math.min(Number(r.clip.start) - holds.from, holds.to - Number(r.clip.end)), - ); + // What is already there, PER SIDE, in the pads the bench speaks in. + const haveBefore = Math.floor(Number(r.clip.start) - holds.from); + const haveAfter = Math.floor(holds.to - Number(r.clip.end)); return Response.json( { error: - `already cached to ±${have} s — ask for more` + - (pad >= MAX_PAD ? ` (±${MAX_PAD} s is the widest this asks for)` : ""), - cachedPad: have, + `already cached to −${haveBefore} s / +${haveAfter} s — ask for more on one side` + + (padBefore >= MAX_PAD || padAfter >= MAX_PAD + ? ` (${MAX_PAD} s is the widest one side goes)` + : ""), + cachedBefore: haveBefore, + cachedAfter: haveAfter, + /** The narrower side, for a caller that speaks in one number. */ + cachedPad: Math.min(haveBefore, haveAfter), maxPad: MAX_PAD, }, { status: 409 }, @@ -63,8 +74,14 @@ export async function POST(request: Request) { ); } - const job = startJob(`fetch ${projectId}/${clipId}`, fetchSteps(r.project, clipId, pad), { project: r.project.id }); - return Response.json({ job: jobView(job) }, { status: 202 }); + const job = startJob( + `fetch ${projectId}/${clipId}`, + fetchSteps(r.project, clipId, { padBefore, padAfter }), + { project: r.project.id }, + ); + // Which side this run is growing, so the caller can say so rather than + // guessing from a file name. + return Response.json({ job: jobView(job), padBefore, padAfter }, { status: 202 }); } export async function GET(request: Request) { diff --git a/umtool/components/projects/ClipBench.tsx b/umtool/components/projects/ClipBench.tsx @@ -624,10 +624,18 @@ export default function ClipBench({ data }: { data: ClipBenchData }) { if (el && /^(INPUT|TEXTAREA|SELECT)$/.test(el.tagName)) return; const step = e.shiftKey ? 0.5 : 0.05; switch (e.key) { - case "[": nudge("from", -step); break; - case "]": nudge("from", step); break; - case ",": nudge("to", -step); break; - case ".": nudge("to", step); break; + // The SHIFTED character too, on every edge key. `e.shiftKey` selects + // the 0.5 s step, but shift also changes what the key IS -- `Shift+.` + // arrives as ">" -- so the coarse nudge the hint advertises fell + // through to default and did nothing at all. + case "[": + case "{": nudge("from", -step); break; + case "]": + case "}": nudge("from", step); break; + case ",": + case "<": nudge("to", -step); break; + case ".": + case ">": nudge("to", step); break; case " ": e.preventDefault(); play(sel.from, sel.to); @@ -723,14 +731,19 @@ export default function ClipBench({ data }: { data: ClipBenchData }) { // for; the route refuses a window it can already serve; and a job that ran // still has to show a wider file before this claims anything arrived. const fetchMore = useCallback( - async (pad: number) => { - const before = windows[0] ? windows[0].to - windows[0].from : 0; + async (pads: { padBefore: number; padAfter: number }) => { + const had = windows[0] ?? null; setBusy("fetching a wider window…"); setNote(null); const r = await fetch("/api/report/fetch", { method: "POST", headers: { "content-type": "application/json" }, - body: JSON.stringify({ project: data.project, clip: clip.id, pad: round2(pad) }), + body: JSON.stringify({ + project: data.project, + clip: clip.id, + padBefore: round2(pads.padBefore), + padAfter: round2(pads.padAfter), + }), }); if (!r.ok) { const j = (await r.json()) as { error?: string }; @@ -749,10 +762,18 @@ export default function ClipBench({ data }: { data: ClipBenchData }) { if (sj.job.state === "failed") setNote(`the fetch failed: ${sj.job.error ?? "unknown"}`); else { const j = await refresh(); - const after = j?.windows?.[0] ? j.windows[0].to - j.windows[0].from : 0; + const now = j?.windows?.[0] ?? null; + // WHICH SIDE grew, because that is what was asked for. A fetch that + // ran and moved nothing is the thing this whole path exists to stop + // reporting as success. + const grew = [ + now && had && now.from < had.from - 0.01 ? "earlier" : null, + now && had && now.to > had.to + 0.01 ? "later" : null, + now && !had ? "the window" : null, + ].filter(Boolean); setNote( - after > before + 0.01 - ? "fetched — the build will reuse this file, not download it again" + grew.length + ? `fetched ${grew.join(" and ")} — the build will reuse this file, not download it again` : "nothing new arrived — the cache already covered that window", ); } @@ -858,12 +879,26 @@ export default function ClipBench({ data }: { data: ClipBenchData }) { const railSpan = Math.max(1e-6, rail.to - rail.from); const rpct = (t: number) => `${((t - rail.from) / railSpan) * 100}%`; - // How far the cached file reaches past the clip on its wider side, which is - // the pad the LAST fetch effectively bought. The next one asks for 20 more. - const reach = cached ? Math.max(clip.start - cached.from, cached.to - clip.end) : 0; + // How far the cached file reaches past the clip, PER SIDE. Extending is + // nearly always one-sided -- you want the sentence that follows, not another + // twenty seconds of the lead-in you already heard -- so each button asks for + // 20 more on its own side and leaves the other exactly where it is. + const reachBefore = cached ? clip.start - cached.from : 0; + const reachAfter = cached ? cached.to - clip.end : 0; const peekOutside = peek.some((c) => c.start < view.from - 0.02 || c.end > view.to + 0.02); - const nextPad = Math.min(data.maxPad, Math.round(reach) + 20); - const atMaxPad = reach >= data.maxPad - 0.5; + const nextBefore = Math.min(data.maxPad, Math.round(reachBefore) + 20); + const nextAfter = Math.min(data.maxPad, Math.round(reachAfter) + 20); + // A side stops being worth offering for two different reasons, and they are + // different sentences: the pad cap, and the end of the recording itself. + const atStartOfSource = !!cached && cached.from <= 0.02; + const atEndOfSource = + !!cached && data.sourceDuration != null && cached.to >= data.sourceDuration - 0.02; + const beforeMaxed = reachBefore >= data.maxPad - 0.5 || atStartOfSource; + const afterMaxed = reachAfter >= data.maxPad - 0.5 || atEndOfSource; + const atMaxPad = beforeMaxed && afterMaxed; + /** Keep the side that is not being extended exactly where it is. */ + const keepBefore = round2(reachBefore); + const keepAfter = round2(reachAfter); // ---- the other clips from this recording ---------------------------------- const siblings = data.siblings; @@ -876,12 +911,22 @@ export default function ClipBench({ data }: { data: ClipBenchData }) { .filter((sb) => sb.gap != null && sb.gap <= NEAR_SIBLING) .sort((a, b) => (a.gap ?? 0) - (b.gap ?? 0))[0]; - /** The pad that would put this cue (plus a breath) inside the cache. */ - const padForCue = (c: Cue) => - Math.min( - data.maxPad, - Math.ceil(Math.max(clip.start - (c.start - 2), c.end + 2 - clip.end, reach + 1)), - ); + /** + * The pads that would put this cue (plus a breath) inside the cache. + * + * ONE SIDE. A cue after the clip needs nothing fetched before it, and paying + * for the lead-in a second time to reach the sentence that follows is + * exactly the waste this is for. + */ + const padsForCue = (c: Cue) => { + const after = c.end > view.to; + return { + padBefore: after ? keepBefore : Math.min(data.maxPad, Math.ceil(clip.start - (c.start - 2))), + padAfter: after ? Math.min(data.maxPad, Math.ceil(c.end + 2 - clip.end)) : keepAfter, + }; + }; + const padLabelForCue = (c: Cue) => + c.end > view.to ? `+${padsForCue(c).padAfter} s` : `−${padsForCue(c).padBefore} s`; const segSrc = segment ? `/api/report/segment?project=${encodeURIComponent(data.project)}&clip=${encodeURIComponent(clip.id)}&v=${segmentMtime ?? 0}` @@ -1097,22 +1142,42 @@ export default function ClipBench({ data }: { data: ClipBenchData }) { </span> ) : ( <span className="text-[var(--color-dim)]"> - cached to ±{Math.floor(reach)} s around this clip + cached to −{Math.floor(reachBefore)} s / +{Math.floor(reachAfter)} s around this + clip + </span> + )} + {beforeMaxed ? ( + <span data-fetch-before-maxed="" className="text-[var(--color-dim)]"> + {atStartOfSource + ? "the recording starts here" + : `−${data.maxPad} s is the widest one side goes`} </span> + ) : ( + <button + type="button" + data-fetch-before={nextBefore} + className={buttonVariants({ size: "sm" })} + disabled={!!busy} + onClick={() => void fetchMore({ padBefore: nextBefore, padAfter: keepAfter })} + > + ← fetch to −{nextBefore} s + </button> )} - {atMaxPad ? ( - <span data-fetch-at-max="" className="text-[var(--color-dim)]"> - the cached window is at the maximum (±{data.maxPad} s) + {afterMaxed ? ( + <span data-fetch-after-maxed="" className="text-[var(--color-dim)]"> + {atEndOfSource + ? "the recording ends here" + : `+${data.maxPad} s is the widest one side goes`} </span> ) : ( <button type="button" - data-fetch-more={nextPad} + data-fetch-after={nextAfter} className={buttonVariants({ variant: "primary", size: "sm" })} disabled={!!busy} - onClick={() => void fetchMore(nextPad)} + onClick={() => void fetchMore({ padBefore: keepBefore, padAfter: nextAfter })} > - fetch to ±{nextPad} s + fetch to +{nextAfter} s → </button> )} </div> @@ -1188,14 +1253,14 @@ export default function ClipBench({ data }: { data: ClipBenchData }) { // The whole point of reading ahead: these words may not // be missing at all, they may be the next clip. owner ? `already in the cut as ${owner.id} (${owner.where})` : null, - outside ? `not fetched — click to fetch to ±${padForCue(c)} s` : null, + outside ? `not fetched — click to fetch to ${padLabelForCue(c)}` : null, ] .filter(Boolean) .join("\n\n") } onClick={() => { if (outside) { - if (!atMaxPad) void fetchMore(padForCue(c)); + if (!atMaxPad) void fetchMore(padsForCue(c)); return; } // Snap the NEARER edge to this cue's nearer boundary. diff --git a/umtool/docs/clip-bench.md b/umtool/docs/clip-bench.md @@ -78,13 +78,25 @@ its pad from what is on disk right now and **says the number** ("fetch to to produce a wider file before the bench claims anything arrived. At `FETCH_MAX_PAD` it says so instead of offering a press that cannot help. +**One edge at a time.** Extending a window is nearly always one-sided — you +want the sentence that follows, not another twenty seconds of the lead-in you +already heard — so a symmetric pad made every "20 more" twice what was asked +for. `--fetch-only` takes `--pad-before` and `--pad-after` (with `--pad` as the +shorthand that sets both), `POST /api/report/fetch` takes `padBefore` / +`padAfter`, and the bench offers two controls: *"← fetch to −23 s"* and *"fetch +to +23 s →"*, each computing its own side from what is cached on that side and +passing the OTHER side through unchanged, so the file that lands still contains +the one it replaces. A side that cannot grow says which reason it is — *"the +recording starts here"* or the pad cap — rather than offering a press that +cannot help. The message after a fetch names the side that moved. + **Read ahead before you pay for it.** The cue rail runs ±60 s past the cached file (one request to `/api/report/cues`, text from the archive and cheap beside media). Everything outside the cache is dimmed, both cache edges are marked, and the cached stretch — the part the waveform above is showing — is shaded, so the two scales cannot be mistaken for one. Clicking a dimmed cue fetches exactly far -enough to reach it plus two seconds; "peek further" widens the reading by -another 60 s without downloading anything. +enough to reach it plus two seconds, **on that cue's side only**; "peek further" +widens the reading by another 60 s without downloading anything. ## Three things the JSON cannot show you diff --git a/umtool/lib/report/driver.mjs b/umtool/lib/report/driver.mjs @@ -163,15 +163,19 @@ export const FETCH_MAX_PAD = 120; * Fetch ONE clip's window, wide. What the bench's "fetch more" runs. * @param {{ dir: string }} project * @param {string} clipId - * @param {number} pad + * @param {number | { padBefore: number, padAfter: number }} pad * @returns {import("../trim").Step[]} */ export function fetchSteps(project, clipId, pad) { + // A number is the symmetric shorthand; {padBefore, padAfter} is one edge at + // a time, which is what extending a window actually is. + const before = typeof pad === "object" && pad ? Number(pad.padBefore) : Number(pad); + const after = typeof pad === "object" && pad ? Number(pad.padAfter) : Number(pad); return [ { cwd: PIPELINE_DIR, env: {}, - label: `fetch ${clipId} with ${pad}s of pad`, + label: `fetch ${clipId} with −${before}s / +${after}s of pad`, argv: [ "node", script("build-video.mjs"), @@ -180,8 +184,10 @@ export function fetchSteps(project, clipId, pad) { path.join(project.dir, "out"), "--fetch-only", clipId, - "--pad", - String(pad), + "--pad-before", + String(before), + "--pad-after", + String(after), "--progress", "ndjson", ], diff --git a/umtool/report-to-video/build-video.mjs b/umtool/report-to-video/build-video.mjs @@ -39,6 +39,7 @@ // --continue-on-error Record a failed entry and carry on, instead of aborting // --fetch-only <id> Fetch one clip's window into clips-raw and stop // --pad <s> Override render.fetchPad (the clip bench fetches wide) +// --pad-before <s> / --pad-after <s> One side only; each defaults to --pad // --site-origin <url> Archive to read cue windows from when there is no local // corpus (defaults to the manifest's provenance.siteOrigin) // --resolve-site-ids On a published-id miss, find the record by scanning the @@ -324,9 +325,15 @@ async function fetchClip(entry, meta, render, rawDir, opts) { // Deliberately over-fetch: the snapping pass below needs room on both sides to // find a silence, and a clip that has no slack can only be cut where the cue // happened to break — which is what put words in half in the first place. + // ONE EDGE AT A TIME. Extending a window is nearly always one-sided -- you + // want the sentence that follows, not another twenty seconds of the lead-in + // you already heard -- and a symmetric pad makes every "20 more" fetch twice + // what was asked for. `--pad` stays the shorthand that sets both. const pad = opts.pad ?? render.fetchPad ?? 3.0; - const from = Math.max(0, entry.start - pad); - const to = entry.end + pad; + const padBefore = opts.padBefore ?? pad; + const padAfter = opts.padAfter ?? pad; + const from = Math.max(0, entry.start - padBefore); + const to = entry.end + padAfter; // Shared across variants, and deliberately so: this is the only expensive // thing in a build, and the two cuts overlap almost entirely. @@ -2272,7 +2279,7 @@ async function main() { console.error( "usage: build-video.mjs <manifest.json> [--out <dir>] [--variant sourced|full]\n" + " [--only <id>] [--fetch-only <id>]\n" + - " [--pad <s>] [--skip-fetch] [--no-xfade] [--no-chapters] [--chapters-only]\n" + + " [--pad <s>] [--pad-before <s>] [--pad-after <s>] [--skip-fetch] [--no-xfade] [--no-chapters] [--chapters-only]\n" + " [--progress ndjson] [--continue-on-error] [--no-reuse]\n" + " [--no-rail] [--rail-only] [--preview <start> <dur>]\n" + " [--site-origin <url>] [--resolve-site-ids] [--cue-source auto|local|http]", @@ -2286,6 +2293,8 @@ async function main() { setProgressMode(flag("--progress") ?? "human"); const padArg = flag("--pad"); + const padBeforeArg = flag("--pad-before"); + const padAfterArg = flag("--pad-after"); const opts = { variant: flag("--variant") ?? "sourced", skipFetch: argv.includes("--skip-fetch"), @@ -2297,6 +2306,8 @@ async function main() { noRail: argv.includes("--no-rail"), railOnly: argv.includes("--rail-only"), pad: padArg === undefined ? undefined : Number(padArg), + padBefore: padBeforeArg === undefined ? undefined : Number(padBeforeArg), + padAfter: padAfterArg === undefined ? undefined : Number(padAfterArg), siteOrigin: flag("--site-origin"), resolveSiteIds: argv.includes("--resolve-site-ids"), cueSource: flag("--cue-source"),