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:
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)) {