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:
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"),