diff --git a/changes/youtube-auto-caption-duration.md b/changes/youtube-auto-caption-duration.md
new file mode 100644
index 00000000..4d31971d
--- /dev/null
+++ b/changes/youtube-auto-caption-duration.md
@@ -0,0 +1,4 @@
+type: fixed
+area: youtube
+
+- YouTube auto-generated captions no longer disappear mid-speech: long caption rows carry a placeholder 3-second duration in YouTube's TimedText data, so converted cues now stay on screen until the next caption event, matching YouTube's own display timing.
diff --git a/src/core/services/youtube/timedtext.test.ts b/src/core/services/youtube/timedtext.test.ts
index 1f543cd9..df3ab872 100644
--- a/src/core/services/youtube/timedtext.test.ts
+++ b/src/core/services/youtube/timedtext.test.ts
@@ -39,6 +39,36 @@ test('convertYoutubeTimedTextToVtt does not swallow text after zero-length overl
);
});
+test('convertYoutubeTimedTextToVtt extends rolling captions to the next window event', () => {
+ // Real-world shape of YouTube's sentence-level auto captions: window-append
+ // filler rows (a="1", sometimes without d) mark the display timeline, while
+ // long text rows carry a placeholder d="3000" far shorter than the speech.
+ const result = convertYoutubeTimedTextToVtt(
+ [
+ ' \n \n ありがとうって言えないよね。こんなんじゃ。私だったら無理だよ。
event, including empty window-append fillers.
+ // In the rolling auto-caption format YouTube displays each caption until the
+ // next window event and the row's own duration is often a 3000ms placeholder,
+ // so these timestamps are the only reliable source for cue end times.
+ eventStartsMs: number[];
+ hasRollingWindowEvents: boolean;
+}
+
const YOUTUBE_TIMEDTEXT_EXTENSIONS = new Set(['srv1', 'srv2', 'srv3', 'ytsrv3']);
function decodeNumericEntity(match: string, codePoint: number): string {
@@ -39,14 +49,24 @@ function parseAttributeMap(raw: string): Map ]*)>([\s\S]*?)<\/p>/g)) {
const attrs = parseAttributeMap(match[1] ?? '');
const startMs = Number(attrs.get('t'));
+ if (!Number.isFinite(startMs)) {
+ continue;
+ }
+ eventStartsMs.push(startMs);
+ if (attrs.get('a') === '1') {
+ hasRollingWindowEvents = true;
+ }
+
const durationMs = Number(attrs.get('d'));
- if (!Number.isFinite(startMs) || !Number.isFinite(durationMs)) {
+ if (!Number.isFinite(durationMs)) {
continue;
}
@@ -59,7 +79,17 @@ function extractYoutubeTimedTextRows(xml: string): YoutubeTimedTextRow[] {
rows.push({ startMs, durationMs, text });
}
- return rows;
+ eventStartsMs.sort((a, b) => a - b);
+ return { rows, eventStartsMs, hasRollingWindowEvents };
+}
+
+function findNextEventStartMs(eventStartsMs: number[], afterMs: number): number | undefined {
+ for (const startMs of eventStartsMs) {
+ if (startMs > afterMs) {
+ return startMs;
+ }
+ }
+ return undefined;
}
function formatVttTimestamp(ms: number): string {
@@ -79,7 +109,7 @@ export function isYoutubeTimedTextExtension(value: string | undefined): boolean
}
export function convertYoutubeTimedTextToVtt(xml: string): string {
- const rows = extractYoutubeTimedTextRows(xml);
+ const { rows, eventStartsMs, hasRollingWindowEvents } = extractYoutubeTimedTextDocument(xml);
if (rows.length === 0) {
return 'WEBVTT\n';
}
@@ -90,10 +120,17 @@ export function convertYoutubeTimedTextToVtt(xml: string): string {
const row = rows[index]!;
const nextRow = rows[index + 1];
const unclampedEnd = row.startMs + row.durationMs;
+ // Rolling auto captions display until the next window event; the row's own
+ // duration is frequently a 3000ms placeholder that cuts long lines short.
+ const nextEventStart = hasRollingWindowEvents
+ ? findNextEventStartMs(eventStartsMs, row.startMs)
+ : undefined;
const clampedEnd =
- nextRow && unclampedEnd > nextRow.startMs
- ? Math.max(row.startMs, nextRow.startMs - 1)
- : unclampedEnd;
+ nextEventStart !== undefined
+ ? nextEventStart
+ : nextRow && unclampedEnd > nextRow.startMs
+ ? Math.max(row.startMs, nextRow.startMs - 1)
+ : unclampedEnd;
if (clampedEnd <= row.startMs) {
continue;
}