mirror of
https://github.com/unshackle-dl/unshackle.git
synced 2026-11-14 13:05:09 +00:00
Live-to-VOD replays carry cue times on the live media clock, which overflowed the SRT conversion and dropped every cue.
97 lines
4.2 KiB
Python
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)
|