Archilyzer · Source

archilyzer

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

commit f01cc78fc7caea0d3426ce50da47525c7fa39d59
parent fc93770247132926044fc160ee62ed2d6bc498aa
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date:   Sun, 20 Sep 2026 17:42:01 -0400

bench: one clips-raw read per request, and a per-clip `fetched`

readClipDetail asked the directory twice PER CLIP -- once for the padded
window the build would fetch, once for the full list -- so a forty-clip cut
paid eighty readdirs to draw one page. rawCacheOf() reads it once and answers
by arithmetic.

It lives in its own leaf module rather than in serve.mjs: serve.mjs imports
report.mjs, and closing that circle breaks module initialisation outright
(kinds.mjs reads a report.mjs const at top level, and in the cycle it is still
in its dead zone). serve.mjs re-exports it so the bench keeps one door.

`fetched` is the PLAYER question -- a cached file holding the clip s own
window -- beside `cached`, which stays the BUILD question it always was.
walkReadiness() is the one definition of what the walk visits.

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

Diffstat:
Mumtool/lib/projects/report.mjs | 47+++++++++++++++++++++++++++++++++++++++++++----
Aumtool/lib/report/raw-cache.mjs | 48++++++++++++++++++++++++++++++++++++++++++++++++
Mumtool/lib/report/serve.mjs | 15+++++++++------
3 files changed, 100 insertions(+), 10 deletions(-)

diff --git a/umtool/lib/projects/report.mjs b/umtool/lib/projects/report.mjs @@ -7,7 +7,8 @@ // and neither parses JSON. import { readdir, readFile, stat } from "node:fs/promises"; import path from "node:path"; -import { DEFAULT_VARIANT, cachedWindowsFor, findContainingWindow } from "umtool-report-to-video/build-video"; +import { DEFAULT_VARIANT, cachedWindowsFor } from "umtool-report-to-video/build-video"; +import { rawCacheOf } from "../report/raw-cache.mjs"; import { channelName, cleanTitle } from "umtool-report-to-video/attribution"; /** @@ -184,6 +185,37 @@ export function reviewOf(m) { } /** + * What the WALK will actually visit, and how much of it can be visited today. + * + * The walk is somebody at a desk answering one question per clip, so it goes to + * the clips that still need an answer AND can be watched end to end right now: + * + * needing -> nobody has judged it (`clipVerdict` is "unreviewed"). The same + * rule reviewOf() counts by, so "12 of 19 reviewed" and "ready 3 + * of 7" cannot disagree about the same clip. + * ready -> needing AND `fetched`, which readClipDetail computes from the + * clips-raw cache: a file holding the clip's own window. + * + * Walking onto a clip with nothing to play is a dead end -- there is nothing to + * judge and the only move is to press `n` again -- and walking back onto one + * already answered is the same round trip a walk exists to remove. `readyIds` + * is in TIMELINE ORDER, because the cut's order is the order you watch it in. + * + * @param {{id: string, kind?: string, fetched?: boolean}[]} entries readClipDetail's entries + */ +export function walkReadiness(entries) { + const clips = (entries ?? []).filter((e) => (e.kind ?? e.type) === "clip"); + const needing = clips.filter((e) => clipVerdict(e) === "unreviewed"); + const ready = needing.filter((e) => !!e.fetched); + return { + needing: needing.length, + ready: ready.length, + needingIds: needing.map((e) => e.id), + readyIds: ready.map((e) => e.id), + }; +} + +/** * The OTHER clips in the cut that come from this same recording. * * "Does this clip need more context, or is the context already coming up as @@ -972,7 +1004,10 @@ export async function readClipDetail(dir, { manifest = null } = {}) { const shadowExists = await hasShadowChannels(dir); const channelsDir = channelsDirFor(dir, m, { shadowExists }); const build = await buildStateOf(dir, m); - const rawDir = path.join(dir, "out", "clips-raw"); + // ONE listing of out/clips-raw for the whole project. This used to be two + // readdirs PER CLIP -- the padded lookup and the full list -- so a forty-clip + // cut paid eighty directory reads to draw one page. + const raw = await rawCacheOf(dir); const segDirs = segmentDirs(path.join(dir, "out")); const segLists = await Promise.all(segDirs.map((d) => readdir(d).catch(() => []))); const segWhich = segLists.findIndex((l) => l.length); @@ -1010,8 +1045,8 @@ export async function readClipDetail(dir, { manifest = null } = {}) { const pad = m.render?.fetchPad ?? 3.0; const from = Math.max(0, e.start - pad); const to = e.end + pad; - const cached = await findContainingWindow(rawDir, e.video, from, to); - const allWindows = await cachedWindowsFor(rawDir, e.video); + const cached = raw.containing(e.video, from, to); + const allWindows = raw.windows(e.video); // The bench wants the WIDEST containing file (room to drag); the build wants // the tightest (least to decode). They are different questions. const widest = allWindows @@ -1067,6 +1102,10 @@ export async function readClipDetail(dir, { manifest = null } = {}) { noPunctuation, proposed, cached: cached ? { name: cached.name, from: cached.from, to: cached.to } : null, + // `cached` is the BUILD's question (is the padded window on disk); this is + // the PLAYER's (can this clip be watched end to end right now), and it is + // what the walk skips on. Same predicate, different window. + fetched: raw.isFetched(e), widest: widest ? { name: widest.name, from: widest.from, to: widest.to } : null, segment: segNames.has(`${e.id}.mp4`) ? path.posix.join(segRel, `${e.id}.mp4`) : null, wantFrom: from, diff --git a/umtool/lib/report/raw-cache.mjs b/umtool/lib/report/raw-cache.mjs @@ -0,0 +1,48 @@ +// ONE read of out/clips-raw, answering for every clip in a project. +// +// Its own module, and deliberately a LEAF: lib/projects/report.mjs reads it, and +// so does lib/report/serve.mjs, which report.mjs is itself imported by. Putting +// it in serve.mjs closed that circle and broke module initialisation outright +// (kinds.mjs reads a const of report.mjs at top level, and in the cycle that +// const is still in its temporal dead zone). Nothing here imports anything of +// ours but the predicate. +import path from "node:path"; +import { + listRawNames, + tightestContaining, + windowsFromNames, +} from "umtool-report-to-video/build-video"; + +/** + * The cache. The directory is keyed by VIDEO and a page asks about it per clip + * -- the bench page did two readdirs per clip, and a forty-clip cut paid eighty + * of them for one screen. The listing is read once, the per-video parse is + * memoised, and every question below is then arithmetic. + * + * `isFetched(clip)` is the walk's question and the one a follow-up importer + * wants: is there a cached file holding this clip's OWN window, end to end. + * Not the PADDED window the build would fetch (that is `containing()` with the + * pad applied, and it calls three of four fixture clips unfetched), and not + * mere overlap -- a half-covered clip cannot be watched through, so it is not + * ready to judge. + */ +export async function rawCacheOf(projectDir) { + const rawDir = path.join(projectDir, "out", "clips-raw"); + const names = await listRawNames(rawDir); + const byVideo = new Map(); + const windows = (video) => { + if (!byVideo.has(video)) byVideo.set(video, windowsFromNames(names, rawDir, video)); + return byVideo.get(video); + }; + return { + rawDir, + windows, + containing: (video, from, to) => tightestContaining(windows(video), from, to), + isFetched: (clip) => { + const { start, end, video } = clip ?? {}; + if (!Number.isFinite(start) || !Number.isFinite(end)) return false; + return !!tightestContaining(windows(video), start, end); + }, + }; +} + diff --git a/umtool/lib/report/serve.mjs b/umtool/lib/report/serve.mjs @@ -9,7 +9,8 @@ import path from "node:path"; import { stat } from "node:fs/promises"; import { REPORTS_ROOT, resolveInRoots } from "../paths.mjs"; import { walkProjects } from "../projects/walk.mjs"; -import { DEFAULT_VARIANT, cachedWindowsFor } from "umtool-report-to-video/build-video"; +import { DEFAULT_VARIANT, WIN_EPS } from "umtool-report-to-video/build-video"; +import { rawCacheOf } from "./raw-cache.mjs"; import { clipsOf, readManifest } from "../projects/report.mjs"; export async function resolveClip(projectId, clipId) { @@ -23,8 +24,8 @@ export async function resolveClip(projectId, clipId) { return { project, manifest, clip }; } -/** build-video's own tolerance for "this file holds that window". */ -const WIN_EPS = 0.02; +/** The clips-raw cache, re-exported so `serve.mjs` stays the bench's one door. */ +export { rawCacheOf } from "./raw-cache.mjs"; /** * The cached source windows for a clip: the ones that hold ITS window, widest @@ -43,9 +44,11 @@ const WIN_EPS = 0.02; * wrong seconds. A caller with no window (a ledger claim asking for the raw * files of its video) still gets every file, widest first. */ -export async function windowsFor(project, clip) { - const rawDir = path.join(project.dir, "out", "clips-raw"); - const all = await cachedWindowsFor(rawDir, clip.video); +export async function windowsFor(project, clip, cache = null) { + // The cache is optional and passed in by callers that already have one (the + // bench page asks for every clip), so the directory is read once per request. + const c = cache ?? (await rawCacheOf(project.dir)); + const all = c.windows(clip.video); const width = (w) => w.to - w.from; const { start, end } = clip; if (!Number.isFinite(start) || !Number.isFinite(end)) {