mirror of
https://github.com/unshackle-dl/unshackle.git
synced 2026-11-11 19:45:06 +00:00
fix(subtitle): join cues that a segment boundary splits in stpp and wvtt
Share one append_cue join across DASH fTTML, ISM and fVTT parsing. Also map "geo-block" and "geolocation" errors to GEOFENCE in the API.
This commit is contained in:
@@ -52,7 +52,7 @@ format-appropriate clean-up to make a single, valid file:
|
||||
`‎` / `‏` bidirectional entities.
|
||||
- **Segment merging**: when a WebVTT or boxed subtitle arrives as many DASH/HLS
|
||||
segments, unshackle merges the segments back into one continuous document with
|
||||
correct timing.
|
||||
correct timing. A cue that a segment boundary cuts in two comes out as one cue.
|
||||
- **WebVTT sanitization**: merged WebVTT runs through a repair pipeline that clamps
|
||||
negative timestamps to `00:00:00.000`, removes stray cue identifiers (like `Q0`, `Q1`)
|
||||
that confuse downstream parsers, and merges multi-line cues that a service split into
|
||||
|
||||
@@ -114,6 +114,8 @@ def test_build_error_response_debug_mode_includes_traceback() -> None:
|
||||
(TimeoutError("read timeout"), APIErrorCode.NETWORK_ERROR),
|
||||
(Exception("Not available in your region"), APIErrorCode.GEOFENCE),
|
||||
(Exception("Service is geoblocked in GB"), APIErrorCode.GEOFENCE),
|
||||
(Exception("Content is geo-blocked."), APIErrorCode.GEOFENCE),
|
||||
(Exception("Manifest request failed: GeoLocationBlocked"), APIErrorCode.GEOFENCE),
|
||||
(
|
||||
Exception("Service is not available in your region (GB)"),
|
||||
APIErrorCode.GEOFENCE,
|
||||
|
||||
@@ -351,6 +351,20 @@ def test_segmented_fvtt_parses_and_converts(tmp_path, monkeypatch):
|
||||
assert cue_count(out) == 2
|
||||
|
||||
|
||||
def test_segmented_fttml_joins_a_cue_split_at_a_segment_boundary():
|
||||
data = build_stpp_mp4(
|
||||
ttml_fragment(("00:00:01.000", "00:00:02.000", "Carried")),
|
||||
ttml_fragment(("00:00:02.000", "00:00:03.500", "Carried"), ("00:00:03.500", "00:00:04.000", "Next")),
|
||||
)
|
||||
|
||||
captions = Subtitle.parse(data, Codec.fTTML).get_captions("en")
|
||||
|
||||
assert [(c.start / 1e6, c.end / 1e6, c.get_text()) for c in captions] == [
|
||||
(1.0, 3.5, "Carried"),
|
||||
(3.5, 4.0, "Next"),
|
||||
]
|
||||
|
||||
|
||||
def test_segmented_fttml_parses_and_converts(tmp_path, monkeypatch):
|
||||
monkeypatch.setattr("unshackle.core.config.config.subtitle", {}, raising=False)
|
||||
frag = (
|
||||
@@ -448,8 +462,8 @@ def test_dash_fvtt_cues_lose_the_presentation_time_offset(tmp_path, monkeypatch)
|
||||
assert "00:00:02.000 --> 00:00:03.000" in text
|
||||
|
||||
|
||||
def ttml_fragment(*cues: tuple[str, str]) -> str:
|
||||
body = "".join(f'<p begin="{begin}" end="{end}">Line</p>' for begin, end in cues)
|
||||
def ttml_fragment(*cues: tuple[str, ...]) -> str:
|
||||
body = "".join(f'<p begin="{cue[0]}" end="{cue[1]}">{cue[2] if len(cue) > 2 else "Line"}</p>' for cue in cues)
|
||||
return f'<tt xmlns="http://www.w3.org/ns/ttml" xml:lang="en"><body><div>{body}</div></body></tt>'
|
||||
|
||||
|
||||
|
||||
@@ -208,7 +208,10 @@ def categorize_exception(
|
||||
exc_str = f"{exc} {root}".lower()
|
||||
exc_type = type(root).__name__
|
||||
|
||||
if any(keyword in exc_str for keyword in ["geofence", "geoblock", "region", "not available in", "territory"]):
|
||||
if any(
|
||||
keyword in exc_str
|
||||
for keyword in ["geofence", "geoblock", "geo-block", "geolocation", "region", "not available in", "territory"]
|
||||
):
|
||||
return APIError(
|
||||
error_code=APIErrorCode.GEOFENCE,
|
||||
message=f"Content not available in your region: {redact_secrets(str(exc))}",
|
||||
|
||||
@@ -376,6 +376,14 @@ class Subtitle(Track):
|
||||
boxes = MP4.parse_stream(BytesIO(data))
|
||||
return [box.data for box in boxes if box.type == b"mdat"]
|
||||
|
||||
@staticmethod
|
||||
def append_cue(kept: CaptionList, caption: Caption) -> None:
|
||||
"""Append caption to kept, or extend the last cue that a segment boundary cut and caption carries on."""
|
||||
if kept and kept[-1].end == caption.start and kept[-1].get_text() == caption.get_text():
|
||||
kept[-1].end = caption.end
|
||||
else:
|
||||
kept.append(caption)
|
||||
|
||||
@staticmethod
|
||||
def first_cue_start(caption_set: pycaption.CaptionSet) -> Optional[int]:
|
||||
"""The start of the first cue, in microseconds, looking past empty languages; None with no cues."""
|
||||
@@ -413,10 +421,8 @@ class Subtitle(Track):
|
||||
for caption in fragment.get_captions(language):
|
||||
caption.start = max(caption.start + shift, 0)
|
||||
caption.end += shift
|
||||
if kept and kept[-1].end == caption.start and kept[-1].get_text() == caption.get_text():
|
||||
kept[-1].end = caption.end # a cue carried into the next fragment
|
||||
elif caption.end > 0:
|
||||
kept.append(caption)
|
||||
if caption.end > 0:
|
||||
Subtitle.append_cue(kept, caption)
|
||||
if not any(captions.values()):
|
||||
logging.getLogger("Subtitle").debug(f"ISM text: {len(payloads)} fragments, {len(times)} fragment times")
|
||||
self.convert(Subtitle.Codec.TimedTextMarkupLang)
|
||||
@@ -837,7 +843,8 @@ class Subtitle(Track):
|
||||
for payload in Subtitle.mdat_payloads(data)
|
||||
):
|
||||
for lang in segment.get_languages():
|
||||
caption_lists[lang].extend(segment.get_captions(lang))
|
||||
for caption in segment.get_captions(lang):
|
||||
Subtitle.append_cue(caption_lists[lang], caption)
|
||||
caption_set = pycaption.CaptionSet(caption_lists)
|
||||
elif codec == Subtitle.Codec.TimedTextMarkupLang:
|
||||
text = try_ensure_utf8(data).decode("utf8")
|
||||
@@ -1109,12 +1116,7 @@ class Subtitle(Track):
|
||||
nodes=nodes,
|
||||
layout_info=layout,
|
||||
)
|
||||
p_caption = captions[-1] if captions else None
|
||||
if p_caption and caption.start == p_caption.end and str(caption.nodes) == str(p_caption.nodes):
|
||||
# it's a duplicate, but lets take its end time
|
||||
p_caption.end = caption.end
|
||||
continue
|
||||
captions.append(caption)
|
||||
Subtitle.append_cue(captions, caption)
|
||||
|
||||
return captions, language
|
||||
|
||||
|
||||
Reference in New Issue
Block a user