Smart Fit Phase A (planner) + the export-side video retime + audio stretch already shipped (#347 + dub_export stretch filter). The last piece of Spec 1 was the subtitle timeline: under stretch_video the dubbed audio plays at FITTED positions, but the standalone SRT/VTT export still used the original segment times — so external subtitles drifted against the dubbed video. - services/fitted_subtitles.py (pure, tested): map_time_to_fitted() + fitted_cues() remap original cue times onto the same per-chunk {orig→new, stretch_ratio} plan the video stretch uses, with a monotonicity guard. - dub_export SRT + VTT endpoints: when a job used stretch_video, cues are regenerated from the plan (subtitles track actual dub placement); no plan → original times, unchanged. New optional ?lang= selects the track. 7 pure tests (chunk-bound mapping, linear interpolation, unit-rate tail, fitted cues, monotonicity, empty-plan identity). Spec 1 (remaining) / parity program Wave 3.1. Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
53 lines
1.8 KiB
Python
53 lines
1.8 KiB
Python
"""Fitted-timeline subtitle remapping (Wave 3.1 / Spec 1) — pure, no I/O."""
|
||
|
||
import pytest
|
||
|
||
from services.fitted_subtitles import fitted_cues, map_time_to_fitted
|
||
|
||
# A 2-chunk plan: chunk 0 [0,4]→[0,6] (1.5× slow), chunk 1 [4,8]→[6,10] (1× — gap).
|
||
PLAN = [
|
||
{"orig_start": 0.0, "orig_end": 4.0, "new_start": 0.0, "new_end": 6.0, "stretch_ratio": 1.5},
|
||
{"orig_start": 4.0, "orig_end": 8.0, "new_start": 6.0, "new_end": 10.0, "stretch_ratio": 1.0},
|
||
]
|
||
|
||
|
||
def test_empty_plan_is_identity():
|
||
assert map_time_to_fitted(3.7, []) == 3.7
|
||
|
||
|
||
def test_chunk_start_and_end_map_to_fitted_bounds():
|
||
assert map_time_to_fitted(0.0, PLAN) == pytest.approx(0.0)
|
||
assert map_time_to_fitted(4.0, PLAN) == pytest.approx(6.0)
|
||
assert map_time_to_fitted(8.0, PLAN) == pytest.approx(10.0)
|
||
|
||
|
||
def test_midpoint_interpolates_linearly():
|
||
# halfway through chunk 0 (t=2 of [0,4]) → halfway of [0,6] = 3.0
|
||
assert map_time_to_fitted(2.0, PLAN) == pytest.approx(3.0)
|
||
|
||
|
||
def test_time_past_last_chunk_carries_at_unit_rate():
|
||
assert map_time_to_fitted(9.0, PLAN) == pytest.approx(11.0) # 10 + (9-8)
|
||
|
||
|
||
def test_fitted_cues_uses_new_timeline():
|
||
segs = [{"start": 0.0, "end": 2.0}, {"start": 4.0, "end": 6.0}]
|
||
cues = fitted_cues(segs, PLAN)
|
||
assert cues[0] == (pytest.approx(0.0), pytest.approx(3.0))
|
||
# seg 2: 4.0→6.0, 6.0→8.0
|
||
assert cues[1] == (pytest.approx(6.0), pytest.approx(8.0))
|
||
|
||
|
||
def test_fitted_cues_are_monotone():
|
||
# Overlapping/odd inputs must still produce a non-decreasing cue stream.
|
||
segs = [{"start": 3.9, "end": 4.1}, {"start": 4.0, "end": 4.0}]
|
||
cues = fitted_cues(segs, PLAN)
|
||
for s, e in cues:
|
||
assert e >= s
|
||
assert cues[1][0] >= cues[0][1] - 1e-9
|
||
|
||
|
||
def test_empty_plan_cues_match_original():
|
||
segs = [{"start": 1.0, "end": 2.5}]
|
||
assert fitted_cues(segs, []) == [(pytest.approx(1.0), pytest.approx(2.5))]
|