fix(subtitle): rebase live-clock dash webvtt cues by presentationTimeOffset

Live-to-VOD replays carry cue times on the live media clock, which overflowed the SRT conversion and dropped every cue.
This commit is contained in:
imSp4rky
2026-10-25 13:15:34 -06:00
parent cb2ffdb55a
commit 2dc458f8b4
3 changed files with 43 additions and 3 deletions
+14 -1
View File
@@ -10,7 +10,7 @@ survive, while duplicate headers collapse and boundary-duplicate cues dedupe.
from __future__ import annotations
from unshackle.core.utils.webvtt import merge_segmented_webvtt
from unshackle.core.utils.webvtt import merge_segmented_webvtt, rebase_webvtt, timestamp_ms
# Two segments, each a full WebVTT document with its own X-TIMESTAMP-MAP header,
# as they arrive concatenated from a DASH/HLS subtitle stream. The first cue carries
@@ -118,3 +118,16 @@ def test_merge_skips_malformed_timing_line():
out = merge_segmented_webvtt(vtt)
assert "Good" in out
assert "Broken" not in out
def test_rebase_moves_live_clock_cues_to_zero():
vtt = "WEBVTT\n\n498024:05:44.300 --> 498024:05:44.829 line:0.01%\nText\n"
out = rebase_webvtt(vtt, timestamp_ms("498024:00:14.700"))
assert "00:05:29.600 --> 00:05:30.129 line:0.01%\nText" in out
def test_rebase_leaves_presentation_time_cues_alone():
# a second pass, or a VOD period with a non-zero offset, must not shift again
vtt = "WEBVTT\n\n00:05:29.600 --> 00:05:30.129\nText\n"
assert rebase_webvtt(vtt, 1_792_886_414_700) == vtt
assert rebase_webvtt(vtt, 0) == vtt
+9 -1
View File
@@ -27,7 +27,7 @@ from unshackle.core.config import config
from unshackle.core.tracks.track import Track
from unshackle.core.utilities import try_ensure_utf8, with_script
from unshackle.core.utils.subprocess import log_tool_run
from unshackle.core.utils.webvtt import merge_segmented_webvtt
from unshackle.core.utils.webvtt import merge_segmented_webvtt, rebase_webvtt
# silence srt library INFO logging
logging.getLogger("srt").setLevel(logging.ERROR)
@@ -280,6 +280,14 @@ class Subtitle(Track):
text = self.path.read_text("utf8")
text = Subtitle.strip_webvtt_timestamp_map(text)
if self.descriptor == Track.Descriptor.DASH:
from unshackle.core.manifests import DASH
template = DASH.merge_segment_templates(
self.data["dash"]["adaptation_set"], self.data["dash"]["representation"]
)
if template is not None:
offset = template.get("presentationTimeOffset", "0")
text = rebase_webvtt(text, int(offset) * 1000 // int(template.get("timescale", "1")))
if len(self.data["dash"]["segment_durations"]) > 1:
text = merge_segmented_webvtt(
text,
+20 -1
View File
@@ -1,8 +1,10 @@
import re
from typing import Optional
TIMESTAMP = r"(?:\d+:)?\d{2}:\d{2}\.\d{3}"
# Timing line: optional hours, verbatim settings tail.
TIMING = re.compile(r"^((?:\d+:)?\d{2}:\d{2}\.\d{3})[ \t]+-->[ \t]+((?:\d+:)?\d{2}:\d{2}\.\d{3})(.*)$")
TIMING = re.compile(rf"^({TIMESTAMP})[ \t]+-->[ \t]+({TIMESTAMP})(.*)$")
CUE_TIMES = re.compile(rf"(?m)^([ \t]*)({TIMESTAMP})([ \t]+-->[ \t]+)({TIMESTAMP})")
def timestamp_ms(ts: str) -> int:
@@ -75,3 +77,20 @@ def merge_segmented_webvtt(vtt_raw: str, segment_durations: Optional[list[int]]
blocks = headers + [f"{c[0]} --> {c[1]}{c[2]}\n{c[3]}" for c in cues]
return "WEBVTT\n\n" + "\n\n".join(blocks) + "\n"
def rebase_webvtt(text: str, offset_ms: int) -> str:
"""
Subtract offset_ms from every cue time when the cues sit on the media timeline of a DASH
period whose presentationTimeOffset is offset_ms, as a live replay's do. Cues that start
below the offset are already presentation times, so the text stays as it is.
"""
first = CUE_TIMES.search(text)
if offset_ms <= 0 or not first or timestamp_ms(first[2]) < offset_ms:
return text
def shift(ts: str) -> str:
ms = max(timestamp_ms(ts) - offset_ms, 0)
return f"{ms // 3_600_000:02}:{ms // 60_000 % 60:02}:{ms // 1000 % 60:02}.{ms % 1000:03}"
return CUE_TIMES.sub(lambda m: f"{m[1]}{shift(m[2])}{m[3]}{shift(m[4])}", text)