Handle untagged continuation lines in rolling auto-captions

Short continuations (e.g. a single trailing word) carry no inline timing
tags, so filtering by tags missed them. Instead, in files with inline
timing tags, drop leading cue lines that repeat the previous cue's last
line — covers both tagged and untagged new text.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Claude Fable 5 2026-08-29 21:13:29 +02:00
parent 03eec79b4c
commit a7d7d1cf50
2 changed files with 30 additions and 25 deletions

View File

@ -9,16 +9,18 @@ def strip_html(text):
return re.sub(r'<[^>]+>', '', text) return re.sub(r'<[^>]+>', '', text)
# YouTube auto-captions use rolling cues: the previous line is repeated as # YouTube auto-captions use rolling cues: each cue repeats the previous
# plain text while the new line carries inline word timings (<00:00:00.200>, # cue's last line as plain text before the new text (which usually, but not
# <c>…</c>). Only the tagged lines contain new text. # always, carries inline word timings like <00:00:00.200> or <c>…</c>).
def has_inline_timing_tags(line): def has_inline_timing_tags(text):
return re.search(r'<\d{2}:\d{2}:|<c[.\w]*>', line) is not None return re.search(r'<\d{2}:\d{2}:|<c[.\w]*>', text) is not None
def parse_vtt(content): def parse_vtt(content):
rolling = has_inline_timing_tags(content)
blocks = re.split(r'\n{2,}', content.strip()) blocks = re.split(r'\n{2,}', content.strip())
cues = [] cues = []
prev_last = None
for block in blocks: for block in blocks:
lines = block.strip().splitlines() lines = block.strip().splitlines()
if not lines: if not lines:
@ -30,14 +32,14 @@ def parse_vtt(content):
ts_idx = next((i for i, l in enumerate(lines) if '-->' in l), None) ts_idx = next((i for i, l in enumerate(lines) if '-->' in l), None)
if ts_idx is None: if ts_idx is None:
continue continue
text_lines = lines[ts_idx + 1:] text_lines = [strip_html(l).strip() for l in lines[ts_idx + 1:]]
if any(has_inline_timing_tags(l) for l in text_lines): if rolling:
text_lines = [l for l in text_lines if has_inline_timing_tags(l)] while text_lines and prev_last and text_lines[0] == prev_last:
text = ' '.join( text_lines.pop(0)
strip_html(l).strip() non_empty = [l for l in text_lines if l]
for l in text_lines if rolling and non_empty:
if strip_html(l).strip() prev_last = non_empty[-1]
) text = ' '.join(non_empty)
if text: if text:
cues.append(text) cues.append(text)
return cues return cues

View File

@ -5,16 +5,18 @@ function stripHtml(text) {
return text.replace(/<[^>]+>/g, ''); return text.replace(/<[^>]+>/g, '');
} }
// YouTube auto-captions use rolling cues: the previous line is repeated as // YouTube auto-captions use rolling cues: each cue repeats the previous
// plain text while the new line carries inline word timings (<00:00:00.200>, // cue's last line as plain text before the new text (which usually, but not
// <c>…</c>). Only the tagged lines contain new text. // always, carries inline word timings like <00:00:00.200> or <c>…</c>).
function hasInlineTimingTags(line) { function hasInlineTimingTags(text) {
return /<\d{2}:\d{2}:|<c[.\w]*>/.test(line); return /<\d{2}:\d{2}:|<c[.\w]*>/.test(text);
} }
function parseVtt(content) { function parseVtt(content) {
const rolling = hasInlineTimingTags(content);
const blocks = content.split(/\n{2,}/); const blocks = content.split(/\n{2,}/);
const cues = []; const cues = [];
let prevLast = null;
for (const block of blocks) { for (const block of blocks) {
const lines = block.trim().split('\n'); const lines = block.trim().split('\n');
if (!lines.length) continue; if (!lines.length) continue;
@ -22,14 +24,15 @@ function parseVtt(content) {
if (lines[0].startsWith('NOTE') || lines[0].startsWith('STYLE')) continue; if (lines[0].startsWith('NOTE') || lines[0].startsWith('STYLE')) continue;
const tsIdx = lines.findIndex(l => l.includes('-->')); const tsIdx = lines.findIndex(l => l.includes('-->'));
if (tsIdx === -1) continue; if (tsIdx === -1) continue;
let textLines = lines.slice(tsIdx + 1); const textLines = lines.slice(tsIdx + 1).map(l => stripHtml(l).trim());
if (textLines.some(hasInlineTimingTags)) { if (rolling) {
textLines = textLines.filter(hasInlineTimingTags); while (textLines.length && prevLast && textLines[0] === prevLast) {
textLines.shift();
}
} }
const text = textLines const nonEmpty = textLines.filter(Boolean);
.map(l => stripHtml(l).trim()) if (rolling && nonEmpty.length) prevLast = nonEmpty[nonEmpty.length - 1];
.filter(Boolean) const text = nonEmpty.join(' ');
.join(' ');
if (text) cues.push(text); if (text) cues.push(text);
} }
return cues; return cues;