Archilyzer · Source

archilyzer

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

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:
Acommon/lib/cueWiden.mjs | 99+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcommon/package.json | 1+
Mumtool/report-to-video/resolve-windows.mjs | 70+++++-----------------------------------------------------------------
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.