Merge branch 'fix/review-2077' into fix/community-integration

# Conflicts:
#	CHANGELOG.md
#	docs/dubbing/translation-engines.md
This commit is contained in:
Palash Debnath
2026-09-17 12:38:37 +05:30
6 changed files with 232 additions and 12 deletions
+3
View File
@@ -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**
+54 -9
View File
@@ -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
+5
View File
@@ -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.
@@ -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');
});
+4 -3
View File
@@ -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+(.*)$/;
+157
View File
@@ -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'