diff --git a/docs/guide/subtitles.md b/docs/guide/subtitles.md
index b7e97c92..319f29c1 100644
--- a/docs/guide/subtitles.md
+++ b/docs/guide/subtitles.md
@@ -55,6 +55,9 @@ format-appropriate clean-up to make a single, valid file:
that confuse downstream parsers, and merges multi-line cues that a service split into
separate overlapping cues. By default unshackle keeps the original styling and layout
(see [`preserve_formatting`](#subtitle-configuration)).
+- **Cue markup normalisation**: unshackle removes the WebVTT-only parts of a cue
+ payload, so no player draws them as dialogue. See
+ [Cue markup](#cue-markup-and-html-entities).
- **TTML for muxing**: Matroska cannot carry TTML. If you do not request a specific
output format, unshackle automatically converts any plain TTML track to WebVTT before
muxing (the closest lossless equivalent). Boxed `fTTML` is likewise unwrapped to TTML
@@ -188,6 +191,20 @@ itself. The default (`auto`) prefers the `subby` cleaner for SRT, falls back to
SubtitleEdit when you install it, and otherwise uses the built-in `filter-subs`
approach.
+Every engine removes the same three things: bracketed sound effects (`[MUSIC PLAYING]`,
+`(door creaks)`), speaker labels (`MAN:`), and font tags. Every engine keeps plain
+dialogue and inline `` styling. They differ on music and lyric lines:
+
+| `sdh_method` | Engine that runs | Lyric lines (`♪ ... ♪`) |
+| --- | --- | --- |
+| `auto` | `subby` for an SRT track. SubtitleEdit for any other format when you install it, otherwise `filter-subs`. | Kept, unless the fallback reaches `filter-subs`. |
+| `subby` | `subby`, but only for a track that is already SRT. Any other format falls through to `filter-subs`. | Kept for SRT, removed otherwise. |
+| `subtitleedit` | SubtitleEdit when you install it, otherwise `filter-subs`. | Kept with SubtitleEdit, removed without it. |
+| `filter-subs` | `filter-subs` always. | Removed. |
+
+Pick `filter-subs` when you want lyrics gone as well. Pick `subby` or install
+SubtitleEdit when you want to read along with the songs.
+
!!! note "Why `convert_before_strip` exists"
`subby`'s SDH stripper only operates on SRT. Handed any other codec it returns
silently without stripping anything, not an error but a no-op that leaves the SDH
@@ -288,6 +305,28 @@ Reading the table:
Install [SubtitleEdit / `seconv`](#subtitle-configuration) if you need colour or the
highest → SRT fidelity.
+### Cue markup and HTML entities
+
+Styling is not the only thing a conversion has to handle. WebVTT carries markup that no
+other format understands, and a player that does not know a tag draws it as dialogue.
+unshackle removes that markup before any backend runs, so the result is the same for
+every `conversion_method`:
+
+| WebVTT markup | Result |
+| --- | --- |
+| `text` | Tags removed, text kept. A class only selects a CSS `::cue` rule that no player ever receives, so it carries no styling. |
+| ``, ``, `` | Class list removed, the styling tag kept. |
+| ``, ``, ``, ` on styling\n"
+ "voice jp \u6f22\n"
+ "kara <00:00:01.500>timing <00:01.500>short\n"
+ )
+ out = Subtitle.strip_webvtt_cue_classes(text)
+ assert "bg_transparent" not in out and "" not in out and "" not in out
+ assert "Mor, da vi blev gift," in out
+ assert "Keep these and this" in out
+ # class list dropped, the styling tag itself kept
+ assert "class on styling" in out
+ # WebVTT-only tags go, their text stays
+ assert "voice jp \u6f22\u304b\u3093" in out
+ assert "kara timing short" in out
+
+
+def test_decode_entities(tmp_path):
+ from unshackle.core.tracks.subtitle_convert import decode_entities
+
+ out = tmp_path / "a.srt"
+ out.write_text(
+ "1\n00:00:01,000 --> 00:00:04,000\nROBERT & RA'ED x 'q'\nTom ¬ sure, AT&T\n",
+ encoding="utf8",
+ )
+ decode_entities(out)
+ text = out.read_text(encoding="utf8")
+ assert "ROBERT & RA'ED \xa0x 'q'" in text
+ # no-semicolon legacy forms stay as written
+ assert "Tom ¬ sure, AT&T" in text
+
+
+def test_filter_subs_converts_before_stripping(tmp_path, monkeypatch):
+ """``sdh_method: filter-subs`` used to hand subtitle-filter a .vtt and raise OSError."""
+ from unshackle.core import binaries
+ from unshackle.core.config import config
+
+ monkeypatch.setattr(binaries, "SubtitleEdit", None)
+ monkeypatch.setattr(config, "subtitle", {"sdh_method": "filter-subs"}, raising=False)
+
+ path = tmp_path / "sub.vtt"
+ path.write_text(
+ "WEBVTT\n\n00:00:01.000 --> 00:00:03.000\n[MUSIC PLAYING]\n\n"
+ "00:00:04.000 --> 00:00:06.000\nPlain dialogue stays.\n",
+ encoding="utf8",
+ )
+ sub = Subtitle(url="https://example.test/x", language="en", codec=Subtitle.Codec.WebVTT)
+ sub.path = path
+ sub.strip_hearing_impaired()
+
+ assert sub.codec == Subtitle.Codec.SubRip
+ text = sub.path.read_text(encoding="utf8")
+ assert "MUSIC PLAYING" not in text
+ assert "Plain dialogue stays." in text
diff --git a/unshackle/core/tracks/subtitle.py b/unshackle/core/tracks/subtitle.py
index 314f5590..d4aa19cb 100644
--- a/unshackle/core/tracks/subtitle.py
+++ b/unshackle/core/tracks/subtitle.py
@@ -288,6 +288,7 @@ class Subtitle(Track):
text = Subtitle.sanitize_webvtt_timestamps(text)
text = Subtitle.sanitize_webvtt_cue_identifiers(text)
text = Subtitle.merge_overlapping_webvtt_cues(text)
+ text = Subtitle.strip_webvtt_cue_classes(text)
preserve_formatting = config.subtitle.get("preserve_formatting", True)
@@ -315,6 +316,29 @@ class Subtitle(Track):
# Keep the sanitized version even if parsing failed
self.path.write_text(text, encoding="utf8")
+ @staticmethod
+ def strip_webvtt_cue_classes(text: str) -> str:
+ """
+ Reduce a cue payload to the markup every target format shares: ````, ````
+ and ```` without their class list. It removes class spans (````),
+ ````, ````, ````/``