fix(youtube): preserve explicit 3000ms caption durations

- Distinguish generated rolling cues from plain-text cues
- Keep explicit sound-cue spans intact
This commit is contained in:
2026-08-27 23:21:58 -07:00
parent fa73aea2f9
commit d1e356f53f
2 changed files with 15 additions and 7 deletions
+3 -3
View File
@@ -125,11 +125,11 @@ test('convertYoutubeTimedTextToVtt leaves pop-on captions intact', () => {
); );
}); });
test('convertYoutubeTimedTextToVtt keeps explicit sound-cue durations in rolling documents', () => { test('convertYoutubeTimedTextToVtt keeps explicit 3000ms sound-cue durations in rolling documents', () => {
const result = convertYoutubeTimedTextToVtt( const result = convertYoutubeTimedTextToVtt(
[ [
'<timedtext><body>', '<timedtext><body>',
'<p t="20305" d="2020" w="1">[音楽]</p>', '<p t="20305" d="3000" w="1">[音楽]</p>',
'<p t="26269" w="1" a="1">\n</p>', '<p t="26269" w="1" a="1">\n</p>',
'<p t="26279" d="3000" w="1"><s ac="0">じゃあ、君からお願いします。</s></p>', '<p t="26279" d="3000" w="1"><s ac="0">じゃあ、君からお願いします。</s></p>',
'</body></timedtext>', '</body></timedtext>',
@@ -141,7 +141,7 @@ test('convertYoutubeTimedTextToVtt keeps explicit sound-cue durations in rolling
[ [
'WEBVTT', 'WEBVTT',
'', '',
'00:00:20.305 --> 00:00:22.325', '00:00:20.305 --> 00:00:23.305',
'[音楽]', '[音楽]',
'', '',
'00:00:26.279 --> 00:00:29.279', '00:00:26.279 --> 00:00:29.279',
+12 -4
View File
@@ -2,6 +2,7 @@ interface YoutubeTimedTextRow {
startMs: number; startMs: number;
durationMs: number; durationMs: number;
text: string; text: string;
isGenerated: boolean;
rollingWindow: YoutubeRollingWindow | null; rollingWindow: YoutubeRollingWindow | null;
} }
@@ -152,7 +153,8 @@ function extractYoutubeTimedTextDocument(xml: string): YoutubeTimedTextDocument
continue; continue;
} }
const inner = (match[2] ?? '').replace(/<br\s*\/?>/gi, '\n').replace(/<[^>]+>/g, ''); const rawInner = match[2] ?? '';
const inner = rawInner.replace(/<br\s*\/?>/gi, '\n').replace(/<[^>]+>/g, '');
const text = decodeHtmlEntities(inner).trim(); const text = decodeHtmlEntities(inner).trim();
if (!text) { if (!text) {
continue; continue;
@@ -162,6 +164,7 @@ function extractYoutubeTimedTextDocument(xml: string): YoutubeTimedTextDocument
startMs, startMs,
durationMs, durationMs,
text, text,
isGenerated: /<s\b/.test(rawInner),
rollingWindow: resolveRollingWindow(attrs, windowDefinitions), rollingWindow: resolveRollingWindow(attrs, windowDefinitions),
}); });
} }
@@ -179,6 +182,10 @@ function findNextEventStartMs(eventStartsMs: number[], afterMs: number): number
return undefined; return undefined;
} }
function isGeneratedRollingCue(row: YoutubeTimedTextRow, hasRollingWindowEvents: boolean): boolean {
return row.isGenerated && (row.rollingWindow !== null || hasRollingWindowEvents);
}
function formatVttTimestamp(ms: number): string { function formatVttTimestamp(ms: number): string {
const totalMs = Math.max(0, Math.floor(ms)); const totalMs = Math.max(0, Math.floor(ms));
const hours = Math.floor(totalMs / 3_600_000); const hours = Math.floor(totalMs / 3_600_000);
@@ -280,10 +287,11 @@ export function convertYoutubeTimedTextToVtt(xml: string): string {
const row = rows[index]!; const row = rows[index]!;
const nextRow = rows[index + 1]; const nextRow = rows[index + 1];
const unclampedEnd = row.startMs + row.durationMs; const unclampedEnd = row.startMs + row.durationMs;
// YouTube uses exactly 3000ms as a placeholder for rolling speech rows. // YouTube uses exactly 3000ms as a placeholder for generated rolling speech.
// Other durations are explicit, including short sound cues such as [音楽]. // Plain-text cues can explicitly use the same duration and must keep it.
const nextEventStart = const nextEventStart =
hasRollingWindowEvents && row.durationMs === YOUTUBE_ROLLING_PLACEHOLDER_DURATION_MS isGeneratedRollingCue(row, hasRollingWindowEvents) &&
row.durationMs === YOUTUBE_ROLLING_PLACEHOLDER_DURATION_MS
? findNextEventStartMs(eventStartsMs, row.startMs) ? findNextEventStartMs(eventStartsMs, row.startMs)
: undefined; : undefined;
const clampedEnd = const clampedEnd =