diff --git a/components/ExportDialog.tsx b/components/ExportDialog.tsx index 27eaf68..7023f95 100644 --- a/components/ExportDialog.tsx +++ b/components/ExportDialog.tsx @@ -42,6 +42,8 @@ type ExportTab = "video" | "audio" | "transcript" | "timeline"; /** Document formats that support an optional timestamps toggle. */ const DOC_FORMATS = new Set(["txt", "md", "docx", "pdf"]); +/** Caption formats that support the short-cues toggle. */ +const SUBTITLE_FORMATS = new Set(["srt", "vtt"]); const VIDEO_FORMATS: { value: VideoExportFormat; label: string }[] = [ { value: "mp4", label: "MP4" }, @@ -110,6 +112,7 @@ export default function ExportDialog() { const [audioFormat, setAudioFormat] = useState("m4a"); const [textFormat, setTextFormat] = useState("txt"); const [includeTimestamps, setIncludeTimestamps] = useState(false); + const [shortCues, setShortCues] = useState(true); const [timelineFormat, setTimelineFormat] = useState("resolve"); const [timelineFrameRate, setTimelineFrameRate] = @@ -269,6 +272,7 @@ export default function ExportDialog() { ]); const textSupportsTimestamps = DOC_FORMATS.has(textFormat); + const textSupportsShortCues = SUBTITLE_FORMATS.has(textFormat); const exportText = useCallback(() => { // Read live store state at click time so a rename / replace-in-project @@ -282,12 +286,14 @@ export default function ExportDialog() { cuts: liveCuts, speakers: speakersFromWords(s.words, s.speakers), ...(textSupportsTimestamps ? { timestamps: includeTimestamps } : {}), + ...(textSupportsShortCues ? { shortCues } : {}), }); setError(null); trackEvent("export_completed", { kind: "transcript", format: textFormat, ...(textSupportsTimestamps ? { timestamps: includeTimestamps } : {}), + ...(textSupportsShortCues ? { shortCues } : {}), }); } catch (err) { setError(err instanceof Error ? err.message : en["error.export"]); @@ -295,7 +301,9 @@ export default function ExportDialog() { }, [ textFormat, textSupportsTimestamps, + textSupportsShortCues, includeTimestamps, + shortCues, baseName, ]); @@ -506,9 +514,24 @@ export default function ExportDialog() { {t("export.includeTimestamps")} )} + {textSupportsShortCues && ( + + )}

- {textFormat === "srt" || textFormat === "vtt" || textFormat === "json" - ? t("export.subtitlesHelp") + {textFormat === "srt" || textFormat === "vtt" + ? shortCues + ? t("export.subtitlesHelpShortCues") + : t("export.subtitlesHelp") + : textFormat === "json" + ? t("export.subtitlesHelp") : includeTimestamps ? t("export.transcriptHelpTimestamps") : t("export.transcriptHelp")} diff --git a/lib/i18n/messages/de.ts b/lib/i18n/messages/de.ts index 4126a11..593c733 100644 --- a/lib/i18n/messages/de.ts +++ b/lib/i18n/messages/de.ts @@ -140,6 +140,7 @@ export const de: Record = { "export.statCuts": "Schnitte", "export.statEdited": "Bearbeitet", "export.includeTimestamps": "Zeitstempel einbeziehen", + "export.shortCues": "Kurze Untertitel (≤5 s)", "export.transcriptHelp": "Text mit Sprecherlabels und entfernten Schnitten.", "export.transcriptHelpTimestamps": "Text mit Sprecherlabels und entfernten Schnitten. Jeder Abschnitt beginnt mit der Zeit auf der bearbeiteten Timeline.", @@ -147,6 +148,8 @@ export const de: Record = { "PDF nutzt eine lateinische Standardschrift — für volles Unicode (CJK usw.) besser DOCX verwenden.", "export.subtitlesHelp": "SRT und VTT verwenden die bearbeitete Timeline (Schnitte angewendet). JSON behält die vollständige Wortliste für den erneuten Import.", + "export.subtitlesHelpShortCues": + "SRT und VTT verwenden die bearbeitete Timeline (Schnitte angewendet), aufgeteilt in kurze satzweise Untertitel (≤5 s). JSON behält die vollständige Wortliste für den erneuten Import.", "export.encodingHelp": "Neucodierung mit ffmpeg.wasm — längere Dateien dauern eine Weile.", "export.reexport": "Mit neuesten Änderungen erneut exportieren", "export.rendering": "Rendering in deinem Browser…", diff --git a/lib/i18n/messages/en.ts b/lib/i18n/messages/en.ts index 934b216..582a74f 100644 --- a/lib/i18n/messages/en.ts +++ b/lib/i18n/messages/en.ts @@ -141,6 +141,7 @@ export const en = { "export.statCuts": "Cuts", "export.statEdited": "Edited", "export.includeTimestamps": "Include timestamps", + "export.shortCues": "Short cues (≤5s)", "export.transcriptHelp": "Speaker-labeled text with cuts removed.", "export.transcriptHelpTimestamps": "Speaker-labeled text with cuts removed. Each turn starts with its edited-timeline time.", @@ -148,6 +149,8 @@ export const en = { "PDF uses a standard Latin font — prefer DOCX for full Unicode (CJK, etc.).", "export.subtitlesHelp": "SRT and VTT use the edited timeline (cuts applied). JSON keeps the full word list for re-import.", + "export.subtitlesHelpShortCues": + "SRT and VTT use the edited timeline (cuts applied), split into short sentence-sized cues (≤5s). JSON keeps the full word list for re-import.", "export.encodingHelp": "Re-encoding with ffmpeg.wasm — longer files take a while.", "export.reexport": "Re-export with latest edits", "export.rendering": "Rendering in your browser…", diff --git a/lib/i18n/messages/es.ts b/lib/i18n/messages/es.ts index 20d5d1a..bedb5fa 100644 --- a/lib/i18n/messages/es.ts +++ b/lib/i18n/messages/es.ts @@ -140,6 +140,7 @@ export const es: Record = { "export.statCuts": "Cortes", "export.statEdited": "Editado", "export.includeTimestamps": "Incluir marcas de tiempo", + "export.shortCues": "Subtítulos cortos (≤5 s)", "export.transcriptHelp": "Texto con etiquetas de hablante y cortes eliminados.", "export.transcriptHelpTimestamps": "Texto con etiquetas de hablante y cortes eliminados. Cada turno empieza con su tiempo en la línea de tiempo editada.", @@ -147,6 +148,8 @@ export const es: Record = { "PDF usa una fuente latina estándar — preferir DOCX para Unicode completo (CJK, etc.).", "export.subtitlesHelp": "SRT y VTT usan la línea de tiempo editada (con cortes aplicados). JSON conserva la lista completa de palabras para reimportar.", + "export.subtitlesHelpShortCues": + "SRT y VTT usan la línea de tiempo editada (con cortes aplicados), divididos en subtítulos cortos por frase (≤5 s). JSON conserva la lista completa de palabras para reimportar.", "export.encodingHelp": "Recodificando con ffmpeg.wasm — los archivos largos tardan un poco.", "export.reexport": "Reexportar con las últimas ediciones", "export.rendering": "Renderizando en tu navegador…", diff --git a/lib/i18n/messages/fr.ts b/lib/i18n/messages/fr.ts index 3a4a357..40e25f5 100644 --- a/lib/i18n/messages/fr.ts +++ b/lib/i18n/messages/fr.ts @@ -140,6 +140,7 @@ export const fr: Record = { "export.statCuts": "Coupes", "export.statEdited": "Monté", "export.includeTimestamps": "Inclure les horodatages", + "export.shortCues": "Sous-titres courts (≤5 s)", "export.transcriptHelp": "Texte avec étiquettes de locuteur et coupes retirées.", "export.transcriptHelpTimestamps": "Texte avec étiquettes de locuteur et coupes retirées. Chaque tour commence par son temps sur la timeline montée.", @@ -147,6 +148,8 @@ export const fr: Record = { "Le PDF utilise une police latine standard — préférez DOCX pour l’Unicode complet (CJK, etc.).", "export.subtitlesHelp": "SRT et VTT utilisent la timeline montée (coupes appliquées). JSON conserve la liste complète des mots pour une réimportation.", + "export.subtitlesHelpShortCues": + "SRT et VTT utilisent la timeline montée (coupes appliquées), découpés en sous-titres courts par phrase (≤5 s). JSON conserve la liste complète des mots pour une réimportation.", "export.encodingHelp": "Réencodage avec ffmpeg.wasm — les fichiers longs prennent un moment.", "export.reexport": "Réexporter avec les dernières modifications", "export.rendering": "Rendu dans votre navigateur…", diff --git a/lib/i18n/messages/ja.ts b/lib/i18n/messages/ja.ts index 99f78a4..487ae20 100644 --- a/lib/i18n/messages/ja.ts +++ b/lib/i18n/messages/ja.ts @@ -140,6 +140,7 @@ export const ja: Record = { "export.statCuts": "カット", "export.statEdited": "編集後", "export.includeTimestamps": "タイムスタンプを含める", + "export.shortCues": "短い字幕(≤5秒)", "export.transcriptHelp": "話者ラベル付きテキスト。カット部分は除外されます。", "export.transcriptHelpTimestamps": "話者ラベル付きテキスト。カット部分は除外され、各発話は編集後タイムラインの時刻で始まります。", @@ -147,6 +148,8 @@ export const ja: Record = { "PDF はラテン系の標準フォントを使います。CJK など完全な Unicode には DOCX を推奨します。", "export.subtitlesHelp": "SRT と VTT は編集後のタイムライン(カット適用済み)を使用します。JSON は再インポート用に完全な単語リストを保持します。", + "export.subtitlesHelpShortCues": + "SRT と VTT は編集後のタイムライン(カット適用済み)を使い、短い文単位の字幕(≤5秒)に分割します。JSON は再インポート用に完全な単語リストを保持します。", "export.encodingHelp": "ffmpeg.wasm で再エンコード中 — 長いファイルは時間がかかります。", "export.reexport": "最新の編集で再書き出し", "export.rendering": "ブラウザでレンダリング中…", diff --git a/lib/i18n/messages/ko.ts b/lib/i18n/messages/ko.ts index b6f8202..5fae2fe 100644 --- a/lib/i18n/messages/ko.ts +++ b/lib/i18n/messages/ko.ts @@ -140,6 +140,7 @@ export const ko: Record = { "export.statCuts": "컷", "export.statEdited": "편집본", "export.includeTimestamps": "타임스탬프 포함", + "export.shortCues": "짧은 자막 (≤5초)", "export.transcriptHelp": "화자 라벨이 포함된 텍스트이며 컷한 내용은 제거됩니다.", "export.transcriptHelpTimestamps": "화자 라벨이 포함된 텍스트이며 컷한 내용은 제거됩니다. 각 발화는 편집된 타임라인 시각으로 시작합니다.", @@ -147,6 +148,8 @@ export const ko: Record = { "PDF는 표준 라틴 글꼴을 사용합니다. 완전한 유니코드(CJK 등)에는 DOCX를 권장합니다.", "export.subtitlesHelp": "SRT와 VTT는 편집된 타임라인(컷 적용)을 사용합니다. JSON은 다시 가져오기 위해 전체 단어 목록을 유지합니다.", + "export.subtitlesHelpShortCues": + "SRT와 VTT는 편집된 타임라인(컷 적용)을 사용하며 짧은 문장 단위 자막(≤5초)으로 나눕니다. JSON은 다시 가져오기 위해 전체 단어 목록을 유지합니다.", "export.encodingHelp": "ffmpeg.wasm으로 다시 인코딩 중 — 긴 파일은 시간이 걸립니다.", "export.reexport": "최신 편집으로 다시 내보내기", "export.rendering": "브라우저에서 렌더링 중…", diff --git a/lib/i18n/messages/pt.ts b/lib/i18n/messages/pt.ts index 3a183b9..1fa457a 100644 --- a/lib/i18n/messages/pt.ts +++ b/lib/i18n/messages/pt.ts @@ -140,6 +140,7 @@ export const pt: Record = { "export.statCuts": "Cortes", "export.statEdited": "Editado", "export.includeTimestamps": "Incluir marcas de tempo", + "export.shortCues": "Legendas curtas (≤5 s)", "export.transcriptHelp": "Texto com rótulos de falante e cortes removidos.", "export.transcriptHelpTimestamps": "Texto com rótulos de falante e cortes removidos. Cada turno começa com o tempo na linha do tempo editada.", @@ -147,6 +148,8 @@ export const pt: Record = { "PDF usa uma fonte latina padrão — prefira DOCX para Unicode completo (CJK etc.).", "export.subtitlesHelp": "SRT e VTT usam a linha do tempo editada (com cortes aplicados). JSON conserva a lista completa de palavras para reimportar.", + "export.subtitlesHelpShortCues": + "SRT e VTT usam a linha do tempo editada (com cortes aplicados), divididos em legendas curtas por frase (≤5 s). JSON conserva a lista completa de palavras para reimportar.", "export.encodingHelp": "Recodificando com ffmpeg.wasm — arquivos longos demoram um pouco.", "export.reexport": "Reexportar com as últimas edições", "export.rendering": "Renderizando no seu navegador…", diff --git a/lib/i18n/messages/zh-CN.ts b/lib/i18n/messages/zh-CN.ts index e099614..39344b2 100644 --- a/lib/i18n/messages/zh-CN.ts +++ b/lib/i18n/messages/zh-CN.ts @@ -142,6 +142,7 @@ export const zhCN: Record = { "export.statCuts": "剪除", "export.statEdited": "成片", "export.includeTimestamps": "包含时间戳", + "export.shortCues": "短字幕(≤5 秒)", "export.transcriptHelp": "带说话人标签的文本,已移除剪除内容。", "export.transcriptHelpTimestamps": "带说话人标签的文本,已移除剪除内容。每段以成片时间轴上的时间开头。", @@ -149,6 +150,8 @@ export const zhCN: Record = { "PDF 使用标准拉丁字体 — 完整 Unicode(如中日韩)请优先选择 DOCX。", "export.subtitlesHelp": "SRT 和 VTT 使用已应用剪除的成片时间轴;JSON 保留完整词表以便重新导入。", + "export.subtitlesHelpShortCues": + "SRT 和 VTT 使用已应用剪除的成片时间轴,并拆成短句字幕(≤5 秒)。JSON 保留完整词表以便重新导入。", "export.encodingHelp": "使用 ffmpeg.wasm 重新编码,较长文件需要更多时间。", "export.reexport": "按最新编辑重新导出", "export.rendering": "正在浏览器中渲染…", diff --git a/lib/i18n/messages/zh-TW.ts b/lib/i18n/messages/zh-TW.ts index 6b07d89..c94fa01 100644 --- a/lib/i18n/messages/zh-TW.ts +++ b/lib/i18n/messages/zh-TW.ts @@ -140,6 +140,7 @@ export const zhTW: Record = { "export.statCuts": "剪除", "export.statEdited": "成片", "export.includeTimestamps": "包含時間戳", + "export.shortCues": "短字幕(≤5 秒)", "export.transcriptHelp": "帶有說話人標籤的文字,已移除剪除內容。", "export.transcriptHelpTimestamps": "帶有說話人標籤的文字,已移除剪除內容。每段以編輯後時間軸上的時間開頭。", @@ -147,6 +148,8 @@ export const zhTW: Record = { "PDF 使用標準拉丁字型 — 完整 Unicode(如中日韓)請優先選擇 DOCX。", "export.subtitlesHelp": "SRT 和 VTT 使用已套用剪除的編輯後時間軸。JSON 保留完整詞表以便重新匯入。", + "export.subtitlesHelpShortCues": + "SRT 和 VTT 使用已套用剪除的編輯後時間軸,並拆成短句字幕(≤5 秒)。JSON 保留完整詞表以便重新匯入。", "export.encodingHelp": "使用 ffmpeg.wasm 重新編碼 — 較長的檔案需要一些時間。", "export.reexport": "以最新編輯重新匯出", "export.rendering": "正在瀏覽器中算出…", diff --git a/lib/serializeTranscript.ts b/lib/serializeTranscript.ts index 472f597..c65b241 100644 --- a/lib/serializeTranscript.ts +++ b/lib/serializeTranscript.ts @@ -31,6 +31,12 @@ export interface SerializeOptions { speakers?: SpeakerInfo[]; /** Prefix each speaker turn with its start time (document formats only). */ timestamps?: boolean; + /** + * When true (default for SRT/VTT), split captions into short sentence-sized + * cues (≤{@link MAX_CUE_DURATION}s, wrapped lines). When false, only split + * on speaker changes and pause gaps (legacy long cues). + */ + shortCues?: boolean; } interface Cue { @@ -48,6 +54,18 @@ interface DocumentTurn { /** Split a cue when the gap between consecutive words exceeds this (seconds). */ const CUE_GAP = 0.75; +/** + * Max on-screen duration for one caption cue (seconds) when short cues are on. + * Continuous speech with no long pause used to become a single 60s+ cue. + */ +const MAX_CUE_DURATION = 5; +/** + * Soft max characters of dialogue in one cue (≈ two 42-char lines). Keeps + * exported SRT/VTT from dumping a whole paragraph onto the video frame. + */ +const MAX_CUE_CHARS = 84; +/** Preferred line width when wrapping cue text for display. */ +const CUE_LINE_CHARS = 42; const MIME: Record = { srt: "application/x-subrip", @@ -89,10 +107,11 @@ export function serializeTranscript( if (prepared.length === 0) { throw new Error(en["error.noWords"]); } - const cues = wordsToCues(prepared); + const shortCues = options.shortCues !== false; + const cues = wordsToCues(prepared, shortCues); return format === "vtt" - ? serializeVtt(cues, speakers) - : serializeSrt(cues, speakers); + ? serializeVtt(cues, speakers, shortCues) + : serializeSrt(cues, speakers, shortCues); } /** Serialize to DOCX or PDF (binary). */ @@ -301,36 +320,135 @@ function isWordKept(word: Word, cuts: TimeRange[]): boolean { return !cuts.some((c) => mid >= c.start && mid < c.end); } -function wordsToCues(words: Word[]): Cue[] { +/** + * Group timed words into caption cues. + * + * Always splits on speaker change and pause gaps. When `shortCues` is on, also + * splits on sentence-ending punctuation and hard-caps duration / character + * count so continuous speech cannot produce a single oversized on-screen block. + */ +function wordsToCues(words: Word[], shortCues: boolean): Cue[] { const cues: Cue[] = []; let batch: Word[] = []; const flush = () => { if (batch.length === 0) return; - cues.push({ + const cue: Cue = { start: batch[0].start, end: Math.max(batch[batch.length - 1].end, batch[0].start + 0.02), text: batch.map((w) => w.text).join(" "), speaker: batch[0].speaker, - }); + }; batch = []; + + // Absorb tiny trailing fragments (e.g. a lone "exporter.") into the previous + // cue when the previous cue was mid-sentence and the fragment still fits. + const prev = cues[cues.length - 1]; + if ( + shortCues && + prev && + prev.speaker === cue.speaker && + !endsSentence(prev.text) && + cue.start - prev.end <= CUE_GAP && + cue.end - cue.start < 1.25 && + cue.text.length <= 24 && + cue.end - prev.start <= MAX_CUE_DURATION && + wrapCueLines(`${prev.text} ${cue.text}`, CUE_LINE_CHARS).length <= 2 + ) { + prev.end = cue.end; + prev.text = `${prev.text} ${cue.text}`; + return; + } + cues.push(cue); }; - for (const w of words) { - const last = batch[batch.length - 1]; + const wouldOverflow = (next: Word): boolean => { + if (!shortCues || batch.length === 0) return false; + const text = `${batch.map((w) => w.text).join(" ")} ${next.text}`; + const duration = next.end - batch[0].start; + if (duration > MAX_CUE_DURATION) return true; + // Let a short lowercase sentence-final token finish the current cue + // ("… the old" + "exporter.") instead of becoming a one-word orphan. if ( - last && - (w.speaker !== last.speaker || w.start - last.end > CUE_GAP) + endsSentence(next.text) && + next.text.length <= 24 && + !/^\p{Lu}/u.test(next.text) ) { - flush(); + return false; + } + return ( + wrapCueLines(text, CUE_LINE_CHARS).length > 2 || + text.length > MAX_CUE_CHARS + ); + }; + + for (const w of words) { + const last = batch[batch.length - 1]; + if (last) { + const speakerOrGap = + w.speaker !== last.speaker || w.start - last.end > CUE_GAP; + if (speakerOrGap || wouldOverflow(w)) { + flush(); + } } batch.push(w); + // Prefer a new cue after each sentence so timestamps stay line/sentence sized. + if (shortCues && endsSentence(w.text)) flush(); } flush(); return cues; } -function serializeSrt(cues: Cue[], speakers: SpeakerInfo[]): string { +/** True when a word looks like the end of a sentence (ASR usually keeps the mark). */ +function endsSentence(text: string): boolean { + return /[.!?…。?!]["'"”’」』】))\]]*$/u.test(text.trim()); +} + +function formatCueBody( + text: string, + speakerPrefix: string | undefined, + wrap: boolean +): string { + if (!wrap) { + return speakerPrefix ? `${speakerPrefix}${text}` : text; + } + const wrapped = wrapCueLines(text, CUE_LINE_CHARS); + if (!speakerPrefix) return wrapped.join("\n"); + if (wrapped.length === 0) return speakerPrefix.trimEnd(); + return [`${speakerPrefix}${wrapped[0]}`, ...wrapped.slice(1)].join("\n"); +} + +function wrapCueLines(text: string, maxChars: number): string[] { + const words = text.split(/\s+/).filter(Boolean); + if (words.length === 0) return [""]; + const lines: string[] = []; + let current = ""; + for (const word of words) { + const next = current ? `${current} ${word}` : word; + if (next.length <= maxChars) { + current = next; + continue; + } + if (current) lines.push(current); + if (word.length <= maxChars) { + current = word; + } else { + for (let i = 0; i < word.length; i += maxChars) { + const chunk = word.slice(i, i + maxChars); + if (i + maxChars < word.length) lines.push(chunk); + else current = chunk; + } + } + } + if (current) lines.push(current); + return lines; +} + +function serializeSrt( + cues: Cue[], + speakers: SpeakerInfo[], + shortCues: boolean +): string { return ( cues .map((cue, i) => { @@ -339,9 +457,15 @@ function serializeSrt(cues: Cue[], speakers: SpeakerInfo[]): string { `${formatSrtTimestamp(cue.start)} --> ${formatSrtTimestamp(cue.end)}`, ]; if (cue.speaker >= 0) { - lines.push(`${speakerLabel(speakers, cue.speaker)}: ${cue.text}`); + lines.push( + formatCueBody( + cue.text, + `${speakerLabel(speakers, cue.speaker)}: `, + shortCues + ) + ); } else { - lines.push(cue.text); + lines.push(formatCueBody(cue.text, undefined, shortCues)); } return lines.join("\n"); }) @@ -349,14 +473,21 @@ function serializeSrt(cues: Cue[], speakers: SpeakerInfo[]): string { ); } -function serializeVtt(cues: Cue[], speakers: SpeakerInfo[]): string { +function serializeVtt( + cues: Cue[], + speakers: SpeakerInfo[], + shortCues: boolean +): string { const body = cues .map((cue) => { const timing = `${formatVttTimestamp(cue.start)} --> ${formatVttTimestamp(cue.end)}`; + const dialogue = shortCues + ? wrapCueLines(cue.text, CUE_LINE_CHARS).join("\n") + : cue.text; const text = cue.speaker >= 0 - ? `${cue.text}` - : cue.text; + ? `${dialogue}` + : dialogue; return `${timing}\n${text}`; }) .join("\n\n"); diff --git a/tests/serialize-transcript-test.ts b/tests/serialize-transcript-test.ts index 65b522f..ed73b27 100644 --- a/tests/serialize-transcript-test.ts +++ b/tests/serialize-transcript-test.ts @@ -308,4 +308,131 @@ const sample: Word[] = [ console.log("manual cuts: ok"); } +{ + // Sentence-ending punctuation starts a new cue even when the pause is short. + const words: Word[] = [ + { id: 0, text: "Hello", start: 0, end: 0.4, speaker: 0, deleted: false }, + { id: 1, text: "world.", start: 0.4, end: 0.8, speaker: 0, deleted: false }, + { id: 2, text: "How", start: 0.9, end: 1.1, speaker: 0, deleted: false }, + { id: 3, text: "are", start: 1.1, end: 1.3, speaker: 0, deleted: false }, + { id: 4, text: "you?", start: 1.3, end: 1.6, speaker: 0, deleted: false }, + ]; + const srt = serializeTranscript(words, "srt", { + editedTimeline: false, + duration: 5, + }); + assert(srt.includes("Speaker 1: Hello world."), `sentence cue 1\n${srt}`); + assert(srt.includes("Speaker 1: How are you?"), `sentence cue 2\n${srt}`); + assert(srt.includes("1\n") && srt.includes("2\n"), `two cues\n${srt}`); + console.log("sentence cue split: ok"); +} + +{ + // Continuous speech without long pauses must not become one huge cue. + const words: Word[] = []; + for (let i = 0; i < 80; i++) { + const start = i * 0.4; + words.push({ + id: i, + text: `w${i}`, + start, + end: start + 0.35, + speaker: 0, + deleted: false, + }); + } + const srt = serializeTranscript(words, "srt", { + editedTimeline: false, + duration: 40, + }); + const blocks = srt.trim().split(/\n\n+/); + assert(blocks.length >= 4, `expected several cues, got ${blocks.length}\n${srt}`); + for (const block of blocks) { + const match = block.match( + /(\d{2}):(\d{2}):(\d{2}),(\d{3}) --> (\d{2}):(\d{2}):(\d{2}),(\d{3})/ + ); + assert(match !== null, `missing timing in cue\n${block}`); + const toSec = (h: string, m: string, s: string, ms: string) => + Number(h) * 3600 + Number(m) * 60 + Number(s) + Number(ms) / 1000; + const dur = + toSec(match![5], match![6], match![7], match![8]) - + toSec(match![1], match![2], match![3], match![4]); + assert(dur <= 5.05, `cue longer than 5s (${dur})\n${block}`); + } + console.log("max cue duration: ok"); +} + +{ + // shortCues: false keeps legacy speaker/gap-only splitting (long cues OK). + const words: Word[] = []; + for (let i = 0; i < 40; i++) { + const start = i * 0.4; + words.push({ + id: i, + text: `w${i}`, + start, + end: start + 0.35, + speaker: 0, + deleted: false, + }); + } + const srt = serializeTranscript(words, "srt", { + editedTimeline: false, + duration: 20, + shortCues: false, + }); + const blocks = srt.trim().split(/\n\n+/); + assert(blocks.length === 1, `legacy mode should be one cue, got ${blocks.length}\n${srt}`); + assert(srt.includes("w0 w1"), "legacy keeps continuous text"); + assert(!srt.includes("\nw0 w1\n"), "legacy does not wrap body lines"); + console.log("shortCues off: ok"); +} + +{ + // Character cap + line wrap: a long run without punctuation still yields short cues. + const tokens = [ + "The", + "quick", + "brown", + "fox", + "jumps", + "over", + "the", + "lazy", + "dog", + "again", + "today", + "while", + "everyone", + "watches", + "quietly", + ]; + const words: Word[] = tokens.map((text, i) => ({ + id: i, + text, + start: i * 0.25, + end: i * 0.25 + 0.2, + speaker: 0, + deleted: false, + })); + const srt = serializeTranscript(words, "srt", { + editedTimeline: false, + duration: 10, + }); + const blocks = srt.trim().split(/\n\n+/); + assert(blocks.length >= 2, `char cap should split cues\n${srt}`); + for (const block of blocks) { + const body = block.split("\n").slice(2).join("\n"); + // Strip speaker prefix on the first body line for the length check. + const dialogue = body.replace(/^Speaker \d+: /, "").replace(/\n/g, " "); + assert( + dialogue.length <= 90, + `cue dialogue too long (${dialogue.length})\n${block}` + ); + const bodyLines = block.split("\n").slice(2); + assert(bodyLines.length <= 3, `cue should wrap to ≤3 lines\n${block}`); + } + console.log("max cue chars: ok"); +} + console.log("ALL SERIALIZE TRANSCRIPT TESTS PASSED");