diff --git a/backend/api/routers/dub_export.py b/backend/api/routers/dub_export.py index 63087d46..042dcf6a 100644 --- a/backend/api/routers/dub_export.py +++ b/backend/api/routers/dub_export.py @@ -1657,11 +1657,8 @@ async def dub_download_audio( def _format_srt_time(seconds): - h = int(seconds // 3600) - m = int((seconds % 3600) // 60) - s = int(seconds % 60) - ms = int((seconds % 1) * 1000) - return f"{h:02d}:{m:02d}:{s:02d},{ms:03d}" + from services.srt_parser import format_cue_timestamp + return format_cue_timestamp(seconds, ",") def _pick_subtitle_text(seg: dict, dual: bool) -> str: """One line per subtitle cue, unless dual=true and an original exists. @@ -1745,11 +1742,8 @@ async def dub_export_srt( ) def _format_vtt_time(seconds): - h = int(seconds // 3600) - m = int((seconds % 3600) // 60) - s = int(seconds % 60) - ms = int((seconds % 1) * 1000) - return f"{h:02d}:{m:02d}:{s:02d}.{ms:03d}" + from services.srt_parser import format_cue_timestamp + return format_cue_timestamp(seconds, ".") @router.get("/dub/vtt/{job_id}") @router.get("/dub/vtt/{job_id}/{filename}") diff --git a/backend/api/routers/openai_compat.py b/backend/api/routers/openai_compat.py index 03e597fe..7ec030bc 100644 --- a/backend/api/routers/openai_compat.py +++ b/backend/api/routers/openai_compat.py @@ -721,17 +721,11 @@ def list_voices(): def _format_ts_srt(seconds: float) -> str: """Format seconds as SRT timestamp: HH:MM:SS,mmm""" - h = int(seconds // 3600) - m = int((seconds % 3600) // 60) - s = int(seconds % 60) - ms = int((seconds % 1) * 1000) - return f"{h:02d}:{m:02d}:{s:02d},{ms:03d}" + from services.srt_parser import format_cue_timestamp + return format_cue_timestamp(seconds, ",") def _format_ts_vtt(seconds: float) -> str: """Format seconds as VTT timestamp: HH:MM:SS.mmm""" - h = int(seconds // 3600) - m = int((seconds % 3600) // 60) - s = int(seconds % 60) - ms = int((seconds % 1) * 1000) - return f"{h:02d}:{m:02d}:{s:02d}.{ms:03d}" + from services.srt_parser import format_cue_timestamp + return format_cue_timestamp(seconds, ".") diff --git a/backend/services/srt_parser.py b/backend/services/srt_parser.py index 3b26e818..357cb87a 100644 --- a/backend/services/srt_parser.py +++ b/backend/services/srt_parser.py @@ -126,3 +126,19 @@ def parse_srt(content: str) -> SrtParseResult: for i, seg in enumerate(out) ] return SrtParseResult(segments=segments, skipped_cues=skipped, dropped_overlaps=dropped) + + +def format_cue_timestamp(seconds: float, ms_separator: str) -> str: + """`HH:MM:SSmmm` for `seconds`, rounded to the millisecond. + + Rounds the whole value once, then splits it, so a time that is not exact + in binary (2.3 is 2.29999...) stays 2.300 instead of truncating to 2.299, + which moved every such cue a millisecond early on export, and 59.9996 + carries to the next second instead of printing `,1000`. SRT separates + the milliseconds with `,`; WebVTT with `.`. + """ + total_ms = int(round(seconds * 1000)) + h, rem = divmod(total_ms, 3_600_000) + m, rem = divmod(rem, 60_000) + s, ms = divmod(rem, 1000) + return f"{h:02d}:{m:02d}:{s:02d}{ms_separator}{ms:03d}" diff --git a/tests/test_subtitle_timestamp_rounding.py b/tests/test_subtitle_timestamp_rounding.py new file mode 100644 index 00000000..d59fd789 --- /dev/null +++ b/tests/test_subtitle_timestamp_rounding.py @@ -0,0 +1,93 @@ +"""Exported subtitle cue times keep their milliseconds. + +The SRT/VTT formatters truncated ``(seconds % 1) * 1000``. Most decimal times +are not exact in binary (2.3 is 2.29999…), so a cue imported as +``00:00:02,300`` exported as ``00:00:02,299`` — every such cue moved a +millisecond early in the dub SRT/VTT downloads, the burned-in subtitles and +the OpenAI-compatible transcription's srt/vtt formats. +""" +from __future__ import annotations + +import os +import uuid + +import pytest + +os.environ.setdefault("OMNIVOICE_MODEL", "test") + +# (seconds, SRT form). 2.3 / 4.1 / 70.7 are the binary-inexact values an SRT +# import produces; 59.9996 must carry into the next second, never ",1000". +_CASES = [ + (0.0, "00:00:00,000"), + (2.3, "00:00:02,300"), + (4.1, "00:00:04,100"), + (70.7, "00:01:10,700"), + (3661.123, "01:01:01,123"), + (59.9996, "00:01:00,000"), +] + + +@pytest.mark.parametrize("seconds,expected", _CASES) +def test_dub_srt_and_vtt_times_round_to_the_millisecond(seconds, expected): + from api.routers.dub_export import _format_srt_time, _format_vtt_time + + assert _format_srt_time(seconds) == expected + assert _format_vtt_time(seconds) == expected.replace(",", ".") + + +@pytest.mark.parametrize("seconds,expected", _CASES) +def test_openai_compat_srt_and_vtt_times_round_to_the_millisecond(seconds, expected): + from api.routers.openai_compat import _format_ts_srt, _format_ts_vtt + + assert _format_ts_srt(seconds) == expected + assert _format_ts_vtt(seconds) == expected.replace(",", ".") + + +@pytest.fixture() +def imported_job(): + """A dub job carrying the cue times an imported .srt produces.""" + from services.dub_pipeline import _dub_jobs + from services.srt_parser import parse_srt + + segments = parse_srt( + "1\n00:00:02,300 --> 00:00:04,100\nFirst\n\n" + "2\n00:01:10,700 --> 00:01:12,900\nSecond\n" + ).segments + job_id = str(uuid.uuid4())[:8] + _dub_jobs[job_id] = { + "video_path": "/nonexistent/original.mp4", + "duration": 80.0, + "filename": "imported.mp4", + "segments": segments, + } + yield job_id, _dub_jobs[job_id] + _dub_jobs.pop(job_id, None) + + +_SRT_TIMINGS = ["00:00:02,300 --> 00:00:04,100", "00:01:10,700 --> 00:01:12,900"] + + +def test_imported_srt_cue_times_survive_the_srt_and_vtt_downloads(imported_job): + from fastapi.testclient import TestClient + from main import app + + job_id, _ = imported_job + client = TestClient(app, client=("127.0.0.1", 50000)) + + srt = client.get(f"/dub/srt/{job_id}") + assert srt.status_code == 200 + assert [line for line in srt.text.splitlines() if "-->" in line] == _SRT_TIMINGS + + vtt = client.get(f"/dub/vtt/{job_id}") + assert vtt.status_code == 200 + assert [line for line in vtt.text.splitlines() if "-->" in line] == [ + t.replace(",", ".") for t in _SRT_TIMINGS + ] + + +def test_imported_srt_cue_times_survive_burn_in(tmp_path, imported_job): + from api.routers.dub_export import _write_burn_srt + + _, job = imported_job + content = open(_write_burn_srt(job, str(tmp_path), "stamp", dual=False), encoding="utf-8").read() + assert [line for line in content.splitlines() if "-->" in line] == _SRT_TIMINGS