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>
This commit is contained in:
co-authored by
Claude Opus 5
parent
eaf8bb9538
commit
3cd67811e5
@@ -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}")
|
||||
|
||||
@@ -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, ".")
|
||||
|
||||
@@ -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:SS<sep>mmm` 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}"
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user