Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ the frozen-backend fallback mirror it for their toolchains.
- An engine that fails to start now says whether it timed out, crashed (with its exit code and last output) or answered wrongly, instead of "did not signal ready: None" (#2037, #2026)
- Transcribing an M4A file with PyTorch Whisper works, instead of failing with "Format not recognised" (#2042, #2039)
- PyTorch Whisper runs on 6 GB NVIDIA cards instead of falling back to CPU, because its memory check now fits the model it loads (#2044, #2041)
- Exported SRT and VTT subtitles keep each cue's milliseconds, instead of moving many cues 1 ms early (#2074)
- MCP tools wait as long as the backend does, so a long transcription no longer fails at 120 s with an empty error (#2043, #2040)

### CI
Expand Down
14 changes: 4 additions & 10 deletions backend/api/routers/dub_export.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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}")
Expand Down
14 changes: 4 additions & 10 deletions backend/api/routers/openai_compat.py
Original file line number Diff line number Diff line change
Expand Up @@ -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, ".")
16 changes: 16 additions & 0 deletions backend/services/srt_parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -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}"
93 changes: 93 additions & 0 deletions tests/test_subtitle_timestamp_rounding.py
Original file line number Diff line number Diff line change
@@ -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