diff --git a/tests/tracks/test_webvtt_merge.py b/tests/tracks/test_webvtt_merge.py index 93ea61ce..915c88a5 100644 --- a/tests/tracks/test_webvtt_merge.py +++ b/tests/tracks/test_webvtt_merge.py @@ -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 diff --git a/unshackle/core/tracks/subtitle.py b/unshackle/core/tracks/subtitle.py index 3d6858f4..b362e7c9 100644 --- a/unshackle/core/tracks/subtitle.py +++ b/unshackle/core/tracks/subtitle.py @@ -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, diff --git a/unshackle/core/utils/webvtt.py b/unshackle/core/utils/webvtt.py index 8a302ea0..d7d060e3 100644 --- a/unshackle/core/utils/webvtt.py +++ b/unshackle/core/utils/webvtt.py @@ -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)