Files
VoiceStudio/tests/test_subtitle_timestamp_rounding.py
kevin9327andClaude Opus 5 3cd67811e5 fix(subtitles): exported cue times keep their milliseconds
The SRT/VTT formatters in dub_export and openai_compat 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 and /dub/vtt
downloads, burned-in subtitles, and /v1/audio/transcriptions srt/vtt.

All four now call srt_parser.format_cue_timestamp, which rounds the whole
value to milliseconds once and splits it, so 59.9996 carries to
00:01:00,000 rather than printing ",1000" -- the same round-then-divmod
shape karaoke_ass._ass_time already uses.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 08:31:19 +09:00

94 lines
3.1 KiB
Python

"""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