fix(subs): stop segmented WebVTT merge from stripping formatting

Merge cues at the string level instead of round-tripping through
pycaption, which dropped inline tags and cue settings. STYLE and
REGION blocks are kept, duplicate boundary cues splice correctly,
and a bad timing line now skips the cue instead of crashing.

Also rank pysubs2 above SubtitleEdit for ASS to TTML/VTT since
SubtitleEdit flattens inline styling there. Documented the measured
backend fidelity table and added regression tests for the merge.
This commit is contained in:
imSp4rky
2026-07-12 09:39:39 -06:00
parent 6ef24661c8
commit d7a2bb6b8e
5 changed files with 228 additions and 208 deletions
+12
View File
@@ -87,6 +87,18 @@ def test_subtitleedit_ranks_first_when_available(monkeypatch):
assert chain[0] == "subtitleedit"
def test_styled_ass_to_ttml_vtt_prefers_pysubs2_over_subtitleedit(monkeypatch):
# Measured: SE flattens ASS inline styling to TTML/VTT; pysubs2 keeps i/b/u.
monkeypatch.setattr(binaries, "SubtitleEdit", "/usr/bin/seconv")
for target in (Codec.TimedTextMarkupLang, Codec.WebVTT):
chain = [b.name for b in sc.resolve_backends(Codec.SubStationAlphav4, target)]
assert chain[0] == "pysubs2", f"expected pysubs2 first for ASS->{target.name}, got {chain}"
assert "subtitleedit" in chain # still a fallback
# ASS->SRT keeps SE first (only backend that keeps colour there).
chain = [b.name for b in sc.resolve_backends(Codec.SubStationAlphav4, Codec.SubRip)]
assert chain[0] == "subtitleedit"
def test_pin_then_fallback_orders_pin_first():
chain = [b.name for b in sc.resolve_backends(Codec.WebVTT, Codec.SubRip, pin="pysubs2")]
assert chain[0] == "pysubs2"
+113
View File
@@ -0,0 +1,113 @@
"""Regression tests for segmented-WebVTT merging (``core/utils/webvtt.py``).
The merge used to round-trip cues through pycaption's reader/writer (and a pysubs2
pre-normalization step), both of which silently dropped inline formatting despite
``preserve_formatting=True``. It is now a pure string-level splice: cue
timing lines, settings, and payload are kept verbatim, so span tags
(``<i>``/``<u>``/``<b>``), ASS-style overrides (``{\\an8}``), and entities all
survive, while duplicate headers collapse and boundary-duplicate cues dedupe.
"""
from __future__ import annotations
from unshackle.core.utils.webvtt import merge_segmented_webvtt
# 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
# span tags, an {\an8} override, a cue setting, and a literal &lt; entity.
SEGMENTED_VTT = (
"WEBVTT\n"
"X-TIMESTAMP-MAP=MPEGTS:900000,LOCAL:00:00:00.000\n\n"
"00:00:01.000 --> 00:00:03.000 line:10% align:center\n"
"<i>Hello</i> {\\an8}world <u>und</u> <b>bold</b> 5 &lt; 6\n\n"
"WEBVTT\n"
"X-TIMESTAMP-MAP=MPEGTS:1800000,LOCAL:00:00:00.000\n\n"
"00:00:04.000 --> 00:00:06.000\n"
"<i>Second</i> cue\n"
)
def test_merge_preserves_inline_formatting():
out = merge_segmented_webvtt(SEGMENTED_VTT, segment_durations=[900000, 900000], timescale=1)
for tag in ("<i>", "</i>", "<u>", "</u>", "<b>", "</b>", "{\\an8}"):
assert tag in out, f"{tag!r} was stripped from merged output:\n{out}"
def test_merge_preserves_cue_settings_and_entities():
out = merge_segmented_webvtt(SEGMENTED_VTT, segment_durations=[900000, 900000], timescale=1)
assert "line:10% align:center" in out # cue positioning survives
assert "&lt;" in out # a literal entity is not turned into a stray tag
def test_merge_collapses_headers_into_single_document():
out = merge_segmented_webvtt(SEGMENTED_VTT, segment_durations=[900000, 900000], timescale=1)
assert out.count("WEBVTT") == 1
assert "X-TIMESTAMP-MAP" not in out
def test_merge_dedupes_boundary_duplicate_cues():
# A cue that spans a segment boundary is emitted in both segments; the merge
# should collapse the adjacent identical pair into one.
dup = SEGMENTED_VTT + (
"\nWEBVTT\n"
"X-TIMESTAMP-MAP=MPEGTS:2700000,LOCAL:00:00:00.000\n\n"
"00:00:04.000 --> 00:00:06.000\n"
"<i>Second</i> cue\n"
)
out = merge_segmented_webvtt(dup, segment_durations=[900000, 900000, 900000], timescale=1)
assert out.count("<i>Second</i> cue") == 1, f"boundary duplicate not deduped:\n{out}"
def test_merge_keeps_timing_lines_verbatim():
# String-level merge must not re-render timestamps (pycaption shortened
# 00:00:01.000 -> 00:01.000) and must keep the settings tail untouched.
out = merge_segmented_webvtt(SEGMENTED_VTT, segment_durations=[900000, 900000], timescale=1)
assert "00:00:01.000 --> 00:00:03.000 line:10% align:center" in out
assert "00:00:04.000 --> 00:00:06.000" in out
def test_merge_splices_triple_duplicate_into_one():
# A cue spanning two boundaries appears in three consecutive segments; the
# kept cue must be extended each time (latent bug: extending the dropped one).
vtt = (
"WEBVTT\n\n00:00:01.000 --> 00:00:02.000\nSame cue\n\n"
"WEBVTT\n\n00:00:02.000 --> 00:00:03.000\nSame cue\n\n"
"WEBVTT\n\n00:00:03.000 --> 00:00:04.000\nSame cue\n"
)
out = merge_segmented_webvtt(vtt)
assert out.count("Same cue") == 1
assert "00:00:01.000 --> 00:00:04.000" in out
def test_merge_drops_cue_ids_and_notes_keeps_style():
vtt = (
"WEBVTT\n\n"
"STYLE\n::cue { color: red }\n\n"
"NOTE a comment\n\n"
"some-cue-id-42\n00:00:01.000 --> 00:00:02.000\nHello\n"
)
out = merge_segmented_webvtt(vtt)
assert "Hello" in out
assert "some-cue-id-42" not in out
assert "NOTE" not in out
assert "STYLE\n::cue { color: red }" in out # kept; players without STYLE support ignore it
def test_merge_dedupes_repeated_style_blocks_before_cues():
# Every segment repeats the same STYLE block; keep exactly one, before the first cue.
style = "WEBVTT\n\nSTYLE\n::cue { color: red }\n\n"
vtt = style + "00:00:01.000 --> 00:00:02.000\nOne\n\n" + style + "00:00:03.000 --> 00:00:04.000\nTwo\n"
out = merge_segmented_webvtt(vtt)
assert out.count("STYLE") == 1
assert out.index("STYLE") < out.index("-->")
def test_merge_skips_malformed_timing_line():
vtt = (
"WEBVTT\n\n"
"garbage --> not-a-time\nBroken\n\n"
"00:00:01.000 --> 00:00:02.000\nGood\n"
)
out = merge_segmented_webvtt(vtt)
assert "Good" in out
assert "Broken" not in out