295 lines
12 KiB
TypeScript
295 lines
12 KiB
TypeScript
/**
|
||
* Subtitle cue splitting — long narration → short, readable cues.
|
||
*
|
||
* The timeline pass emits one {@link SubtitleCue} per non-empty `speech` action,
|
||
* whose text is the *whole* narration paragraph. Burned into the video that is a
|
||
* 4–6 line block that covers the lower half of the slide; as a sidecar SRT/VTT
|
||
* it is one cue that lingers for the whole paragraph. This module splits each cue
|
||
* into short pieces (1–2 lines) and distributes the parent's time window across
|
||
* them, so both the burned-in overlay and the exported SRT/VTT read line-by-line.
|
||
*
|
||
* Timing is by **character weight**, not a per-word timestamp: the TTS pipeline
|
||
* only stores a whole-clip duration (`AudioFileRecord.duration`), so there is no
|
||
* finer alignment to use. Narration pace is roughly uniform, so a piece's share
|
||
* of the window ∝ its share of the (weighted) characters keeps audio and text in
|
||
* sync to within a few hundred ms — and the burned-in overlay and the sidecar
|
||
* file use the exact same cues, so they can never drift from each other.
|
||
*
|
||
* Pure: depends only on the IR types. No `@/`, no DOM — inside the
|
||
* `lib/video-export/**` purity boundary.
|
||
*/
|
||
import type { SubtitleCue } from './ir';
|
||
|
||
/**
|
||
* Readability budget per cue, in "CJK-equivalent" units (a wide CJK glyph is 1,
|
||
* a Latin/ASCII char ~0.5). ~40 units ≈ two comfortable lines of a bottom
|
||
* caption band at the default font size.
|
||
*/
|
||
const MAX_UNITS = 40;
|
||
|
||
/** A piece shorter than this (ms) is merged into a neighbour so it doesn't flash. */
|
||
const MIN_CUE_MS = 1200;
|
||
|
||
/**
|
||
* Clause punctuation used to break an *over-budget* sentence into cue-sized
|
||
* pieces, kept with the preceding clause. Includes both semicolons (`;;`),
|
||
* which {@link splitSentences} intentionally does not treat as sentence ends
|
||
* (they join clauses, not sentences), so a long semicolon-joined sentence still
|
||
* breaks here rather than overflowing the readability budget.
|
||
*/
|
||
const SECONDARY = /([,、,::;;—])/;
|
||
|
||
/**
|
||
* Locale-agnostic ICU sentence segmenter, constructed once. Its Unicode
|
||
* sentence-boundary rules already preserve decimals (`3.14`), versions (`v1.2`),
|
||
* acronyms (`U.S.`), inline abbreviations (`e.g.`), ellipses, and CJK terminators
|
||
* (`。!?…`) — so the boundary logic no longer needs a hand-rolled punctuation
|
||
* heuristic. See {@link splitSentences} for the one gap it leaves.
|
||
*/
|
||
const SENTENCE_SEGMENTER = new Intl.Segmenter(undefined, { granularity: 'sentence' });
|
||
|
||
/**
|
||
* Titles that take a following proper noun in the *same* sentence — `Dr. Smith`,
|
||
* `Prof. Wang`, `Mr. Lee`. ICU ends a sentence at their trailing dot when a
|
||
* capitalized word follows, so {@link splitSentences} re-joins that one segment.
|
||
*
|
||
* Deliberately narrow: only titles, not sentence-*trailing* abbreviations like
|
||
* `Inc.`, `Ltd.`, `etc.`, `vs.`, or `No.`. Those legitimately end sentences
|
||
* (`…made by Acme Inc. Next point.`), and a capitalized word after them is
|
||
* usually a new sentence — merging them would delete real boundaries. Titles
|
||
* before a *number* (`No. 5`, `Fig. 3`) or a *lowercase* word (`etc. and…`) are
|
||
* already kept whole by ICU and need no merge. Lower-cased, no trailing dot.
|
||
*/
|
||
const CONTINUING_TITLES = new Set(['mr', 'mrs', 'ms', 'dr', 'prof', 'sr', 'jr']);
|
||
|
||
/** The previous segment ends in a title abbreviation whose sentence continues (`… Dr.`). */
|
||
function endsWithContinuingTitle(text: string): boolean {
|
||
const m = /(\p{L}+)\.\s*$/u.exec(text);
|
||
return !!m && CONTINUING_TITLES.has(m[1].toLowerCase());
|
||
}
|
||
|
||
/** True for a wide (CJK / full-width) code point that occupies a full cell. */
|
||
function isWide(ch: string): boolean {
|
||
const c = ch.codePointAt(0) ?? 0;
|
||
return (
|
||
(c >= 0x1100 && c <= 0x115f) || // Hangul Jamo
|
||
(c >= 0x2e80 && c <= 0x9fff) || // CJK radicals … unified ideographs
|
||
(c >= 0xa000 && c <= 0xa4cf) || // Yi
|
||
(c >= 0xac00 && c <= 0xd7a3) || // Hangul syllables
|
||
(c >= 0xf900 && c <= 0xfaff) || // CJK compatibility ideographs
|
||
(c >= 0xfe30 && c <= 0xfe4f) || // CJK compatibility forms
|
||
(c >= 0xff00 && c <= 0xff60) || // full-width forms
|
||
(c >= 0xffe0 && c <= 0xffe6) ||
|
||
(c >= 0x20000 && c <= 0x3fffd) // CJK ext B+
|
||
);
|
||
}
|
||
|
||
/** Visual width of a string in CJK-equivalent units (wide=1, other=0.5). */
|
||
export function textUnits(text: string): number {
|
||
let units = 0;
|
||
for (const ch of text) units += isWide(ch) ? 1 : 0.5;
|
||
return units;
|
||
}
|
||
|
||
/**
|
||
* Weight used for time allocation: counts glyphs that take reading time (wide=1,
|
||
* other visible=0.5) and ignores whitespace, so a piece's time share tracks how
|
||
* much there is to *read*, not raw length. Always ≥ a small floor so an
|
||
* all-punctuation piece still gets a sliver of time rather than zero.
|
||
*/
|
||
function timeWeight(text: string): number {
|
||
let w = 0;
|
||
for (const ch of text) {
|
||
if (/\s/.test(ch)) continue;
|
||
w += isWide(ch) ? 1 : 0.5;
|
||
}
|
||
return Math.max(w, 0.5);
|
||
}
|
||
|
||
/** Hard-wrap a run with no usable punctuation into ≤ MAX_UNITS chunks. */
|
||
function hardWrap(text: string): string[] {
|
||
const out: string[] = [];
|
||
let buf = '';
|
||
let units = 0;
|
||
for (const ch of text) {
|
||
const u = isWide(ch) ? 1 : 0.5;
|
||
if (units + u > MAX_UNITS && buf) {
|
||
out.push(buf);
|
||
buf = '';
|
||
units = 0;
|
||
}
|
||
buf += ch;
|
||
units += u;
|
||
}
|
||
if (buf) out.push(buf);
|
||
return out;
|
||
}
|
||
|
||
/** Split on a punctuation regex, re-attaching each delimiter to its preceding chunk. */
|
||
function splitKeepingDelimiters(text: string, re: RegExp): string[] {
|
||
const parts = text.split(re);
|
||
const out: string[] = [];
|
||
for (let i = 0; i < parts.length; i += 2) {
|
||
const chunk = (parts[i] ?? '') + (parts[i + 1] ?? '');
|
||
if (chunk) out.push(chunk);
|
||
}
|
||
return out;
|
||
}
|
||
|
||
/**
|
||
* Split text into sentences at genuine boundaries, keeping the terminating
|
||
* punctuation with its sentence.
|
||
*
|
||
* Boundary detection is delegated to ICU via {@link SENTENCE_SEGMENTER}, which
|
||
* handles the multilingual/CJK cases (`。!?…`, decimals, versions, `e.g.`,
|
||
* `U.S.`, ellipses) that a hand-rolled punctuation scan got wrong. ICU leaves one
|
||
* narration gap: it ends a sentence after a title like `Dr.` / `Prof.` when a
|
||
* capitalized word follows (`Dr. Smith` → `Dr.` / `Smith …`). A narrow post-merge
|
||
* re-joins *that* segment — but only when all of these hold, so it never deletes a
|
||
* real boundary:
|
||
* - the previous segment ends in a continuing title ({@link CONTINUING_TITLES}),
|
||
* not a sentence-trailing abbreviation like `Inc.`/`etc.`;
|
||
* - the previous segment does not end at an explicit newline (a hard boundary);
|
||
* - the next segment starts with a capital (the proper noun that ICU mis-split;
|
||
* a lowercase or numeric continuation was never split by ICU in the first place).
|
||
* Blank segments (whitespace between sentences) are dropped.
|
||
*/
|
||
function splitSentences(text: string): string[] {
|
||
const segments = [...SENTENCE_SEGMENTER.segment(text)].map((s) => s.segment);
|
||
const merged: string[] = [];
|
||
for (const seg of segments) {
|
||
const prev = merged[merged.length - 1];
|
||
const continuesTitle =
|
||
prev !== undefined &&
|
||
!/\n\s*$/.test(prev) &&
|
||
/^\s*\p{Lu}/u.test(seg) &&
|
||
endsWithContinuingTitle(prev);
|
||
if (continuesTitle) {
|
||
merged[merged.length - 1] = prev + seg;
|
||
} else {
|
||
merged.push(seg);
|
||
}
|
||
}
|
||
return merged.map((s) => s.trim()).filter(Boolean);
|
||
}
|
||
|
||
/**
|
||
* Split narration text into short pieces (each ≤ ~MAX_UNITS), preferring
|
||
* sentence boundaries, then clause punctuation, then a hard wrap. Whitespace-only
|
||
* input yields no pieces. Adjacent under-budget sentences are greedily packed so
|
||
* a short sentence isn't left alone on its own cue.
|
||
*/
|
||
export function splitCueText(text: string): string[] {
|
||
const trimmed = text.trim();
|
||
if (!trimmed) return [];
|
||
|
||
// 1. Sentences.
|
||
const sentences = splitSentences(trimmed);
|
||
|
||
// 2. Break any over-budget sentence on clause punctuation, then hard-wrap.
|
||
// Sentences within budget stay one-per-cue (line-by-line subtitles); a
|
||
// too-short tail is folded in later by the MIN_CUE_MS timing merge, so no
|
||
// re-packing of full sentences here (that would rebuild the 2-line block).
|
||
const pieces: string[] = [];
|
||
for (const sentence of sentences) {
|
||
if (textUnits(sentence) <= MAX_UNITS) {
|
||
pieces.push(sentence);
|
||
continue;
|
||
}
|
||
const clauses = splitKeepingDelimiters(sentence, SECONDARY)
|
||
.map((s) => s.trim())
|
||
.filter(Boolean);
|
||
for (const clause of clauses) {
|
||
if (textUnits(clause) <= MAX_UNITS) pieces.push(clause);
|
||
else pieces.push(...hardWrap(clause));
|
||
}
|
||
}
|
||
|
||
return pieces;
|
||
}
|
||
|
||
/**
|
||
* Join two adjacent cue-text pieces being merged into one cue. A space is
|
||
* inserted only at a Latin word boundary (both sides narrow); CJK text is not
|
||
* space-separated, so if either side of the seam is a wide glyph the pieces are
|
||
* joined directly — otherwise merging e.g. `我们开始。` + `看这里` would inject a
|
||
* spurious space into the caption.
|
||
*/
|
||
function joinCueText(prev: string, next: string): string {
|
||
const left = [...prev].pop() ?? '';
|
||
const right = next[0] ?? '';
|
||
const gap = left && right && !isWide(left) && !isWide(right) ? ' ' : '';
|
||
return `${prev}${gap}${next}`;
|
||
}
|
||
|
||
/**
|
||
* Split one cue into multiple, distributing its `[startMs, endMs]` window across
|
||
* the text pieces in proportion to their reading weight. The pieces tile the
|
||
* window with no gaps or overlaps; the last piece's `endMs` is pinned to the
|
||
* parent's so integer rounding can't drift the track. Pieces whose allotted span
|
||
* would be shorter than {@link MIN_CUE_MS} are merged forward to avoid flashing.
|
||
*
|
||
* A cue that splits into one piece (or whose window is non-positive) is returned
|
||
* unchanged except for text-trim, so already-short narration is untouched.
|
||
*/
|
||
export function splitCue(cue: SubtitleCue): SubtitleCue[] {
|
||
const pieces = splitCueText(cue.text);
|
||
const windowMs = cue.endMs - cue.startMs;
|
||
if (pieces.length <= 1 || windowMs <= 0) {
|
||
const text = pieces[0] ?? cue.text.trim();
|
||
return [{ ...cue, text }];
|
||
}
|
||
|
||
const weights = pieces.map(timeWeight);
|
||
const totalWeight = weights.reduce((a, b) => a + b, 0);
|
||
|
||
// Cumulative boundaries by weight, rounded to ms; first starts at the parent
|
||
// start, last ends at the parent end.
|
||
const bounds: number[] = [cue.startMs];
|
||
let acc = 0;
|
||
for (let i = 0; i < pieces.length - 1; i++) {
|
||
acc += weights[i];
|
||
bounds.push(cue.startMs + Math.round((windowMs * acc) / totalWeight));
|
||
}
|
||
bounds.push(cue.endMs);
|
||
|
||
const raw: SubtitleCue[] = pieces.map((text, i) => ({
|
||
index: cue.index,
|
||
sceneId: cue.sceneId,
|
||
actionId: cue.actionId,
|
||
startMs: bounds[i],
|
||
endMs: bounds[i + 1],
|
||
text,
|
||
}));
|
||
|
||
// Merge any sub-MIN_CUE_MS piece into the next (or previous, for the last one)
|
||
// so the visible cue never flashes. Boundaries stay contiguous.
|
||
const merged: SubtitleCue[] = [];
|
||
for (const c of raw) {
|
||
const prev = merged[merged.length - 1];
|
||
if (prev && c.endMs - c.startMs < MIN_CUE_MS) {
|
||
merged[merged.length - 1] = { ...prev, endMs: c.endMs, text: joinCueText(prev.text, c.text) };
|
||
} else {
|
||
merged.push(c);
|
||
}
|
||
}
|
||
// If the very first piece was the short one, it may still be < MIN; fold it in.
|
||
if (merged.length > 1 && merged[0].endMs - merged[0].startMs < MIN_CUE_MS) {
|
||
const [first, second, ...rest] = merged;
|
||
return [
|
||
{ ...second, startMs: first.startMs, text: joinCueText(first.text, second.text) },
|
||
...rest,
|
||
];
|
||
}
|
||
return merged;
|
||
}
|
||
|
||
/**
|
||
* Split every cue in a track and renumber `index` 1:1 over the result. The IR's
|
||
* subtitle track is replaced with this so the burned-in overlay and the SRT/VTT
|
||
* serializers all consume the same short cues.
|
||
*/
|
||
export function splitCues(cues: readonly SubtitleCue[]): SubtitleCue[] {
|
||
return cues.flatMap((c) => splitCue(c)).map((c, index) => ({ ...c, index }));
|
||
}
|