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:
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;