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):
|
def _format_srt_time(seconds):
|
||||||
h = int(seconds // 3600)
|
from services.srt_parser import format_cue_timestamp
|
||||||
m = int((seconds % 3600) // 60)
|
return format_cue_timestamp(seconds, ",")
|
||||||
s = int(seconds % 60)
|
|
||||||
ms = int((seconds % 1) * 1000)
|
|
||||||
return f"{h:02d}:{m:02d}:{s:02d},{ms:03d}"
|
|
||||||
|
|
||||||
def _pick_subtitle_text(seg: dict, dual: bool) -> str:
|
def _pick_subtitle_text(seg: dict, dual: bool) -> str:
|
||||||
"""One line per subtitle cue, unless dual=true and an original exists.
|
"""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):
|
def _format_vtt_time(seconds):
|
||||||
h = int(seconds // 3600)
|
from services.srt_parser import format_cue_timestamp
|
||||||
m = int((seconds % 3600) // 60)
|
return format_cue_timestamp(seconds, ".")
|
||||||
s = int(seconds % 60)
|
|
||||||
ms = int((seconds % 1) * 1000)
|
|
||||||
return f"{h:02d}:{m:02d}:{s:02d}.{ms:03d}"
|
|
||||||
|
|
||||||
@router.get("/dub/vtt/{job_id}")
|
@router.get("/dub/vtt/{job_id}")
|
||||||
@router.get("/dub/vtt/{job_id}/{filename}")
|
@router.get("/dub/vtt/{job_id}/{filename}")
|
||||||
|
|||||||
@@ -721,17 +721,11 @@ def list_voices():
|
|||||||
|
|
||||||
def _format_ts_srt(seconds: float) -> str:
|
def _format_ts_srt(seconds: float) -> str:
|
||||||
"""Format seconds as SRT timestamp: HH:MM:SS,mmm"""
|
"""Format seconds as SRT timestamp: HH:MM:SS,mmm"""
|
||||||
h = int(seconds // 3600)
|
from services.srt_parser import format_cue_timestamp
|
||||||
m = int((seconds % 3600) // 60)
|
return format_cue_timestamp(seconds, ",")
|
||||||
s = int(seconds % 60)
|
|
||||||
ms = int((seconds % 1) * 1000)
|
|
||||||
return f"{h:02d}:{m:02d}:{s:02d},{ms:03d}"
|
|
||||||
|
|
||||||
|
|
||||||
def _format_ts_vtt(seconds: float) -> str:
|
def _format_ts_vtt(seconds: float) -> str:
|
||||||
"""Format seconds as VTT timestamp: HH:MM:SS.mmm"""
|
"""Format seconds as VTT timestamp: HH:MM:SS.mmm"""
|
||||||
h = int(seconds // 3600)
|
from services.srt_parser import format_cue_timestamp
|
||||||
m = int((seconds % 3600) // 60)
|
return format_cue_timestamp(seconds, ".")
|
||||||
s = int(seconds % 60)
|
|
||||||
ms = int((seconds % 1) * 1000)
|
|
||||||
return f"{h:02d}:{m:02d}:{s:02d}.{ms:03d}"
|
|
||||||
|
|||||||
@@ -126,3 +126,19 @@ def parse_srt(content: str) -> SrtParseResult:
|
|||||||
for i, seg in enumerate(out)
|
for i, seg in enumerate(out)
|
||||||
]
|
]
|
||||||
return SrtParseResult(segments=segments, skipped_cues=skipped, dropped_overlaps=dropped)
|
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