mirror of
https://github.com/unshackle-dl/unshackle.git
synced 2026-11-12 20:15:10 +00:00
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:
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user