${text}
\n
', + '', + 'abcdefghijklmnopqrst
', + '', + '[音楽]
', + '\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.
+ // 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 (
@@ -49,10 +60,81 @@ function parseAttributeMap(raw: string): Map ]*)>([\s\S]*?)<\/p>/g)) {
const attrs = parseAttributeMap(match[1] ?? '');
@@ -76,7 +158,12 @@ function extractYoutubeTimedTextDocument(xml: string): YoutubeTimedTextDocument
continue;
}
- rows.push({ startMs, durationMs, text });
+ rows.push({
+ startMs,
+ durationMs,
+ text,
+ rollingWindow: resolveRollingWindow(attrs, windowDefinitions),
+ });
}
eventStartsMs.sort((a, b) => a - b);
@@ -101,6 +188,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;
@@ -120,11 +280,12 @@ 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;
+ // YouTube uses exactly 3000ms as a placeholder for rolling speech rows.
+ // Other durations are explicit, including short sound cues such as [音楽].
+ const nextEventStart =
+ hasRollingWindowEvents && row.durationMs === YOUTUBE_ROLLING_PLACEHOLDER_DURATION_MS
+ ? findNextEventStartMs(eventStartsMs, row.startMs)
+ : undefined;
const clampedEnd =
nextEventStart !== undefined
? nextEventStart
@@ -143,9 +304,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`;