diff --git a/changes/youtube-auto-caption-duration.md b/changes/youtube-auto-caption-duration.md new file mode 100644 index 00000000..8ca62955 --- /dev/null +++ b/changes/youtube-auto-caption-duration.md @@ -0,0 +1,4 @@ +type: fixed +area: youtube + +- YouTube auto-generated captions now follow their intended timing and two-row roll-up layout: long speech is paged instead of covering the video with a wall of text, while explicitly timed sound cues such as `[音楽]` no longer cover later dialogue. diff --git a/src/core/services/youtube/timedtext.test.ts b/src/core/services/youtube/timedtext.test.ts index 1f543cd9..1320bdad 100644 --- a/src/core/services/youtube/timedtext.test.ts +++ b/src/core/services/youtube/timedtext.test.ts @@ -39,6 +39,118 @@ 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

', + '

私だったら無理だよ。

', + '
', + ].join('\n'), + ); + + assert.equal( + result, + [ + 'WEBVTT', + '', + '00:01:38.560 --> 00:01:46.950', + 'ありがとうって言えないよね。こんなんじゃ。', + '', + '00:01:46.960 --> 00:01:50.759', + '私だったら無理だよ。', + '', + ].join('\n'), + ); +}); + +test('convertYoutubeTimedTextToVtt pages oversized two-row rolling captions', () => { + const text = + 'あの西に結構こう山田がスーパーアプローチしてるんだけど西気づかないからちょっとこっちも気づかない感じでこう接してあげようかなて思ってんだけどあの唇巻き込んじゃうしあの思ってることも全部縁に出ちゃって自分であちゃったって言っちゃうタイプなんで結構なんかこうドライなんだけどそこがおもろいよねみたいな'; + const result = convertYoutubeTimedTextToVtt( + [ + '', + '', + '', + '', + '', + '', + '', + `

${text}

`, + '

\n

', + '', + '
', + ].join('\n'), + ); + + const cues = result + .trim() + .split(/\n\n/) + .filter((block) => block.includes('-->')); + const cueText = cues.map((cue) => cue.split('\n').slice(1).join('\n')); + + assert.equal(cues.length, 2); + assert.deepEqual( + cues.map((cue) => cue.split('\n')[0]), + ['00:01:00.440 --> 00:01:07.064', '00:01:07.064 --> 00:01:12.695'], + ); + assert.ok(cueText.every((page) => [...page].length <= 80)); + assert.equal(cueText.join(''), text); +}); + +test('convertYoutubeTimedTextToVtt leaves pop-on captions intact', () => { + const result = convertYoutubeTimedTextToVtt( + [ + '', + '', + '', + '', + '', + '', + '', + '

abcdefghijklmnopqrst

', + '', + '
', + ].join('\n'), + ); + + assert.equal( + result, + ['WEBVTT', '', '00:00:01.000 --> 00:00:04.000', 'abcdefghijklmnopqrst', ''].join('\n'), + ); +}); + +test('convertYoutubeTimedTextToVtt keeps explicit 3000ms sound-cue durations in rolling documents', () => { + const result = convertYoutubeTimedTextToVtt( + [ + '', + '

[音楽]

', + '

\n

', + '

じゃあ、君からお願いします。

', + '
', + ].join('\n'), + ); + + assert.equal( + result, + [ + 'WEBVTT', + '', + '00:00:20.305 --> 00:00:23.305', + '[音楽]', + '', + '00:00:26.279 --> 00:00:29.279', + 'じゃあ、君からお願いします。', + '', + ].join('\n'), + ); +}); + test('normalizeYoutubeAutoVtt strips cumulative rolling-caption prefixes', () => { const result = normalizeYoutubeAutoVtt( [ diff --git a/src/core/services/youtube/timedtext.ts b/src/core/services/youtube/timedtext.ts index fe3aaf47..c0a88020 100644 --- a/src/core/services/youtube/timedtext.ts +++ b/src/core/services/youtube/timedtext.ts @@ -2,9 +2,31 @@ interface YoutubeTimedTextRow { startMs: number; durationMs: number; text: string; + isGenerated: boolean; + rollingWindow: YoutubeRollingWindow | null; +} + +interface YoutubeRollingWindow { + rowCount: number; + columnCount: number; +} + +interface YoutubeTimedTextWindowDefinitions { + rollingStyleIds: Set; + positions: Map; + windows: Map; +} + +interface YoutubeTimedTextDocument { + rows: YoutubeTimedTextRow[]; + // Start times of every

event, including empty window-append fillers. + // Rolling speech rows with a 3000ms placeholder display until the next event. + eventStartsMs: number[]; + hasRollingWindowEvents: boolean; } const YOUTUBE_TIMEDTEXT_EXTENSIONS = new Set(['srv1', 'srv2', 'srv3', 'ytsrv3']); +const YOUTUBE_ROLLING_PLACEHOLDER_DURATION_MS = 3_000; function decodeNumericEntity(match: string, codePoint: number): string { if ( @@ -39,27 +61,129 @@ function parseAttributeMap(raw: string): Map { return attrs; } -function extractYoutubeTimedTextRows(xml: string): YoutubeTimedTextRow[] { +function parsePositiveInteger(value: string | undefined): number | null { + if (value === undefined) { + return null; + } + const parsed = Number(value); + return Number.isSafeInteger(parsed) && parsed > 0 ? parsed : null; +} + +function extractYoutubeTimedTextWindowDefinitions(xml: string): YoutubeTimedTextWindowDefinitions { + const rollingStyleIds = new Set(); + for (const match of xml.matchAll(/]*)\/?\s*>/g)) { + const attrs = parseAttributeMap(match[1] ?? ''); + const id = attrs.get('id'); + if (id !== undefined && attrs.get('mh') === '2') { + rollingStyleIds.add(id); + } + } + + const positions = new Map(); + for (const match of xml.matchAll(/]*)\/?\s*>/g)) { + const attrs = parseAttributeMap(match[1] ?? ''); + const id = attrs.get('id'); + const rowCount = parsePositiveInteger(attrs.get('rc')); + const columnCount = parsePositiveInteger(attrs.get('cc')); + if (id !== undefined && rowCount !== null && columnCount !== null) { + positions.set(id, { rowCount, columnCount }); + } + } + + const windows = new Map(); + for (const match of xml.matchAll(/]*)\/?\s*>/g)) { + const attrs = parseAttributeMap(match[1] ?? ''); + const id = attrs.get('id'); + const styleId = attrs.get('ws'); + const positionId = attrs.get('wp'); + const position = positionId === undefined ? undefined : positions.get(positionId); + if ( + id !== undefined && + styleId !== undefined && + rollingStyleIds.has(styleId) && + position !== undefined + ) { + windows.set(id, position); + } + } + + return { rollingStyleIds, positions, windows }; +} + +function resolveRollingWindow( + attrs: Map, + definitions: YoutubeTimedTextWindowDefinitions, +): YoutubeRollingWindow | null { + const windowId = attrs.get('w'); + if (windowId !== undefined) { + return definitions.windows.get(windowId) ?? null; + } + + const styleId = attrs.get('ws'); + const positionId = attrs.get('wp'); + if ( + styleId === undefined || + positionId === undefined || + !definitions.rollingStyleIds.has(styleId) + ) { + return null; + } + return definitions.positions.get(positionId) ?? null; +} + +function extractYoutubeTimedTextDocument(xml: string): YoutubeTimedTextDocument { const rows: YoutubeTimedTextRow[] = []; + const eventStartsMs: number[] = []; + let hasRollingWindowEvents = false; + const windowDefinitions = extractYoutubeTimedTextWindowDefinitions(xml); for (const match of xml.matchAll(/]*)>([\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; } - const inner = (match[2] ?? '').replace(//gi, '\n').replace(/<[^>]+>/g, ''); + const rawInner = match[2] ?? ''; + const inner = rawInner.replace(//gi, '\n').replace(/<[^>]+>/g, ''); const text = decodeHtmlEntities(inner).trim(); if (!text) { continue; } - rows.push({ startMs, durationMs, text }); + rows.push({ + startMs, + durationMs, + text, + isGenerated: / 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 isGeneratedRollingCue(row: YoutubeTimedTextRow, hasRollingWindowEvents: boolean): boolean { + return row.isGenerated && (row.rollingWindow !== null || hasRollingWindowEvents); } function formatVttTimestamp(ms: number): string { @@ -71,6 +195,79 @@ function formatVttTimestamp(ms: number): string { return `${String(hours).padStart(2, '0')}:${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}.${String(millis).padStart(3, '0')}`; } +const ROLLING_PAGE_BREAK_PATTERN = /[\s、。!?!?]/u; + +// VTT cannot carry SRV3's row and column limits. Page only roll-up windows so +// the overlay keeps their bounded presentation without changing authored cues. +function splitRollingCaptionIntoPages(text: string, rollingWindow: YoutubeRollingWindow): string[] { + const pageCapacity = rollingWindow.rowCount * rollingWindow.columnCount; + const characters = [...text]; + if ( + !Number.isSafeInteger(pageCapacity) || + pageCapacity <= 0 || + characters.length <= pageCapacity + ) { + return [text]; + } + + const pages: string[] = []; + let pageStart = 0; + while (pageStart < characters.length) { + let pageEnd = Math.min(pageStart + pageCapacity, characters.length); + if (pageEnd < characters.length) { + const earliestNaturalBreak = pageStart + Math.ceil(pageCapacity * 0.6); + for (let index = pageEnd - 1; index >= earliestNaturalBreak; index -= 1) { + if (ROLLING_PAGE_BREAK_PATTERN.test(characters[index]!)) { + pageEnd = index + 1; + break; + } + } + } + pages.push(characters.slice(pageStart, pageEnd).join('')); + pageStart = pageEnd; + } + return pages; +} + +interface TimedCaptionPage { + startMs: number; + endMs: number; + text: string; +} + +function timeCaptionPages(input: { + text: string; + pages: string[]; + startMs: number; + endMs: number; +}): TimedCaptionPage[] { + const durationMs = input.endMs - input.startMs; + if (input.pages.length === 1 || durationMs < input.pages.length) { + return [{ startMs: input.startMs, endMs: input.endMs, text: input.text }]; + } + + const totalCharacters = [...input.text].length; + const timedPages: TimedCaptionPage[] = []; + let consumedCharacters = 0; + let pageStartMs = input.startMs; + // Automatic captions often omit span offsets, so distribute the known cue + // duration by page length while guaranteeing every page at least one ms. + for (let index = 0; index < input.pages.length; index += 1) { + const page = input.pages[index]!; + consumedCharacters += [...page].length; + const remainingPages = input.pages.length - index - 1; + const proportionalEndMs = + input.startMs + Math.round((durationMs * consumedCharacters) / totalCharacters); + const pageEndMs = + remainingPages === 0 + ? input.endMs + : Math.min(Math.max(proportionalEndMs, pageStartMs + 1), input.endMs - remainingPages); + timedPages.push({ startMs: pageStartMs, endMs: pageEndMs, text: page }); + pageStartMs = pageEndMs; + } + return timedPages; +} + export function isYoutubeTimedTextExtension(value: string | undefined): boolean { if (!value) { return false; @@ -79,7 +276,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 +287,19 @@ 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 generated rolling speech. + // Plain-text cues can explicitly use the same duration and must keep it. + const nextEventStart = + isGeneratedRollingCue(row, hasRollingWindowEvents) && + row.durationMs === YOUTUBE_ROLLING_PLACEHOLDER_DURATION_MS + ? 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; } @@ -106,9 +312,19 @@ export function convertYoutubeTimedTextToVtt(xml: string): string { if (!text) { continue; } - blocks.push( - `${formatVttTimestamp(row.startMs)} --> ${formatVttTimestamp(clampedEnd)}\n${text}`, - ); + const pages = row.rollingWindow + ? splitRollingCaptionIntoPages(text, row.rollingWindow) + : [text]; + for (const page of timeCaptionPages({ + text, + pages, + startMs: row.startMs, + endMs: clampedEnd, + })) { + blocks.push( + `${formatVttTimestamp(page.startMs)} --> ${formatVttTimestamp(page.endMs)}\n${page.text}`, + ); + } } return `WEBVTT\n\n${blocks.join('\n\n')}\n`;