From 151d58f0241869dc3ec8a169a5d12ab867e3a175 Mon Sep 17 00:00:00 2001 From: kevin9327 <5299031+kevin9327@users.noreply.github.com> Date: Mon, 14 Sep 2026 08:48:05 +0900 Subject: [PATCH 1/7] fix(dub): a pasted WebVTT file maps its cues, not its markup Dub -> Paste translation -> Load file accepts .vtt and sends timestamped text to the lenient SRT parser, which is meant to take VTT too. Two ordinary WebVTT files broke it: - Cues without an hours field (00:01.000 --> 00:04.500) matched neither the frontend's timing detector nor the backend pattern, so the dialog mapped WEBVTT, the timing lines and the dialogue as plain translations, and the endpoint itself answered "No timed cues found". - A cue identifier or NOTE block after a cue became part of that cue's text, because only digit-only index lines were trimmed. The hours are now optional in both patterns, as dub_pipeline's yt-dlp caption parser already allows. For WebVTT input a cue's text ends at its first blank line, as the format specifies; SRT keeps its lenient blank-line handling. Co-Authored-By: Claude Opus 5 --- backend/services/srt_parser.py | 21 +++++++-- .../src/test/dubPasteTranslation.test.jsx | 5 +++ frontend/src/utils/pasteTranslations.js | 7 +-- tests/test_srt_parser.py | 44 +++++++++++++++++++ 4 files changed, 70 insertions(+), 7 deletions(-) diff --git a/backend/services/srt_parser.py b/backend/services/srt_parser.py index 3b26e818..cdb193e4 100644 --- a/backend/services/srt_parser.py +++ b/backend/services/srt_parser.py @@ -23,8 +23,10 @@ import re 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})" +# Captures: HH MM SS sep(`,` or `.`) ms (1-3 digits). The hours are optional: +# WebVTT allows `mm:ss.ttt`, and .vtt files reach this parser from the paste +# dialog (the yt-dlp caption parser in dub_pipeline already accepts them). +_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, @@ -36,12 +38,16 @@ _H = r"[^\S\n]*" # Whole timing line: `00:00:01,000 --> 00:00:04,500` plus optional trailing # cue style hints (X1: Y1: ... ) we just throw away. _TIMING_RE = re.compile(rf"^{_H}{_TS}{_H}-->{_H}{_TS}.*$", re.MULTILINE) +# A WebVTT file starts with this signature line. +_WEBVTT_RE = re.compile(r"WEBVTT(?:[ \t]|\n|$)") +# A blank (or whitespace-only) line. +_BLANK_LINE_RE = re.compile(r"\n[^\S\n]*\n") -def _ts_to_seconds(h: str, m: str, s: str, ms: str) -> float: +def _ts_to_seconds(h: "str | None", 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 @dataclass @@ -68,6 +74,11 @@ 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") + # In WebVTT a cue's text ends at the first blank line; what follows before + # the next timing line is that cue's identifier or a NOTE/STYLE block, not + # dialogue. SRT keeps its lenient handling of blank lines inside a cue. + is_webvtt = bool(_WEBVTT_RE.match(text.lstrip())) + raw: list[dict] = [] skipped = 0 # Find every timing line, slice the cue text from there to the next @@ -87,6 +98,8 @@ def parse_srt(content: str) -> SrtParseResult: 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") + if is_webvtt: + body = _BLANK_LINE_RE.split(body, maxsplit=1)[0] # 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") diff --git a/frontend/src/test/dubPasteTranslation.test.jsx b/frontend/src/test/dubPasteTranslation.test.jsx index 888b8d81..99d872d8 100644 --- a/frontend/src/test/dubPasteTranslation.test.jsx +++ b/frontend/src/test/dubPasteTranslation.test.jsx @@ -60,6 +60,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'); diff --git a/frontend/src/utils/pasteTranslations.js b/frontend/src/utils/pasteTranslations.js index eb5fcd46..209130ff 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 = /(?:\d{1,2}:)?[0-5]?\d:[0-5]?\d[,.]\d{1,3}\s*-->/; // `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..2c184d68 100644 --- a/tests/test_srt_parser.py +++ b/tests/test_srt_parser.py @@ -189,3 +189,47 @@ 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")] \ No newline at end of file From b33d9452ee673ac4f056360e5b283fb9b0532f6d Mon Sep 17 00:00:00 2001 From: kevin9327 <5299031+kevin9327@users.noreply.github.com> Date: Mon, 14 Sep 2026 08:50:25 +0900 Subject: [PATCH 2/7] docs(changelog): note the WebVTT paste fix (#2077) Co-Authored-By: Claude Opus 5 --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index c52baa68..99c424ad 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -29,6 +29,7 @@ the frozen-backend fallback mirror it for their toolchains. - Transcribing an M4A file with PyTorch Whisper works, instead of failing with "Format not recognised" (#2042, #2039) - PyTorch Whisper runs on 6 GB NVIDIA cards instead of falling back to CPU, because its memory check now fits the model it loads (#2044, #2041) - MCP tools wait as long as the backend does, so a long transcription no longer fails at 120 s with an empty error (#2043, #2040) +- A WebVTT file loaded into Paste translation maps its cues, instead of its timing lines, cue labels or notes (#2077) ### CI From fc2d811471bc3bfa8b27363c41a48ba8aeb08e4c Mon Sep 17 00:00:00 2001 From: kevin9327 <5299031+kevin9327@users.noreply.github.com> Date: Mon, 14 Sep 2026 08:51:22 +0900 Subject: [PATCH 3/7] docs(changelog): file the entry apart from the other open PRs' lines Co-Authored-By: Claude Opus 5 --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 99c424ad..1756f283 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,12 +24,12 @@ the frozen-backend fallback mirror it for their toolchains. ### Fixed - Stopping a process on macOS no longer fails with "Operation not permitted" when it was already exiting (#2032) +- A WebVTT file loaded into Paste translation maps its cues, instead of its timing lines, cue labels or notes (#2077) - A YouTube link blocked by its "not a bot" check now says how to attach signed-in cookies in Dub, instead of quoting yt-dlp's command-line flags (#2036, #2034) - An engine that fails to start now says whether it timed out, crashed (with its exit code and last output) or answered wrongly, instead of "did not signal ready: None" (#2037, #2026) - Transcribing an M4A file with PyTorch Whisper works, instead of failing with "Format not recognised" (#2042, #2039) - PyTorch Whisper runs on 6 GB NVIDIA cards instead of falling back to CPU, because its memory check now fits the model it loads (#2044, #2041) - MCP tools wait as long as the backend does, so a long transcription no longer fails at 120 s with an empty error (#2043, #2040) -- A WebVTT file loaded into Paste translation maps its cues, instead of its timing lines, cue labels or notes (#2077) ### CI From 34b5eaabf1795a9846540a3f2c24510f9fa4e1be Mon Sep 17 00:00:00 2001 From: Shivendra-Coherent Date: Wed, 16 Sep 2026 18:10:51 +0530 Subject: [PATCH 4/7] fix(subtitles): keep cue lines that are only a number MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An imported or pasted subtitle whose line is just a number — a year, a score, a street number, a "3 / 2 / 1" countdown — was silently deleted, and when that line was the cue's only text the whole cue disappeared. Cue bodies are sliced from a timing line up to the next one, which swallows the next cue's index. The parser clawed that back by popping every trailing digit-only line, a rule that cannot tell an index from numeric dialogue. Three shapes fell out of it: - the last cue of a file has no next index to pop, so a closing "1999" was taken for one and the cue was dropped as empty; - an index-less export has no indices at all, yet still lost its closing numeric line — and lost it without counting a skip, so the import reported itself lossless while dropping a line; - the `while` loop popped digit lines until it hit a non-digit, eating a multi-line countdown cue whole. Give back exactly the one line that was swallowed: a single line, only when a next cue exists to own it, and only in a file that indexes its cues at all — decided once from the preamble before the first timing line, since a body of "42" is indistinguishable from an index on its own. Index detection is ASCII-only, because `str.isdigit()` is also true for Arabic-Indic and Devanagari numerals, which in a 646-language dubbing app are dialogue rather than SubRip indices. Affects both subtitle entry points: POST /dub/import-srt/{job_id} and POST /dub/parse-subtitle-text. Co-Authored-By: Claude Opus 5 (1M context) --- CHANGELOG.md | 1 + backend/services/srt_parser.py | 48 +++++++++++++++-- tests/test_srt_parser.py | 97 ++++++++++++++++++++++++++++++++++ 3 files changed, 142 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 77e2dd83..bf9c5488 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -31,6 +31,7 @@ the frozen-backend fallback mirror it for their toolchains. - Transcribing an M4A file with PyTorch Whisper works, instead of failing with "Format not recognised" (#2042, #2039) - PyTorch Whisper runs on 6 GB NVIDIA cards instead of falling back to CPU, because its memory check now fits the model it loads (#2044, #2041) - MCP tools wait as long as the backend does, so a long transcription no longer fails at 120 s with an empty error (#2043, #2040) +- Importing or pasting subtitles keeps cue lines that are only a number — a year, a score, a "3 / 2 / 1" countdown — instead of silently deleting them or dropping the cue entirely (#2150) ### CI diff --git a/backend/services/srt_parser.py b/backend/services/srt_parser.py index 3b26e818..f99134b1 100644 --- a/backend/services/srt_parser.py +++ b/backend/services/srt_parser.py @@ -44,6 +44,35 @@ def _ts_to_seconds(h: str, m: str, s: str, ms: str) -> float: return int(h) * 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: + """Whether this file numbers its cues, decided once from the preamble. + + A SubRip file opens with the first cue's index; an index-less export + opens with the timing line itself. Files don't mix the two styles, so + one look at what precedes the first timing line settles it for the + whole parse — and settling it globally is the point: deciding per-cue + means guessing from a body, and a body of "42" is indistinguishable + from an index. + """ + head = text[:first_timing_start] + for line in reversed(head.split("\n")): + if line.strip(): + return _is_index_line(line) + return False + + @dataclass class SrtParseResult: segments: list[dict] @@ -74,6 +103,10 @@ def parse_srt(content: str) -> SrtParseResult: # 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,12 +118,19 @@ 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) + has_next = i + 1 < len(matches) + body_end = matches[i + 1].start() if has_next 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. + # Give back exactly the one index line the slice above swallowed: + # a single line, only when a next cue exists to own it, and only in + # a file that indexes its cues at all. Anything looser eats real + # dialogue — subtitles are full of numeric-only lines (a year, a + # score, a street number, a "3 / 2 / 1" countdown). The old rule + # popped *every* trailing digit line unconditionally, so the last + # cue of a file ("1999") vanished outright and an index-less export + # quietly lost its closing number. lines = body.split("\n") - while lines and lines[-1].strip().isdigit(): + if indexed and has_next and lines and _is_index_line(lines[-1]): lines.pop() cue_text = "\n".join(line.strip() for line in lines if line.strip()) if not cue_text: diff --git a/tests/test_srt_parser.py b/tests/test_srt_parser.py index bc89bd1b..2c7ae447 100644 --- a/tests/test_srt_parser.py +++ b/tests/test_srt_parser.py @@ -175,6 +175,103 @@ def test_timing_line_tolerates_leading_and_inner_spaces(): assert result.segments[0]["text"] == "Indented." +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."] + + def test_segments_get_sequential_ids_and_required_fields(): srt = """1 00:00:01,000 --> 00:00:02,000 From a33db40613afbb6f568ab49d920a2fba55eefa04 Mon Sep 17 00:00:00 2001 From: Palash Debnath <4178343+debpalash@users.noreply.github.com> Date: Thu, 17 Sep 2026 12:16:41 +0530 Subject: [PATCH 5/7] fix: align WebVTT detection with cue boundaries --- CHANGELOG.md | 5 ++++- docs/dubbing/translation-engines.md | 2 ++ frontend/src/test/dubPasteTranslation.test.jsx | 5 +++++ frontend/src/utils/pasteTranslations.js | 2 +- 4 files changed, 12 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 96c19c29..693d0ab6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,10 @@ the frozen-backend fallback mirror it for their toolchains. - Handle missing Electron signing credentials and retry packaging fixes without moving release tags (#2157) +### Fixed + +- Parse pasted WebVTT cues without treating mid-sentence timestamps as subtitle records (#2077) — thanks @kevin9327! + ## [0.5.3] — 2026-09-17 **Highlights** @@ -70,7 +74,6 @@ the frozen-backend fallback mirror it for their toolchains. - Video previews show their thumbnail before playback, including the source video in Dub (#2129) - Linux and Windows workspace headers consistently expand and collapse the sidebar, with the app logo at the top of the collapsed rail (#2129) - Stopping a process on macOS no longer fails with "Operation not permitted" when it was already exiting (#2032) -- A WebVTT file loaded into Paste translation maps its cues, instead of its timing lines, cue labels or notes (#2077) - A YouTube link blocked by its "not a bot" check now says how to attach signed-in cookies in Dub, instead of quoting yt-dlp's command-line flags (#2036, #2034) - An engine that fails to start now says whether it timed out, crashed (with its exit code and last output) or answered wrongly, instead of "did not signal ready: None" (#2037, #2026) - Transcribing an M4A file with PyTorch Whisper works, instead of failing with "Format not recognised" (#2042, #2039) diff --git a/docs/dubbing/translation-engines.md b/docs/dubbing/translation-engines.md index f1654ca5..6e50aedd 100644 --- a/docs/dubbing/translation-engines.md +++ b/docs/dubbing/translation-engines.md @@ -234,3 +234,5 @@ panels instead so neither editor becomes unusably small. from-source checkout. - **Installed it but still "needs install"** — restart the backend so Python picks up the newly-installed module. + +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. diff --git a/frontend/src/test/dubPasteTranslation.test.jsx b/frontend/src/test/dubPasteTranslation.test.jsx index 99d872d8..42d9159e 100644 --- a/frontend/src/test/dubPasteTranslation.test.jsx +++ b/frontend/src/test/dubPasteTranslation.test.jsx @@ -426,3 +426,8 @@ 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 209130ff..0ab2116b 100644 --- a/frontend/src/utils/pasteTranslations.js +++ b/frontend/src/utils/pasteTranslations.js @@ -26,7 +26,7 @@ // 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 = /(?:\d{1,2}:)?[0-5]?\d:[0-5]?\d[,.]\d{1,3}\s*-->/; +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+(.*)$/; From b900130f296f7525b3657672f16780311d6184e2 Mon Sep 17 00:00:00 2001 From: Palash Debnath <4178343+debpalash@users.noreply.github.com> Date: Thu, 17 Sep 2026 12:31:49 +0530 Subject: [PATCH 6/7] fix: address updated review findings and regressions --- backend/services/srt_parser.py | 18 ++++++++++++------ tests/test_srt_parser.py | 6 ++++++ 2 files changed, 18 insertions(+), 6 deletions(-) diff --git a/backend/services/srt_parser.py b/backend/services/srt_parser.py index 1a2a2380..b33cbe23 100644 --- a/backend/services/srt_parser.py +++ b/backend/services/srt_parser.py @@ -124,12 +124,18 @@ def parse_srt(content: str) -> SrtParseResult: 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. - marker = re.search(r"(?:^|\n)([ \t]*[0-9]+[ \t]*)\n?[ \t]*\Z", body) if has_next and not is_webvtt else None - if marker: - before = body[:marker.start(1)] - separated = bool(re.search(r"\n[ \t]*\n[ \t]*$", before)) - if separated or indexed: - body = before + 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: diff --git a/tests/test_srt_parser.py b/tests/test_srt_parser.py index 1d261b13..93807c9e 100644 --- a/tests/test_srt_parser.py +++ b/tests/test_srt_parser.py @@ -340,3 +340,9 @@ def test_mixed_indexed_and_unindexed_cues_preserve_numbers(first_index): 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' From 724e573746afd2e33c95046fba07afaa9406c989 Mon Sep 17 00:00:00 2001 From: Palash Debnath <4178343+debpalash@users.noreply.github.com> Date: Thu, 17 Sep 2026 12:35:55 +0530 Subject: [PATCH 7/7] style: apply frontend formatter to import regression tests --- frontend/src/test/dubPasteTranslation.test.jsx | 1 - 1 file changed, 1 deletion(-) diff --git a/frontend/src/test/dubPasteTranslation.test.jsx b/frontend/src/test/dubPasteTranslation.test.jsx index 42d9159e..5d62beab 100644 --- a/frontend/src/test/dubPasteTranslation.test.jsx +++ b/frontend/src/test/dubPasteTranslation.test.jsx @@ -427,7 +427,6 @@ describe('DubPasteTranslationDialog', () => { }); }); - it('keeps an hourless timestamp embedded in prose as plain text', () => { expect(detectPasteMode('Continue at 01:30.000 --> the finale')).toBe('plain'); });