commit fb8c3674cae5a4a0e4f14a74e683b6192bb5b20c
parent 721530d506ba681198278d641dc774b0ded3ce81
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date: Mon, 5 Oct 2026 03:25:00 -0400
common: sentence widening moves to lib/cueWiden.mjs; resolve-windows re-exports it
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Diffstat:
3 files changed, 105 insertions(+), 65 deletions(-)
diff --git a/common/lib/cueWiden.mjs b/common/lib/cueWiden.mjs
@@ -0,0 +1,99 @@
+// cueWiden.mjs — widen a span of a record from cue edges to whole sentences.
+//
+// A span starts life as the cue span covering a quote, and a cue boundary is a
+// bad place to cut: ASR breaks cues where the caption line wrapped, which is
+// routinely mid-sentence and often mid-word. `widen` walks outward from the
+// span to the nearest sentence boundary in the cues — a cue whose text ends in
+// . ? or ! — so the span carries the whole thought.
+//
+// Two consumers ask it and must get the same answer:
+// - umtool's report-to-video (`resolve-windows.mjs`, which re-exports it and
+// widens a manifest's clip windows, and `cutToQuote`);
+// - the report converters (lib/report/convert*.ts), which widen a citation
+// that carries one second into a span.
+// So it lives HERE, once. Plain ESM with no imports: umtool's scripts run
+// under bare node, which cannot load a `.ts` file.
+
+/** @typedef {{ start: number, end: number, text: string }} Cue */
+
+const ENDS_SENTENCE = /[.!?]["'”’)\]]*\s*$/;
+
+// A cue that is only "[music]" or "[ __ ]" (the profanity bleep) carries no
+// sentence signal; treat it as transparent so expansion walks past it.
+const IS_FILLER = /^\s*(\[[^\]]*\]|>>|♪|—|-)*\s*$/;
+
+// A manifest stores times rounded to 2 dp, so a value read back from it can sit
+// a hair BELOW the cue end it came from. Without a tolerance the end lookup then
+// lands on the previous cue, the forward search runs on to the next sentence, and
+// the span grows a little every time this is run — it has to be a fixed point.
+export const CUE_EPS = 0.02;
+
+/**
+ * First cue whose span contains t, else the nearest one on the right side.
+ * @param {readonly Cue[]} cues
+ * @param {number} t
+ * @param {"start" | "end"} which
+ */
+function indexAt(cues, t, which) {
+ let idx = cues.findIndex((c) => c.end > t);
+ if (idx < 0) idx = cues.length - 1;
+ if (which === "end") {
+ let j = cues.findIndex((c) => c.end >= t - CUE_EPS);
+ if (j < 0) j = cues.length - 1;
+ idx = j;
+ }
+ return idx;
+}
+
+/**
+ * The span widened to sentence edges, within the lead budget at the start; the
+ * end looks at most `maxTail` past the span and is never clamped short of a
+ * sentence it found. `cues` is sorted by start and not empty.
+ *
+ * @param {readonly Cue[]} cues
+ * @param {number} start
+ * @param {number} end
+ * @param {{ maxLead?: number, maxTail?: number }} [opts]
+ * @returns {{ start: number, end: number, leadCues: number, tailCues: number }}
+ */
+export function widen(cues, start, end, { maxLead = 8, maxTail = 12 } = {}) {
+ /** @param {Cue} c */
+ const isBoundary = (c) => ENDS_SENTENCE.test(c.text) && !IS_FILLER.test(c.text);
+ const i0 = indexAt(cues, start, "start");
+ const i1 = indexAt(cues, end, "end");
+
+ // START: the latest cue that OPENS a sentence (i.e. its predecessor closes
+ // one) at or before the quote, within the lead budget. Finding no such cue
+ // means every candidate lead-in is a sentence fragment, so take none at all —
+ // a fragment is the irrelevant context we are trying to avoid, not context.
+ let si = null;
+ for (let i = i0; i > 0; i -= 1) {
+ if (start - cues[i].start > maxLead) break;
+ if (isBoundary(cues[i - 1])) {
+ si = i;
+ break;
+ }
+ }
+ if (si === null) si = i0;
+
+ // END: the first cue that CLOSES a sentence at or after the quote. Never
+ // clamp to a budget here — stopping partway through a sentence is exactly the
+ // mid-thought ending this is meant to remove, so the budget only decides how
+ // far to look, and failing to find one falls back to the original cue end.
+ let ei = null;
+ for (let j = i1; j < cues.length; j += 1) {
+ if (cues[j].end - end > maxTail) break;
+ if (isBoundary(cues[j])) {
+ ei = j;
+ break;
+ }
+ }
+ if (ei === null) ei = i1;
+
+ return {
+ start: cues[si].start,
+ end: cues[ei].end,
+ leadCues: i0 - si,
+ tailCues: ei - i1,
+ };
+}
diff --git a/common/package.json b/common/package.json
@@ -31,6 +31,7 @@
"./components/urlState": "./components/urlState.ts",
"./components/virtualizer": "./components/virtualizer.ts",
"./components/*": "./components/*.tsx",
+ "./lib/cueWiden.mjs": "./lib/cueWiden.mjs",
"./lib/detectPlatform.mjs": "./lib/detectPlatform.mjs",
"./lib/evidenceClip.mjs": "./lib/evidenceClip.mjs",
"./lib/ports.mjs": "./lib/ports.mjs",
diff --git a/umtool/report-to-video/resolve-windows.mjs b/umtool/report-to-video/resolve-windows.mjs
@@ -51,72 +51,12 @@ import path from "node:path";
import { createCueSource, siteOriginFromManifest } from "./cues.mjs";
import { clipLabel, createLocalMedia, hasLocalMedia } from "./local-media.mjs";
+// THE SENTENCE WIDENING IS COMMON'S (common/lib/cueWiden.mjs): one copy, shared
+// with the report converters, which widen a citation's one second into a span.
+// This file re-exports it.
+import { CUE_EPS as EPS, widen } from "yt-dlp-transcript-common/lib/cueWiden.mjs";
-const ENDS_SENTENCE = /[.!?]["'”’)\]]*\s*$/;
-
-// A cue that is only "[music]" or "[ __ ]" (the profanity bleep) carries no
-// sentence signal; treat it as transparent so expansion walks past it.
-const IS_FILLER = /^\s*(\[[^\]]*\]|>>|♪|—|-)*\s*$/;
-
-// The manifest stores times rounded to 2 dp, so a value read back from it can sit
-// a hair BELOW the cue end it came from. Without a tolerance the end lookup then
-// lands on the previous cue, the forward search runs on to the next sentence, and
-// the clip grows a little every time this is run — it has to be a fixed point.
-const EPS = 0.02;
-
-
-function indexAt(cues, t, which) {
- // First cue whose span contains t, else the nearest one on the right side.
- let idx = cues.findIndex((c) => c.end > t);
- if (idx < 0) idx = cues.length - 1;
- if (which === "end") {
- let j = cues.findIndex((c) => c.end >= t - EPS);
- if (j < 0) j = cues.length - 1;
- idx = j;
- }
- return idx;
-}
-
-export function widen(cues, start, end, { maxLead = 8, maxTail = 12 } = {}) {
- const isBoundary = (c) => ENDS_SENTENCE.test(c.text) && !IS_FILLER.test(c.text);
- const i0 = indexAt(cues, start, "start");
- const i1 = indexAt(cues, end, "end");
-
- // START: the latest cue that OPENS a sentence (i.e. its predecessor closes
- // one) at or before the quote, within the lead budget. Finding no such cue
- // means every candidate lead-in is a sentence fragment, so take none at all —
- // a fragment is the irrelevant context we are trying to avoid, not context.
- let si = null;
- for (let i = i0; i > 0; i -= 1) {
- if (start - cues[i].start > maxLead) break;
- if (isBoundary(cues[i - 1])) {
- si = i;
- break;
- }
- }
- if (si === null) si = i0;
-
- // END: the first cue that CLOSES a sentence at or after the quote. Never
- // clamp to a budget here — stopping partway through a sentence is exactly the
- // mid-thought ending this is meant to remove, so the budget only decides how
- // far to look, and failing to find one falls back to the original cue end.
- let ei = null;
- for (let j = i1; j < cues.length; j += 1) {
- if (cues[j].end - end > maxTail) break;
- if (isBoundary(cues[j])) {
- ei = j;
- break;
- }
- }
- if (ei === null) ei = i1;
-
- return {
- start: cues[si].start,
- end: cues[ei].end,
- leadCues: i0 - si,
- tailCues: ei - i1,
- };
-}
+export { widen };
// ---------------------------------------------------------------------------
// THE CUT INSIDE THE EXTENT.