Archilyzer · Source

archilyzer

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

commit 1aa818bb580cd8aff4a6f5e6a2ce6b8ead176a30
parent 3c3eb5950a3b6a90d75c7d2909ef141cbb5ffac2
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date:   Sat, 19 Sep 2026 15:39:49 -0400

cut-to-quote: the cite comes with the cut

`cite` is the second the burned-in header prints and the second the card's
link points at. It starts life as the floor of `start`; a cut is a window
INSIDE the extent, so as soon as the quote's own sentence starts later than
`start`, the header names a second the clip no longer plays. build-video said
so on 11 of the 12 clips in the lawyer draft ("cite N is outside the cut …
kept as written") and it was right to: a renderer cannot move a citation on
its own.

The pass that MOVED the edge can. `--cut-to-quote --write` now clamps the
cite to a whole second inside the cut it just derived — and does the same for
a manifest whose cuts are already written, which needs no cues, no network
and no --force-cut.

The build's check is left exactly as it is: "kept as written" is the correct
behaviour for a renderer, and it is now a real warning about a real manifest
rather than noise on every clip.

`citeUrl` is a different promise. An explicit one may deliberately cite
ANOTHER recording of the same moment (a mirror that reads better), whose
clock is not this one's — so `t=` is only re-pointed when the URL names this
very channel/video, i.e. when it is the derived URL written down. The edit is
a targeted replace rather than URLSearchParams.set, which would re-escape
every other parameter for nothing.

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

Diffstat:
Mumtool/report-to-video/cut-to-quote.test.mjs | 64+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
Mumtool/report-to-video/resolve-windows.mjs | 98++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---
2 files changed, 158 insertions(+), 4 deletions(-)

diff --git a/umtool/report-to-video/cut-to-quote.test.mjs b/umtool/report-to-video/cut-to-quote.test.mjs @@ -9,7 +9,7 @@ import assert from "node:assert/strict"; import test from "node:test"; -import { cutToQuote, quoteFragments, normaliseWords } from "./resolve-windows.mjs"; +import { citeForCut, cutToQuote, quoteFragments, normaliseWords } from "./resolve-windows.mjs"; /** A cue list at one second per cue, which makes every expectation exact. */ const cues = [ @@ -68,3 +68,65 @@ test("normalisation drops case, punctuation and brackets", () => { assert.equal(normaliseWords("So, [Speaker] HERE -- is it?"), "so here is it"); assert.deepEqual(quoteFragments("one two ... three"), [["one", "two"], ["three"]]); }); + +// ---- the cite that comes with the cut ------------------------------------ +// A cut is a window inside the extent, so the cite that was the floor of +// `start` routinely ends up before the first frame that plays -- which is the +// "cite N is outside the cut" the build used to print on nearly every clip. + +const entryOf = (o) => ({ video: "vid1", channel: "chan", start: 100.4, end: 160, ...o }); + +test("a cite before the cut moves to the first whole second inside it", () => { + const r = citeForCut(entryOf({ cite: 100 }), { cutStart: 138.2, cutEnd: 157.9 }); + assert.deepEqual(r, { cite: 139 }); +}); + +test("the floor of `start` counts as outside a cut that begins at `start`", () => { + // start 100.4 -> cite 100, cutStart 100.4: off by the rounding alone, and the + // build flagged it just the same. + const r = citeForCut(entryOf({ cite: 100 }), { cutStart: 100.4, cutEnd: 120 }); + assert.deepEqual(r, { cite: 101 }); +}); + +test("a cite already inside the cut is left alone", () => { + assert.equal(citeForCut(entryOf({ cite: 140 }), { cutStart: 138.2, cutEnd: 157.9 }), null); +}); + +test("no cite at all falls back to `start`, and moves with it", () => { + assert.deepEqual(citeForCut(entryOf({}), { cutStart: 138.2, cutEnd: 157.9 }), { cite: 139 }); + assert.equal(citeForCut(entryOf({ start: 140 }), { cutStart: 138.2, cutEnd: 157.9 }), null); +}); + +test("a cite after the cut comes back to the last whole second inside it", () => { + assert.deepEqual(citeForCut(entryOf({ cite: 159 }), { cutStart: 138.2, cutEnd: 157.9 }), { + cite: 157, + }); +}); + +test("a cut straddling no whole second moves nothing", () => { + assert.equal(citeForCut(entryOf({ cite: 100 }), { cutStart: 138.2, cutEnd: 138.9 }), null); +}); + +test("the derived citeUrl follows the cite", () => { + const e = entryOf({ + cite: 100, + citeUrl: "https://anilyzer.pages.dev/?v=chan%2Fvid1&t=100", + }); + assert.deepEqual(citeForCut(e, { cutStart: 138.2, cutEnd: 157.9 }), { + cite: 139, + citeUrl: "https://anilyzer.pages.dev/?v=chan%2Fvid1&t=139", + }); +}); + +test("the channel may come from the manifest rather than the entry", () => { + const e = { video: "vid1", start: 100.4, end: 160, cite: 100, citeUrl: "https://x/?v=chan%2Fvid1&t=100" }; + const r = citeForCut(e, { cutStart: 138.2, cutEnd: 157.9 }, { channelSlug: "chan" }); + assert.equal(r.citeUrl, "https://x/?v=chan%2Fvid1&t=139"); +}); + +test("a citeUrl naming ANOTHER recording is not ours to move", () => { + // A mirror whose archived transcript reads better is a different clock: the + // cite is this recording's second, the URL is a promise about that one. + const e = entryOf({ cite: 100, citeUrl: "https://anilyzer.pages.dev/?v=other%2Fvid9&t=100" }); + assert.deepEqual(citeForCut(e, { cutStart: 138.2, cutEnd: 157.9 }), { cite: 139 }); +}); diff --git a/umtool/report-to-video/resolve-windows.mjs b/umtool/report-to-video/resolve-windows.mjs @@ -22,6 +22,9 @@ // clip's `cutStart`/`cutEnd` -- the tight cut the video // renders -- from where its `quote` actually is inside its // window. See "extent vs cut" in docs/report-video.md. +// With --write it also carries the clip's `cite` forward into +// the cut it just derived, so the header prints a second the +// clip actually plays (and its derived citeUrl follows). // --min-match <f> Reject a cut whose quote is less than this well matched // (default 0.6). Below it the clip is reported UNMATCHED and // nothing is written for it. @@ -238,6 +241,63 @@ export function cutToQuote(cues, quote, extent, { minMatch = 0.6, maxLead = 8, m } /** + * Where the cite second has to move to once a cut is derived. + * + * `cite` is the second the burned-in header PRINTS (attribution.mjs) and the + * second the card's link points at (render-cards.mjs derives `t=` from it). It + * starts life as the floor of `start`, which is a second inside the extent -- + * and a cut is a window INSIDE that extent, so the moment the quote's own + * sentence starts later than `start`, the header names a second the clip no + * longer plays. build-video.mjs says so on every such clip ("cite N is outside + * the cut … kept as written"), and it is right to: it cannot move a citation + * on its own. + * + * Deriving the cut is exactly the moment when moving it IS authorised -- this + * pass is the thing that decided the clip would start later -- so it carries + * the cite along with the edge it just moved. Clamped to whole seconds inside + * the cut, because `t=` is a whole second and a header prints h:mm:ss. + * + * `citeUrl` is a different promise. An explicit one may deliberately point at + * ANOTHER recording of the same moment (a mirror that reads better), whose + * clock is not this one's -- so it is only re-pointed when it names this very + * channel/video, i.e. when it is the derived URL written down. Anything else is + * left exactly as the author wrote it. + * + * @returns {{cite:number, citeUrl?:string}|null} what to write, or null for + * "the cite is already inside the cut" / "no whole second is". + */ +export function citeForCut(entry, cut, { channelSlug } = {}) { + const lo = Math.ceil(cut.cutStart); + const hi = Math.floor(cut.cutEnd); + // A sub-second cut can straddle no whole second at all. Moving the cite to a + // fraction would be a worse citation than leaving it where it is. + if (lo > hi) return null; + const at = entry.cite ?? entry.start; + if (at >= lo && at <= hi) return null; + const cite = at < lo ? lo : hi; + const url = retargetCiteUrl(entry, cite, channelSlug); + return url ? { cite, citeUrl: url } : { cite }; +} + +/** The derived-URL test: same recording, so the same clock, so ours to move. */ +function retargetCiteUrl(entry, cite, channelSlug) { + if (!entry.citeUrl) return null; + let u; + try { + u = new URL(entry.citeUrl); + } catch { + return null; + } + if (u.searchParams.get("v") !== `${entry.channel ?? channelSlug}/${entry.video}`) return null; + if (!u.searchParams.has("t")) return null; + // A targeted edit rather than URLSearchParams.set + toString(): re-serialising + // rewrites the escaping of every other parameter (`/` -> `%2F`, ` ` -> `+`), + // which would churn the manifest for nothing. + const next = String(entry.citeUrl).replace(/([?&]t=)[0-9.]+/, `$1${cite}`); + return next === entry.citeUrl ? null : next; +} + +/** * The `--cut-to-quote` pass: derive every clip's cut, print it, maybe write it. * * Deliberately NOT part of the widening run. Widening moves the extent, and the @@ -248,6 +308,7 @@ export function cutToQuote(cues, quote, extent, { minMatch = 0.6, maxLead = 8, m async function cutPass(manifest, { loadCues, slug, opts, write, force }) { let changed = 0; let unmatched = 0; + let cites = 0; for (const e of manifest.timeline) { if (e.type !== "clip") continue; const id = String(e.id).padEnd(4); @@ -257,6 +318,20 @@ async function cutPass(manifest, { loadCues, slug, opts, write, force }) { } if (!force && (e.cutStart != null || e.cutEnd != null)) { console.log(`${id} ${String(e.video).padEnd(12)} already cut ${e.cutStart}–${e.cutEnd} (--force-cut to redo)`); + // The cut stands, but a cite left behind by an EARLIER run of this pass + // still names a second the clip does not play. Aligning it needs no + // cues, no network and no re-derivation, so it is not worth a --force. + if (Number.isFinite(e.cutStart) && Number.isFinite(e.cutEnd)) { + const late = citeForCut(e, e, { channelSlug: slug }); + if (late) { + console.log(` cite ${e.cite ?? e.start} -> ${late.cite}${late.citeUrl ? " (and citeUrl)" : ""}`); + cites += 1; + if (write) { + e.cite = late.cite; + if (late.citeUrl) e.citeUrl = late.citeUrl; + } + } + } continue; } const chan = e.channel ?? slug; @@ -276,13 +351,26 @@ async function cutPass(manifest, { loadCues, slug, opts, write, force }) { `(${(r.cutEnd - r.cutStart).toFixed(1)}s, match ${r.score.toFixed(2)})`, ); console.log(` ${r.matched.slice(0, 120)}`); + // The cite rides along with the edge that just moved -- see citeForCut(). + const moved = citeForCut(e, r, { channelSlug: slug }); + if (moved) { + cites += 1; + console.log( + ` cite ${e.cite ?? e.start} -> ${moved.cite}` + + (moved.citeUrl ? " (and citeUrl)" : e.citeUrl ? " (citeUrl left: another recording)" : ""), + ); + } if (write) { e.cutStart = r.cutStart; e.cutEnd = r.cutEnd; + if (moved) { + e.cite = moved.cite; + if (moved.citeUrl) e.citeUrl = moved.citeUrl; + } } changed += 1; } - return { changed, unmatched }; + return { changed, unmatched, cites }; } async function main() { @@ -331,10 +419,14 @@ async function main() { }); if (argv.includes("--write")) { await writeFile(manifestPath, JSON.stringify(manifest, null, 2) + "\n", "utf8"); - console.log(`\nwrote ${manifestPath} (${r.changed} cut(s) set, ${r.unmatched} unmatched)`); + console.log( + `\nwrote ${manifestPath} (${r.changed} cut(s) set, ${r.cites} cite(s) moved into the cut, ` + + `${r.unmatched} unmatched)`, + ); } else { console.log( - `\ndry run — ${r.changed} cut(s) would be set, ${r.unmatched} unmatched; pass --write to apply`, + `\ndry run — ${r.changed} cut(s) would be set, ${r.cites} cite(s) would move into the cut, ` + + `${r.unmatched} unmatched; pass --write to apply`, ); } return;