feat(captions): segment captions by sentence using punctuation and pauses

This commit is contained in:
Joe Hachem
2026-06-27 19:10:31 +03:00
parent 05072618c4
commit 1f2ddd49d3
6 changed files with 858 additions and 8 deletions
@@ -0,0 +1,41 @@
import { describe, expect, it } from "vitest";
import { flattenCaptionWords } from "./captionLayout";
import type { CaptionCue } from "./types";
describe("flattenCaptionWords", () => {
it("forces a break at every cue boundary so each phrase shows on its own", () => {
const cues: CaptionCue[] = [
{
id: "a",
startMs: 0,
endMs: 1_000,
text: "hello world",
words: [
{ text: "hello", startMs: 0, endMs: 500 },
{ text: "world", startMs: 500, endMs: 1_000, leadingSpace: true },
],
},
{
// back-to-back with cue "a" (no gap) — would previously be re-packed by width
id: "b",
startMs: 1_000,
endMs: 2_000,
text: "next one",
words: [
{ text: "next", startMs: 1_000, endMs: 1_500 },
{ text: "one", startMs: 1_500, endMs: 2_000, leadingSpace: true },
],
},
];
const flattened = flattenCaptionWords(cues);
const firstWordOfSecondCue = flattened.find(
(word) => word.cueId === "b" && word.cueWordIndex === 0,
);
expect(firstWordOfSecondCue?.forcedBreakBefore).toBe(true);
expect(firstWordOfSecondCue?.leadingSpace).toBe(false);
// the very first word of the first cue never forces a break
expect(flattened[0].forcedBreakBefore).toBe(false);
});
});
+4 -2
View File
@@ -179,8 +179,10 @@ export function flattenCaptionWords(cues: CaptionCue[]) {
const cueDuration = Math.max(1, cue.endMs - cue.startMs);
const fallbackWordDuration = cueDuration / sourceWords.length;
const previousCue = cueIndex > 0 ? cues[cueIndex - 1] : null;
const shouldForceCueBreak =
previousCue !== null && cue.startMs - previousCue.endMs >= CAPTION_BLOCK_GAP_BREAK_MS;
// Each cue is its own phrase, so always start a new line/page at a cue boundary.
// Otherwise back-to-back phrases (a small gap) get re-packed together by width and
// their boundary disappears on screen — we want one phrase shown at a time.
const shouldForceCueBreak = previousCue !== null;
sourceWords.forEach((word, wordIndex) => {
const fallbackStartMs = cue.startMs + fallbackWordDuration * wordIndex;