diff --git a/CHANGELOG.md b/CHANGELOG.md index aa270872..5349c580 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -48,6 +48,9 @@ the frozen-backend fallback mirror it for their toolchains. - Decode UTF-16 and Windows-1252 subtitle and manuscript imports in Electron, web, and backend routes (#2073) — thanks @kevin9327! +- Preserve numeric subtitle dialogue while recognizing mixed indexed and unindexed cues (#2151) — thanks @shivsin25! +- Parse pasted WebVTT cues without treating mid-sentence timestamps as subtitle records (#2077) — thanks @kevin9327! + ## [0.5.3] — 2026-09-17 **Highlights** diff --git a/backend/services/srt_parser.py b/backend/services/srt_parser.py index 357cb87a..d5bc92c9 100644 --- a/backend/services/srt_parser.py +++ b/backend/services/srt_parser.py @@ -24,7 +24,7 @@ from dataclasses import dataclass # Captures: HH MM SS sep(`,` or `.`) ms (1-3 digits) -_TS = r"(\d{1,2}):([0-5]?\d):([0-5]?\d)[,.](\d{1,3})" +_TS = r"(?:(\d{1,2}):)?([0-5]?\d):([0-5]?\d)[,.](\d{1,3})" # Horizontal whitespace only — NEVER plain `\s`, which matches newlines. # A timing line lives on ONE line, so `\s*` bought nothing but catastrophic # backtracking: under re.MULTILINE the engine restarts at every line start, @@ -41,7 +41,32 @@ _TIMING_RE = re.compile(rf"^{_H}{_TS}{_H}-->{_H}{_TS}.*$", re.MULTILINE) def _ts_to_seconds(h: str, m: str, s: str, ms: str) -> float: # Pad ms to 3 digits so "5" -> 0.005, "50" -> 0.050. ms_padded = (ms + "000")[:3] - return int(h) * 3600 + int(m) * 60 + int(s) + int(ms_padded) / 1000.0 + return int(h or 0) * 3600 + int(m) * 60 + int(s) + int(ms_padded) / 1000.0 + + +def _is_index_line(line: str) -> bool: + """True when `line` is a bare SubRip cue number. + + Stricter than `str.isdigit()` on purpose: that also accepts non-ASCII + numerals (Arabic-Indic "١٩٩٩", Devanagari "२०२६", and the full-width + forms), which in a 646-language dubbing app are dialogue, never the + ASCII cue indices SubRip actually writes. + """ + stripped = line.strip() + return stripped.isascii() and stripped.isdigit() + + +def _uses_index_lines(text: str, first_timing_start: int) -> bool: + """Initial numbering hint for lenient files without blank separators. + + Later cue boundaries are also inspected: mixed indexed/unindexed files + must not leak index lines or delete numeric dialogue. + """ + head = text[:first_timing_start] + for line in reversed(head.split("\n")): + if line.strip(): + return _is_index_line(line) + return False @dataclass @@ -68,12 +93,17 @@ def parse_srt(content: str) -> SrtParseResult: # Strip BOM and normalise line endings; many editors save SRTs as CRLF. text = content.lstrip("").replace("\r\n", "\n").replace("\r", "\n") + is_webvtt = bool(re.match(r"WEBVTT(?:[ \t]|\n|$)", text.lstrip())) raw: list[dict] = [] skipped = 0 # Find every timing line, slice the cue text from there to the next # timing line (or end of file). This is robust to missing index # numbers and to spec deviations in the blank-line separator. matches = list(_TIMING_RE.finditer(text)) + # Each body is sliced up to the NEXT timing line, which swallows that + # cue's index line. Only an indexed file has an index to give back, so + # decide that once here instead of guessing from each body. + indexed = bool(matches) and _uses_index_lines(text, matches[0].start()) for i, m in enumerate(matches): try: start = _ts_to_seconds(m.group(1), m.group(2), m.group(3), m.group(4)) @@ -85,13 +115,28 @@ def parse_srt(content: str) -> SrtParseResult: skipped += 1 continue body_start = m.end() - body_end = matches[i + 1].start() if i + 1 < len(matches) else len(text) - body = text[body_start:body_end].strip("\n") - # Drop the trailing index number of the NEXT cue (which got eaten - # into our body) by trimming trailing digit-only lines. - lines = body.split("\n") - while lines and lines[-1].strip().isdigit(): - lines.pop() + has_next = i + 1 < len(matches) + body_end = matches[i + 1].start() if has_next else len(text) + body = text[body_start:body_end] + if is_webvtt: + # The blank separator ends WebVTT dialogue; following identifiers, + # NOTE/STYLE blocks belong outside the cue, even when numeric. + body = re.split(r"\n[^\S\n]*\n", body.lstrip("\n"), maxsplit=1)[0] + # An index must directly precede the next timing line. A blank line + # AFTER a number instead marks that number as preceding dialogue. + if has_next and not is_webvtt: + # Inspect lines rather than a backtracking regex on uploaded text. + # One newline terminates the marker; a second means it is dialogue. + marker_lines = body.split("\n") + if marker_lines and not marker_lines[-1].strip(" \t"): + marker_lines.pop() + marker = marker_lines[-1].strip(" \t") if marker_lines else "" + numeric = bool(marker) and marker.isascii() and marker.isdecimal() + separated = len(marker_lines) > 1 and not marker_lines[-2].strip() + expected = marker.lstrip("0") == str(i + 2) + if numeric and (indexed or (separated and expected)): + body = "\n".join(marker_lines[:-1]) + lines = body.strip("\n").split("\n") cue_text = "\n".join(line.strip() for line in lines if line.strip()) if not cue_text: skipped += 1 diff --git a/docs/dubbing/translation-engines.md b/docs/dubbing/translation-engines.md index 5af87015..0f287d3f 100644 --- a/docs/dubbing/translation-engines.md +++ b/docs/dubbing/translation-engines.md @@ -249,3 +249,8 @@ Dubbing transcription emits keepalives during quiet diarization, reference-refin SRT and WebVTT exports round each cue timestamp once to the nearest millisecond, including carry into the next second or minute. The OpenAI-compatible transcription exports use the same formatter. + + +Paste translation accepts WebVTT files with hourless timestamps. Only timing records at line starts activate timestamp matching; timestamp-like text inside a sentence remains dialogue. + +Subtitle import preserves numeric dialogue such as years and countdowns, including files mixing numbered and unnumbered cues. Cue numbers are removed only at identified cue boundaries. diff --git a/frontend/src/test/dubPasteTranslation.test.jsx b/frontend/src/test/dubPasteTranslation.test.jsx index 99ec759d..a91dcc9b 100644 --- a/frontend/src/test/dubPasteTranslation.test.jsx +++ b/frontend/src/test/dubPasteTranslation.test.jsx @@ -61,6 +61,11 @@ describe('detectPasteMode', () => { expect(detectPasteMode('WEBVTT\n\n00:00:01.000 --> 00:00:04.500\nHola.\n')).toBe('timestamped'); }); + it('detects a VTT paste whose cues have no hours field', () => { + // WebVTT allows `mm:ss.ttt`; without this the lines were mapped as plain text. + expect(detectPasteMode('WEBVTT\n\n00:01.000 --> 00:04.500\nHola.\n')).toBe('timestamped'); + }); + it('detects numbered lines in every common prefix style', () => { expect(detectPasteMode('1. Hola\n2. Que tal\n3. Adios')).toBe('numbered'); expect(detectPasteMode('1) Hola\n2) Que tal')).toBe('numbered'); @@ -436,3 +441,7 @@ describe('DubPasteTranslationDialog', () => { expect(screen.getByRole('button', { name: /Apply/i })).toBeDisabled(); }); }); + +it('keeps an hourless timestamp embedded in prose as plain text', () => { + expect(detectPasteMode('Continue at 01:30.000 --> the finale')).toBe('plain'); +}); diff --git a/frontend/src/utils/pasteTranslations.js b/frontend/src/utils/pasteTranslations.js index eb5fcd46..0ab2116b 100644 --- a/frontend/src/utils/pasteTranslations.js +++ b/frontend/src/utils/pasteTranslations.js @@ -23,9 +23,10 @@ * the SAME plan from the same inputs. */ -// A timing line, e.g. `00:00:01,000 --> 00:00:04,500` (`.` ms separator and -// missing leading zeros allowed — mirrors backend/services/srt_parser.py). -const TIMING_RE = /\d{1,2}:[0-5]?\d:[0-5]?\d[,.]\d{1,3}\s*-->/; +// A timing line, e.g. `00:00:01,000 --> 00:00:04,500` (`.` ms separator, +// missing leading zeros and WebVTT's hourless `00:01.000` allowed — mirrors +// backend/services/srt_parser.py). +const TIMING_RE = /^[^\S\n]*(?:\d{1,2}:)?[0-5]?\d:[0-5]?\d[,.]\d{1,3}[^\S\n]*-->/m; // `1. text` / `2) text` / `[3] text` / `4 - text` / `5: text`. const NUMBERED_RE = /^\s*(?:\[\s*(\d{1,5})\s*\]|\(\s*(\d{1,5})\s*\)|(\d{1,5}))\s*[.):\-—]?\s+(.*)$/; diff --git a/tests/test_srt_parser.py b/tests/test_srt_parser.py index bc89bd1b..93807c9e 100644 --- a/tests/test_srt_parser.py +++ b/tests/test_srt_parser.py @@ -189,3 +189,160 @@ B assert seg["id"] == i assert seg["text"] == seg["text_original"] assert seg["speaker_id"] == "Speaker 1" + + +# -- WebVTT through the same parser (Dub -> Paste translation -> Load file) --- + + +def test_webvtt_cues_without_an_hours_field_are_parsed(): + # WebVTT allows `mm:ss.ttt`; the paste dialog accepts .vtt files. + vtt = "WEBVTT\n\n00:01.000 --> 00:02.500\nHola\n\n01:03.000 --> 01:04.000\nQue tal\n" + result = parse_srt(vtt) + assert [(s["start"], s["end"], s["text"]) for s in result.segments] == [ + (1.0, 2.5, "Hola"), + (63.0, 64.0, "Que tal"), + ] + + +def test_webvtt_identifiers_and_note_blocks_stay_out_of_cue_text(): + vtt = ( + "WEBVTT\n\nNOTE made by a translator\n\n" + "intro\n00:00:01.000 --> 00:00:02.500 align:start\nHola\n\n" + "NOTE check this line\n\n" + "cue-2\n00:00:03.000 --> 00:00:04.000\nQue tal\n" + ) + result = parse_srt(vtt) + assert [s["text"] for s in result.segments] == ["Hola", "Que tal"] + + +def test_srt_text_after_a_blank_line_inside_a_cue_is_still_kept(): + # SRT keeps its lenient blank-line handling; only WebVTT has identifiers. + srt = "1\n00:00:01,000 --> 00:00:02,000\nFirst\n\nstill first\n2\n00:00:03,000 --> 00:00:04,000\nSecond\n" + result = parse_srt(srt) + assert [s["text"] for s in result.segments] == ["First\nstill first", "Second"] + + +def test_paste_endpoint_returns_webvtt_cues(): + from fastapi.testclient import TestClient + from main import app + + client = TestClient(app, client=("127.0.0.1", 50000)) + res = client.post( + "/dub/parse-subtitle-text", + json={"text": "WEBVTT\n\nintro\n00:01.000 --> 00:02.000\nHola\n\nNOTE x\n\n00:03.000 --> 00:04.000\nAdios\n"}, + ) + assert res.status_code == 200, res.text + assert [(c["start"], c["text"]) for c in res.json()["segments"]] == [(1.0, "Hola"), (3.0, "Adios")] + +def test_keeps_a_final_cue_that_is_only_a_number(): + # Regression: cue bodies are sliced up to the next timing line, which + # swallows that cue's index, and the parser used to claw it back by + # popping *every* trailing digit-only line. The last cue has no next + # index to pop, so a closing "1999" was mistaken for one — the cue lost + # its only line and was dropped as empty. Numeric-only dialogue is + # everywhere in subtitles (a year, a score, a street number). + srt = """1 +00:00:01,000 --> 00:00:02,000 +The year was + +2 +00:00:03,000 --> 00:00:04,000 +1999 +""" + result = parse_srt(srt) + assert result.skipped_cues == 0 + assert [s["text"] for s in result.segments] == ["The year was", "1999"] + + +def test_keeps_numeric_dialogue_in_an_index_less_file(): + # An index-less export has no index lines to strip at all, so a cue + # ending in a number kept its text silently truncated — no skip counted, + # so the import reported itself as lossless while dropping a line. + srt = """00:00:01,000 --> 00:00:02,000 +The answer is +42 + +00:00:03,000 --> 00:00:04,000 +Next. +""" + result = parse_srt(srt) + assert [s["text"] for s in result.segments] == ["The answer is\n42", "Next."] + + +def test_keeps_numeric_text_when_the_blank_separator_is_missing(): + # Off-spec file with no blank line between cue text and the next index: + # exactly one trailing index line may be reclaimed, never two. + srt = """1 +00:00:01,000 --> 00:00:02,000 +100 +2 +00:00:03,000 --> 00:00:04,000 +Hi +""" + result = parse_srt(srt) + assert [s["text"] for s in result.segments] == ["100", "Hi"] + + +def test_keeps_a_multi_line_numeric_countdown(): + # The old `while` loop popped digit lines until it hit a non-digit, so a + # "3 / 2 / 1" countdown cue was consumed line by line and then dropped. + srt = """1 +00:00:01,000 --> 00:00:02,000 +Ready? + +2 +00:00:03,000 --> 00:00:04,000 +3 +2 +1 +""" + result = parse_srt(srt) + assert [s["text"] for s in result.segments] == ["Ready?", "3\n2\n1"] + + +def test_non_ascii_numerals_are_dialogue_not_cue_indices(): + # `str.isdigit()` is True for Arabic-Indic and Devanagari numerals, which + # SubRip never uses for indices but a 646-language dubbing app sees as + # dialogue constantly. + srt = """1 +00:00:01,000 --> 00:00:02,000 +١٩٩٩ + +2 +00:00:03,000 --> 00:00:04,000 +२०२६ +""" + result = parse_srt(srt) + assert [s["text"] for s in result.segments] == ["١٩٩٩", "२०२६"] + + +def test_still_strips_the_index_line_swallowed_from_the_next_cue(): + # The guard against over-correcting: a numeric-bodied cue followed by + # another must keep its own text and still not leak the next index. + srt = """1 +00:00:01,000 --> 00:00:02,000 +1999 + +2 +00:00:03,000 --> 00:00:04,000 +Next. +""" + result = parse_srt(srt) + assert [s["text"] for s in result.segments] == ["1999", "Next."] + + +@pytest.mark.parametrize("first_index", ["", "1\n"]) +def test_mixed_indexed_and_unindexed_cues_preserve_numbers(first_index): + text = first_index + "00:00:01,000 --> 00:00:02,000\n42\n\n2\n00:00:03,000 --> 00:00:04,000\n1999\n\n00:00:05,000 --> 00:00:06,000\n3\n2\n1\n" + assert [x["text"] for x in parse_srt(text).segments] == ["42", "1999", "3\n2\n1"] + + +def test_webvtt_numeric_dialogue_is_not_a_cue_identifier(): + text = "WEBVTT\n\n00:01.000 --> 00:02.000\n1999\n\nnext-id\n00:03.000 --> 00:04.000\n42\n" + assert [cue["text"] for cue in parse_srt(text).segments] == ["1999", "42"] + + +def test_indexless_numeric_dialogue_after_blank_line_is_preserved(): + text = '00:00:01,000 --> 00:00:02,000\nFirst\n\n42\n00:00:03,000 --> 00:00:04,000\nNext' + segments = parse_srt(text).segments + assert segments[0]['text'] == 'First\n42'