// 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, }; }