mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-08-26 00:15:27 -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:
@@ -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.
|
||||||
@@ -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', () => {
|
test('normalizeYoutubeAutoVtt strips cumulative rolling-caption prefixes', () => {
|
||||||
const result = normalizeYoutubeAutoVtt(
|
const result = normalizeYoutubeAutoVtt(
|
||||||
[
|
[
|
||||||
|
|||||||
@@ -4,6 +4,16 @@ interface YoutubeTimedTextRow {
|
|||||||
text: string;
|
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']);
|
const YOUTUBE_TIMEDTEXT_EXTENSIONS = new Set(['srv1', 'srv2', 'srv3', 'ytsrv3']);
|
||||||
|
|
||||||
function decodeNumericEntity(match: string, codePoint: number): string {
|
function decodeNumericEntity(match: string, codePoint: number): string {
|
||||||
@@ -39,14 +49,24 @@ function parseAttributeMap(raw: string): Map<string, string> {
|
|||||||
return attrs;
|
return attrs;
|
||||||
}
|
}
|
||||||
|
|
||||||
function extractYoutubeTimedTextRows(xml: string): YoutubeTimedTextRow[] {
|
function extractYoutubeTimedTextDocument(xml: string): YoutubeTimedTextDocument {
|
||||||
const rows: YoutubeTimedTextRow[] = [];
|
const rows: YoutubeTimedTextRow[] = [];
|
||||||
|
const eventStartsMs: number[] = [];
|
||||||
|
let hasRollingWindowEvents = false;
|
||||||
|
|
||||||
for (const match of xml.matchAll(/<p\b([^>]*)>([\s\S]*?)<\/p>/g)) {
|
for (const match of xml.matchAll(/<p\b([^>]*)>([\s\S]*?)<\/p>/g)) {
|
||||||
const attrs = parseAttributeMap(match[1] ?? '');
|
const attrs = parseAttributeMap(match[1] ?? '');
|
||||||
const startMs = Number(attrs.get('t'));
|
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'));
|
const durationMs = Number(attrs.get('d'));
|
||||||
if (!Number.isFinite(startMs) || !Number.isFinite(durationMs)) {
|
if (!Number.isFinite(durationMs)) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -59,7 +79,17 @@ function extractYoutubeTimedTextRows(xml: string): YoutubeTimedTextRow[] {
|
|||||||
rows.push({ startMs, durationMs, text });
|
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 {
|
function formatVttTimestamp(ms: number): string {
|
||||||
@@ -79,7 +109,7 @@ export function isYoutubeTimedTextExtension(value: string | undefined): boolean
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function convertYoutubeTimedTextToVtt(xml: string): string {
|
export function convertYoutubeTimedTextToVtt(xml: string): string {
|
||||||
const rows = extractYoutubeTimedTextRows(xml);
|
const { rows, eventStartsMs, hasRollingWindowEvents } = extractYoutubeTimedTextDocument(xml);
|
||||||
if (rows.length === 0) {
|
if (rows.length === 0) {
|
||||||
return 'WEBVTT\n';
|
return 'WEBVTT\n';
|
||||||
}
|
}
|
||||||
@@ -90,10 +120,17 @@ export function convertYoutubeTimedTextToVtt(xml: string): string {
|
|||||||
const row = rows[index]!;
|
const row = rows[index]!;
|
||||||
const nextRow = rows[index + 1];
|
const nextRow = rows[index + 1];
|
||||||
const unclampedEnd = row.startMs + row.durationMs;
|
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 =
|
const clampedEnd =
|
||||||
nextRow && unclampedEnd > nextRow.startMs
|
nextEventStart !== undefined
|
||||||
? Math.max(row.startMs, nextRow.startMs - 1)
|
? nextEventStart
|
||||||
: unclampedEnd;
|
: nextRow && unclampedEnd > nextRow.startMs
|
||||||
|
? Math.max(row.startMs, nextRow.startMs - 1)
|
||||||
|
: unclampedEnd;
|
||||||
if (clampedEnd <= row.startMs) {
|
if (clampedEnd <= row.startMs) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user