mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-08-26 12:15:26 -07:00
fix(youtube): keep auto captions on screen for their full span
YouTube's sentence-level ASR emits long caption rows with a placeholder d="3000", while the caption actually displays until the next window event. Trusting `d` made long lines vanish mid-speech and leave a blank gap until the next cue. Rolling auto-caption documents (rows with a="1") now end each cue at the next event timestamp instead of t + d, matching YouTube's own display timing. Manual and non-rolling TimedText keep duration-based timing so real silence gaps are preserved.
This commit is contained in:
@@ -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(
|
||||
[
|
||||
'<timedtext><body>',
|
||||
'<p t="98550" d="3010" w="1" a="1">\n</p>',
|
||||
'<p t="98560" d="3000" w="1"><s ac="0">ありがとうって言えないよね。こんなんじゃ。</s></p>',
|
||||
'<p t="106950" w="1" a="1">\n</p>',
|
||||
'<p t="106960" d="3799" w="1"><s ac="0">私だったら無理だよ。</s></p>',
|
||||
'</body></timedtext>',
|
||||
].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('normalizeYoutubeAutoVtt strips cumulative rolling-caption prefixes', () => {
|
||||
const result = normalizeYoutubeAutoVtt(
|
||||
[
|
||||
|
||||
@@ -4,6 +4,16 @@ interface YoutubeTimedTextRow {
|
||||
text: string;
|
||||
}
|
||||
|
||||
interface YoutubeTimedTextDocument {
|
||||
rows: YoutubeTimedTextRow[];
|
||||
// Start times of every <p> 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<string, string> {
|
||||
return attrs;
|
||||
}
|
||||
|
||||
function extractYoutubeTimedTextRows(xml: string): YoutubeTimedTextRow[] {
|
||||
function extractYoutubeTimedTextDocument(xml: string): YoutubeTimedTextDocument {
|
||||
const rows: YoutubeTimedTextRow[] = [];
|
||||
const eventStartsMs: number[] = [];
|
||||
let hasRollingWindowEvents = false;
|
||||
|
||||
for (const match of xml.matchAll(/<p\b([^>]*)>([\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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user