Files
unshackle/unshackle/core/utils/webvtt.py
T
imSp4rky 2dc458f8b4 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.
2026-10-25 13:15:34 -06:00

97 lines
4.2 KiB
Python

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(rf"^({TIMESTAMP})[ \t]+-->[ \t]+({TIMESTAMP})(.*)$")
CUE_TIMES = re.compile(rf"(?m)^([ \t]*)({TIMESTAMP})([ \t]+-->[ \t]+)({TIMESTAMP})")
def timestamp_ms(ts: str) -> int:
hms, ms = ts.rsplit(".", 1)
parts = [int(p) for p in hms.split(":")]
while len(parts) < 3:
parts.insert(0, 0)
hours, minutes, seconds = parts
return ((hours * 60 + minutes) * 60 + seconds) * 1000 + int(ms)
def merge_segmented_webvtt(vtt_raw: str, segment_durations: Optional[list[int]] = None, timescale: int = 1) -> str:
"""
Merge concatenated Segmented WebVTT documents into a single document.
Strips the per-segment WEBVTT and X-TIMESTAMP-MAP headers and splices cues
repeated across a segment boundary (identical payload, <=1ms gap or overlap).
Timing lines, cue settings and payload are kept verbatim, so inline formatting
(span tags, entities, ASS-style overrides) survives untouched.
This function applies no timestamp offset: the segments must already carry absolute
cue times. This is the same guard as shaka-player and N_m3u8DL-RE, because
re-offsetting double-shifts such documents. This function accepts
segment_durations/timescale for call-site compatibility. They only matter if
someone reintroduces offsetting.
This function keeps unique STYLE/REGION blocks (players that cannot read them ignore
them). It drops cue identifiers and NOTE blocks. It skips a cue with a malformed
timing line (downstream sanitizers catch worse).
"""
# each kept cue: [start, end, settings, payload, normalized payload for dedupe]
cues: list[list[str]] = []
headers: list[str] = [] # unique STYLE/REGION blocks, first-seen order
prev: Optional[list[str]] = None
prev_end_ms = 0
for block in re.split(r"\n[ \t]*\n", vtt_raw.replace("\r\n", "\n").replace("\r", "\n")):
lines = [
line
for line in block.split("\n")
if not (line.startswith("WEBVTT") or line.lstrip().startswith("X-TIMESTAMP-MAP"))
]
timing_i = next((i for i, line in enumerate(lines) if "-->" in line), None)
if timing_i is None: # NOTE or other metadata block
first = next((line.strip() for line in lines if line.strip()), "")
if first.startswith(("STYLE", "REGION")):
header = "\n".join(lines).strip("\n")
if header not in headers: # segments repeat the same block
headers.append(header)
continue
timing = TIMING.match(lines[timing_i].strip())
if not timing:
continue
start, end, settings = timing.groups()
payload = [line for line in lines[timing_i + 1 :] if line.strip()]
if not payload:
continue
normalized = "\n".join(line.strip() for line in payload)
if prev is not None and timestamp_ms(start) - prev_end_ms <= 1 and normalized == prev[4]:
end_ms = timestamp_ms(end)
if end_ms > prev_end_ms: # splice: extend only, never shorten the kept cue
prev[1] = end
prev_end_ms = end_ms
continue
prev = [start, end, settings, "\n".join(payload), normalized]
prev_end_ms = timestamp_ms(end)
cues.append(prev)
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)