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(
[
'<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="26279" d="3000" w="1"><s ac="0">じゃあ、君からお願いします。</s></p>',
'</body></timedtext>',
@@ -141,7 +141,7 @@ test('convertYoutubeTimedTextToVtt keeps explicit sound-cue durations in rolling
[
'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',
+12 -4
View File
@@ -2,6 +2,7 @@ interface YoutubeTimedTextRow {
startMs: number;
durationMs: number;
text: string;
isGenerated: boolean;
rollingWindow: YoutubeRollingWindow | null;
}
@@ -152,7 +153,8 @@ function extractYoutubeTimedTextDocument(xml: string): YoutubeTimedTextDocument
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();
if (!text) {
continue;
@@ -162,6 +164,7 @@ function extractYoutubeTimedTextDocument(xml: string): YoutubeTimedTextDocument
startMs,
durationMs,
text,
isGenerated: /<s\b/.test(rawInner),
rollingWindow: resolveRollingWindow(attrs, windowDefinitions),
});
}
@@ -179,6 +182,10 @@ function findNextEventStartMs(eventStartsMs: number[], afterMs: number): number
return undefined;
}
function isGeneratedRollingCue(row: YoutubeTimedTextRow, hasRollingWindowEvents: boolean): boolean {
return row.isGenerated && (row.rollingWindow !== null || hasRollingWindowEvents);
}
function formatVttTimestamp(ms: number): string {
const totalMs = Math.max(0, Math.floor(ms));
const hours = Math.floor(totalMs / 3_600_000);
@@ -280,10 +287,11 @@ export function convertYoutubeTimedTextToVtt(xml: string): string {
const row = rows[index]!;
const nextRow = rows[index + 1];
const unclampedEnd = row.startMs + row.durationMs;
// YouTube uses exactly 3000ms as a placeholder for rolling speech rows.
// Other durations are explicit, including short sound cues such as [音楽].
// YouTube uses exactly 3000ms as a placeholder for generated rolling speech.
// Plain-text cues can explicitly use the same duration and must keep it.
const nextEventStart =
hasRollingWindowEvents && row.durationMs === YOUTUBE_ROLLING_PLACEHOLDER_DURATION_MS
isGeneratedRollingCue(row, hasRollingWindowEvents) &&
row.durationMs === YOUTUBE_ROLLING_PLACEHOLDER_DURATION_MS
? findNextEventStartMs(eventStartsMs, row.startMs)
: undefined;
const clampedEnd =