Merge branch 'fix/review-2077' into fix/community-integration
# Conflicts: # CHANGELOG.md # docs/dubbing/translation-engines.md
This commit is contained in:
@@ -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**
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
@@ -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+(.*)$/;
|
||||
|
||||
@@ -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'
|
||||
|
||||
Reference in New Issue
Block a user