diff --git a/tests/tracks/test_webvtt_timestamp_map.py b/tests/tracks/test_webvtt_timestamp_map.py new file mode 100644 index 00000000..1f642999 --- /dev/null +++ b/tests/tracks/test_webvtt_timestamp_map.py @@ -0,0 +1,22 @@ +from unshackle.core.tracks.subtitle import Subtitle + + +def test_strips_timestamp_map_keeps_cues(): + text = "WEBVTT\nX-TIMESTAMP-MAP=MPEGTS:900000,LOCAL:00:00:00.000\n\n00:00:22.690 --> 00:00:24.710\nHi\n" + out = Subtitle.strip_webvtt_timestamp_map(text) + assert "X-TIMESTAMP-MAP" not in out + assert "00:00:22.690 --> 00:00:24.710" in out + assert out.startswith("WEBVTT\n") + + +def test_strips_lowercase_timestamp_map(): + # SubtitleEdit honours the header case-insensitively, so the strip must too + text = "WEBVTT\nx-timestamp-map=MPEGTS:900000,LOCAL:00:00:00.000\n\n00:00:22.690 --> 00:00:24.710\nHi\n" + out = Subtitle.strip_webvtt_timestamp_map(text) + assert "x-timestamp-map" not in out.lower() + assert "00:00:22.690 --> 00:00:24.710" in out + + +def test_no_timestamp_map_is_untouched(): + text = "WEBVTT\n\n00:00:01.000 --> 00:00:02.000\nHi\n" + assert Subtitle.strip_webvtt_timestamp_map(text) == text diff --git a/unshackle/core/tracks/subtitle.py b/unshackle/core/tracks/subtitle.py index 203c271d..b95847c8 100644 --- a/unshackle/core/tracks/subtitle.py +++ b/unshackle/core/tracks/subtitle.py @@ -257,6 +257,7 @@ class Subtitle(Track): self.convert(Subtitle.Codec.WebVTT) elif self.codec == Subtitle.Codec.WebVTT: text = self.path.read_text("utf8") + text = Subtitle.strip_webvtt_timestamp_map(text) if self.descriptor == Track.Descriptor.DASH: if len(self.data["dash"]["segment_durations"]) > 1: text = merge_segmented_webvtt( @@ -303,6 +304,17 @@ class Subtitle(Track): # Keep the sanitized version even if parsing failed self.path.write_text(text, encoding="utf8") + @staticmethod + def strip_webvtt_timestamp_map(text: str) -> str: + """ + Remove X-TIMESTAMP-MAP header lines (RFC 8216 ยง3.5, HLS only). + + Cue times are already absolute, but SubtitleEdit reads the header and offsets + every cue by it during conversion. + """ + # IGNORECASE: SubtitleEdit matches the header case-insensitively, so must the strip + return re.sub(r"^[ \t]*X-TIMESTAMP-MAP[^\n]*\n?", "", text, flags=re.MULTILINE | re.IGNORECASE) + @staticmethod def sanitize_webvtt_timestamps(text: str) -> str: """