Files
VoiceStudio/backend/services/srt_parser.py
T
Palash DebnathandClaude Opus 4.7 e46b4e3d47 feat: import .srt subtitles to bypass Whisper (closes #52)
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>
2026-05-16 12:39:01 +05:30

121 lines
4.3 KiB
Python
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""SRT (SubRip subtitle) parser.
Lenient by design — many "SRT" files in the wild are slightly off-spec
(missing index numbers, blank-line variants, BOM, `.` instead of `,` in
the milliseconds separator). We accept what we can, drop what we can't,
and report counts so the caller can warn the user.
Returns a list of segments compatible with the dub-pipeline shape used
elsewhere in the backend:
{
"id": int,
"start": float, # seconds
"end": float, # seconds
"text": str,
"text_original": str, # same as `text` on import; mutable later
"speaker_id": "Speaker 1", # filler — no diarization on raw .srt
}
"""
from __future__ import annotations
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})"
# 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"^\s*{_TS}\s*-->\s*{_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
@dataclass
class SrtParseResult:
segments: list[dict]
skipped_cues: int # malformed cues we couldn't recover
dropped_overlaps: int # cues that overlapped a kept one
def parse_srt(content: str) -> SrtParseResult:
"""Parse SRT text and return cleaned, non-overlapping segments.
- Skips cues with non-positive duration or unparseable timestamps.
- When two cues overlap, keeps the earlier one and shifts the later
one's `start` forward to the earlier's `end` (rather than dropping
it outright — overlapping is common in captions and the user's
intent is usually "both lines should play, in order"). If the
adjustment leaves the later cue with zero/negative duration it
gets dropped and `dropped_overlaps` increments.
"""
if not content:
return SrtParseResult([], 0, 0)
# Strip BOM and normalise line endings; many editors save SRTs as CRLF.
text = content.lstrip("").replace("\r\n", "\n").replace("\r", "\n")
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))
for i, m in enumerate(matches):
try:
start = _ts_to_seconds(m.group(1), m.group(2), m.group(3), m.group(4))
end = _ts_to_seconds(m.group(5), m.group(6), m.group(7), m.group(8))
except (ValueError, IndexError):
skipped += 1
continue
if end <= start:
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()
cue_text = "\n".join(line.strip() for line in lines if line.strip())
if not cue_text:
skipped += 1
continue
raw.append({"start": start, "end": end, "text": cue_text})
raw.sort(key=lambda r: r["start"])
# De-overlap pass.
out: list[dict] = []
dropped = 0
last_end = 0.0
for r in raw:
s, e = r["start"], r["end"]
if s < last_end:
s = last_end
if e <= s:
dropped += 1
continue
out.append({"start": s, "end": e, "text": r["text"]})
last_end = e
segments = [
{
"id": i,
"start": round(seg["start"], 3),
"end": round(seg["end"], 3),
"text": seg["text"],
"text_original": seg["text"],
"speaker_id": "Speaker 1",
}
for i, seg in enumerate(out)
]
return SrtParseResult(segments=segments, skipped_cues=skipped, dropped_overlaps=dropped)