Files
VoiceStudio/tests/test_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

162 lines
3.8 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.
"""Unit tests for the SRT parser used by /dub/import-srt."""
import sys
import os
import pytest
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "backend"))
from services.srt_parser import parse_srt # noqa: E402
def test_parses_a_well_formed_srt_file():
srt = """1
00:00:01,000 --> 00:00:04,500
Hello world.
2
00:00:05,250 --> 00:00:08,000
Second line here.
"""
result = parse_srt(srt)
assert result.skipped_cues == 0
assert result.dropped_overlaps == 0
assert len(result.segments) == 2
assert result.segments[0]["start"] == 1.0
assert result.segments[0]["end"] == 4.5
assert result.segments[0]["text"] == "Hello world."
assert result.segments[1]["start"] == 5.25
assert result.segments[1]["text"] == "Second line here."
def test_joins_multi_line_cues_with_newline():
srt = """1
00:00:01,000 --> 00:00:04,000
Line one
Line two
"""
result = parse_srt(srt)
assert result.segments[0]["text"] == "Line one\nLine two"
def test_accepts_dot_as_milliseconds_separator():
# WebVTT-style timestamps inside an SRT file are common in the wild.
srt = """1
00:00:01.500 --> 00:00:03.250
Dotty.
"""
result = parse_srt(srt)
assert result.segments[0]["start"] == 1.5
assert result.segments[0]["end"] == 3.25
def test_handles_utf8_bom_at_start_of_file():
srt = "1\n00:00:01,000 --> 00:00:02,000\nBOM cue.\n"
result = parse_srt(srt)
assert len(result.segments) == 1
assert result.segments[0]["text"] == "BOM cue."
def test_handles_crlf_line_endings():
srt = "1\r\n00:00:01,000 --> 00:00:02,000\r\nWindows.\r\n\r\n"
result = parse_srt(srt)
assert len(result.segments) == 1
def test_skips_cue_with_non_positive_duration():
srt = """1
00:00:05,000 --> 00:00:05,000
Zero duration.
2
00:00:06,000 --> 00:00:05,500
End before start.
3
00:00:07,000 --> 00:00:08,000
Good cue.
"""
result = parse_srt(srt)
assert result.skipped_cues == 2
assert len(result.segments) == 1
assert result.segments[0]["text"] == "Good cue."
def test_skips_cue_with_only_whitespace_body():
srt = """1
00:00:01,000 --> 00:00:02,000
2
00:00:03,000 --> 00:00:04,000
Real text.
"""
result = parse_srt(srt)
assert result.skipped_cues == 1
assert len(result.segments) == 1
def test_shifts_overlapping_cue_to_keep_both():
# Cue 2 starts inside cue 1; we expect cue 2's start to be pushed
# forward to cue 1's end so both still play, in order.
srt = """1
00:00:01,000 --> 00:00:05,000
First.
2
00:00:03,000 --> 00:00:07,000
Second.
"""
result = parse_srt(srt)
assert len(result.segments) == 2
assert result.segments[1]["start"] == 5.0
assert result.segments[1]["end"] == 7.0
assert result.dropped_overlaps == 0
def test_drops_overlap_that_would_have_negative_duration():
srt = """1
00:00:01,000 --> 00:00:10,000
First.
2
00:00:03,000 --> 00:00:08,000
Second entirely inside first.
"""
result = parse_srt(srt)
assert len(result.segments) == 1
assert result.dropped_overlaps == 1
def test_parses_missing_index_numbers():
# No "1", "2" lines — just timings + text. This happens with some
# subtitle editors that strip indices.
srt = """00:00:01,000 --> 00:00:02,000
First.
00:00:03,000 --> 00:00:04,000
Second.
"""
result = parse_srt(srt)
assert len(result.segments) == 2
def test_returns_empty_result_for_empty_input():
assert parse_srt("").segments == []
assert parse_srt("").skipped_cues == 0
def test_segments_get_sequential_ids_and_required_fields():
srt = """1
00:00:01,000 --> 00:00:02,000
A
2
00:00:03,000 --> 00:00:04,000
B
"""
result = parse_srt(srt)
for i, seg in enumerate(result.segments):
assert seg["id"] == i
assert seg["text"] == seg["text_original"]
assert seg["speaker_id"] == "Speaker 1"