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>
162 lines
3.8 KiB
Python
162 lines
3.8 KiB
Python
"""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"
|