After transcription the user can paste a translation produced elsewhere
(ChatGPT, DeepL, a human translator) and have it map onto the segments
that already exist — no re-transcription, no timing loss.
Three input shapes are auto-detected: a timestamped .srt/.vtt (cues matched
to segments by time overlap, greedy one-to-one so one long cue can't be
copied onto several rows), numbered lines (`1.` / `2)` / `[3]`, mapped by
number and falling back to order when a model renumbers mid-answer), and
plain lines (positional, blank lines treated as separators rather than
empty translations). Nothing is applied until the preview dialog has shown
every row as before→after with unmatched rows flagged.
Applying goes through `pasteTranslations` in useSegmentEditing, which
mirrors `segmentEditField`'s duties across rows in ONE undo step: write
`text` and `translations[dubLangCode]` in lock-step and clear the stale
machine-translation badges. It never writes `text_original` (the translate
source `handleTranslateAll` reads — overwriting it would poison every later
re-translate) and never touches a language other than the active one.
Changing `text` alone marks those rows stale via the existing per-language
fingerprints, so no new flag is needed.
The new `POST /dub/parse-subtitle-text` is a stateless wrapper over the
existing `services.srt_parser.parse_srt`, so the lenient cue parsing stays
single-sourced instead of being reimplemented in JavaScript.
Also fixes a ReDoS in that parser, reachable today via /dub/import-srt:
`_TIMING_RE` used `^\s*` under re.MULTILINE, so at every line start the
engine consumed all remaining blank lines before failing on the first
digit — quadratic. 20k blank lines already took 1.7s and a 2 MB blank-line
file never returned, pinning the request thread. Horizontal-whitespace-only
classes make the scan linear.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Closes#52. Users who already have correct, pre-synced subtitles can now
skip ASR entirely — they upload a video as normal and then hit "Import
.srt" instead of "Upload & Transcribe". The .srt cues populate the dub
segment list directly, so the rest of the pipeline (translate, dub,
export) just works.
Backend
- services/srt_parser.py: lenient SubRip parser. Tolerates BOM, CRLF,
missing index numbers, dot-vs-comma ms separator, and overlap (shifts
the later cue's start to the earlier's end rather than dropping). Skips
cues with non-positive duration or empty bodies; reports counts so the
UI can warn.
- dub_core.py: new POST /dub/import-srt/{job_id} accepts the .srt file,
parses it, clamps cues that run past the source media's duration, and
replaces job["segments"]. Tries UTF-8 with BOM first, falls back to
latin-1 for legacy Windows subs.
Frontend
- api/dub.ts: dubImportSrt helper with a typed response.
- hooks/useDubWorkflow.js: handleDubImportSrt — sets segments, flips
dubStep to 'editing', shows a toast with per-bucket counts (imported /
skipped / overlap-shifted / clamped) so the user sees what happened.
- pages/DubTab.jsx: "Import .srt" button next to "Upload & Transcribe"
once a job exists, plus a smaller "Import .srt instead" affordance in
the transcription-failure banner — the exact recovery path the
reporter asked for.
Tests
- tests/test_srt_parser.py: 12 cases covering well-formed input,
multi-line cues, dot-as-separator, BOM, CRLF, malformed cues, empty
bodies, overlap shift, overlap-becomes-zero-drop, missing indices,
empty input, and segment shape (sequential ids, speaker filler).
pytest is now 226 passed (was 214); vitest unchanged at 11.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>