feat(subtitle): read scc closed captions and convert them before muxing

SCC goes straight to --sub-format when it is SRT, WebVTT or TTML, else to SRT; pycaption reads the CC1 channel, and an SCC with no CC1 captions warns. Raise the pycaption floor to 2.2.24 for its SCC reader fixes.
This commit is contained in:
imSp4rky
2026-11-13 14:09:16 -07:00
parent 7f91a1d36b
commit 94dfb5d677
8 changed files with 118 additions and 10 deletions
+1 -1
View File
@@ -301,7 +301,7 @@ and `descriptive`. `Audio.Codec` covers `AAC`, `AC3` (`DD`), `EC3` (`DD+`), `AC4
(subtitles for the deaf and hard-of-hearing), and `forced`. A track cannot be both `cc` (subtitles for the deaf and hard-of-hearing), and `forced`. A track cannot be both `cc`
and `sdh`, and `forced` cannot combine with either. `Subtitle.Codec` includes `SubRip` and `sdh`, and `forced` cannot combine with either. `Subtitle.Codec` includes `SubRip`
(SRT), `WebVTT` (VTT), `SubStationAlpha`/`SubStationAlphav4` (SSA/ASS), (SRT), `WebVTT` (VTT), `SubStationAlpha`/`SubStationAlphav4` (SSA/ASS),
`TimedTextMarkupLang` (TTML), `SAMI`, and the box-encapsulated DASH formats `fTTML` `TimedTextMarkupLang` (TTML), `SAMI`, `SCC`, and the box-encapsulated DASH formats `fTTML`
(STPP) and `fVTT` (WVTT). Subtitles receive post-download normalization (STPP) and `fVTT` (WVTT). Subtitles receive post-download normalization
(segment merging, WebVTT sanitization, format conversion, and optional SDH stripping), (segment merging, WebVTT sanitization, format conversion, and optional SDH stripping),
all documented for users in [Subtitles](../guide/subtitles.md). all documented for users in [Subtitles](../guide/subtitles.md).
+11 -2
View File
@@ -28,6 +28,7 @@ shown in track listings. The **Extension** column is the file extension used on
| `MicroDVD` | SUB | `sub` | MicroDVD | | `MicroDVD` | SUB | `sub` | MicroDVD |
| `MPL2` | MPL2 | `mpl2` | MPL2 | | `MPL2` | MPL2 | `mpl2` | MPL2 |
| `TMP` | TMP | `tmp` | TMP | | `TMP` | TMP | `tmp` | TMP |
| `SCC` | SCC | `scc` | Scenarist Closed Captions (CEA-608), read only |
| `fTTML` | STPP | `stpp` | MPEG-DASH box-encapsulated TTML (IMSC1) | | `fTTML` | STPP | `stpp` | MPEG-DASH box-encapsulated TTML (IMSC1) |
| `fVTT` | WVTT | `wvtt` | MPEG-DASH box-encapsulated WebVTT | | `fVTT` | WVTT | `wvtt` | MPEG-DASH box-encapsulated WebVTT |
@@ -65,6 +66,13 @@ format-appropriate clean-up to make a single, valid file:
output format, unshackle automatically converts any plain TTML track to WebVTT before output format, unshackle automatically converts any plain TTML track to WebVTT before
muxing (the closest lossless equivalent). Boxed `fTTML` is likewise unwrapped to TTML muxing (the closest lossless equivalent). Boxed `fTTML` is likewise unwrapped to TTML
first. first.
- **SCC for muxing**: Matroska cannot carry SCC, and no later step can read it. Before
muxing, unshackle converts an SCC track straight to the `--sub-format` you request
when that is SRT, WebVTT or TTML, and to SRT in all other cases (no `--sub-format`,
`original`, or another format, which unshackle then makes from the SRT). Choose
`--sub-format vtt` to keep the caption rows: WebVTT keeps a top-of-screen caption at
the top, SRT does not. unshackle reads only the CC1 channel. An SCC file with no CC1
captions gives an empty track and a warning.
You normally do not need to think about any of this. It gives a clean file. You normally do not need to think about any of this. It gives a clean file.
@@ -302,8 +310,9 @@ styling that survives. `—` means that the backend cannot convert that pair.
Reading the table: Reading the table:
- **Keep the original**: leaving `--sub-format` unset (or set to `original`) never - **Keep the original**: leaving `--sub-format` unset (or set to `original`) does not
round-trips the file, so every style survives. Only convert when your player cannot round-trip the file, so every style survives. TTML and SCC are the exceptions, as noted
above. Only convert when your player cannot
read the source format. read the source format.
- **`subtitleedit`** (SubtitleEdit / `seconv`): the best choice for anything → SRT. It - **`subtitleedit`** (SubtitleEdit / `seconv`): the best choice for anything → SRT. It
is the only backend that carries colour, and it embeds `{\an8}` tags to preserve is the only backend that carries colour, and it embeds `{\an8}` tags to preserve
+1 -1
View File
@@ -37,7 +37,7 @@ dependencies = [
"langcodes>=3.4.0,<4", "langcodes>=3.4.0,<4",
"lxml>=5.2.1,<7", "lxml>=5.2.1,<7",
"protobuf>=4.25.3,<7", "protobuf>=4.25.3,<7",
"pycaption>=2.2.6,<2.2.27", "pycaption>=2.2.24,<2.2.27",
"pycryptodomex>=3.20.0,<4", "pycryptodomex>=3.20.0,<4",
"pyjwt>=2.12.0,<3", "pyjwt>=2.12.0,<3",
"pymediainfo>=6.1.0,<8", "pymediainfo>=6.1.0,<8",
+72
View File
@@ -189,6 +189,78 @@ def test_webvtt_to_srt_conversion(tmp_path, monkeypatch):
assert cue_count(out) == 2 assert cue_count(out) == 2
SCC_SAMPLE = """Scenarist_SCC V1.0
00:00:00;00 9420 9152 9723 5bc2 4f43 49ce c120 c445 20c2 c152 434f 5d80 9420 942c 942f
00:00:04;15 942c
00:01:48;06 9420 9152 91b3 54e5 2067 7573 f461 2c20 c2ec 616e e361 2046 ecef f2bf
00:01:51;20 9420 942c 942f
00:01:55;20 942c
"""
def test_scc_mime_maps_to_the_scc_codec():
assert Codec.from_mime("x-scc") is Codec.SCC
assert Codec.from_mime("scc") is Codec.SCC
@pytest.mark.parametrize(
"sub_format, expected",
[
(None, Codec.SubRip),
("original", Codec.SubRip),
(Codec.SubRip, Codec.SubRip),
(Codec.WebVTT, Codec.WebVTT),
(Codec.SubStationAlphav4, Codec.SubRip),
],
)
def test_scc_converts_to_the_requested_format_or_srt(tmp_path, monkeypatch, sub_format, expected):
from unshackle.commands.dl import convert_scc
monkeypatch.setattr("unshackle.core.config.config.subtitle", {}, raising=False)
sub = make_sub(tmp_path, "x.scc", SCC_SAMPLE, Codec.SCC)
assert convert_scc(sub, sub_format) == expected
assert sub.codec == expected
assert cue_count(sub.path) == 2
assert "¿Te gusta, Blanca Flor?" in sub.path.read_text("utf8")
def test_scc_to_webvtt_keeps_the_caption_row(tmp_path, monkeypatch):
from unshackle.commands.dl import convert_scc
monkeypatch.setattr("unshackle.core.config.config.subtitle", {}, raising=False)
sub = make_sub(tmp_path, "x.scc", SCC_SAMPLE, Codec.SCC)
convert_scc(sub, Codec.WebVTT)
assert "line:" in sub.path.read_text("utf8")
def test_scc_as_srt_converts_on_to_ass(tmp_path, monkeypatch):
from unshackle.commands.dl import convert_scc
monkeypatch.setattr("unshackle.core.config.config.subtitle", {}, raising=False)
sub = make_sub(tmp_path, "x.scc", SCC_SAMPLE, Codec.SCC)
convert_scc(sub, Codec.SubStationAlphav4)
assert sub.convert(Codec.SubStationAlphav4, forced=True).read_text("utf8").count("Dialogue:") == 2
def test_scc_without_cc1_captions_warns(tmp_path, monkeypatch, caplog):
from unshackle.commands.dl import convert_scc
monkeypatch.setattr("unshackle.core.config.config.subtitle", {}, raising=False)
cc3 = "Scenarist_SCC V1.0\n\n00:00:01:00\t1520 1520 1552 1552 c8e5 ecec ef80 152f 152f\n\n00:00:03:00\t152c 152c\n"
convert_scc(make_sub(tmp_path, "x.scc", cc3, Codec.SCC), None)
assert "has no CC1 captions" in caplog.text
def test_scc_with_a_bad_timecode_is_a_syntax_error():
with pytest.raises(SyntaxError):
Subtitle.parse(b"Scenarist_SCC V1.0\n\n00:00:xx;00\t9420 942f\n", Codec.SCC)
def test_same_codec_is_noop(tmp_path): def test_same_codec_is_noop(tmp_path):
sub = make_sub(tmp_path, "x.srt", "1\n00:00:01,000 --> 00:00:02,000\nHi\n", Codec.SubRip) sub = make_sub(tmp_path, "x.srt", "1\n00:00:01,000 --> 00:00:02,000\nHi\n", Codec.SubRip)
assert sub.convert(Codec.SubRip) == sub.path assert sub.convert(Codec.SubRip) == sub.path
+18 -1
View File
@@ -103,7 +103,7 @@ from unshackle.core.tracks import Audio, Subtitle, Tracks, Video
from unshackle.core.tracks.attachment import Attachment from unshackle.core.tracks.attachment import Attachment
from unshackle.core.tracks.dv_fixup import apply_dv_fixup from unshackle.core.tracks.dv_fixup import apply_dv_fixup
from unshackle.core.tracks.hybrid import Hybrid from unshackle.core.tracks.hybrid import Hybrid
from unshackle.core.tracks.subtitle_convert import SUBSTATION, build_ass_style from unshackle.core.tracks.subtitle_convert import PYCAPTION_WRITERS, SUBSTATION, build_ass_style
from unshackle.core.tracks.track import Track, assert_fragments_decrypted, has_encrypted_sample_entry from unshackle.core.tracks.track import Track, assert_fragments_decrypted, has_encrypted_sample_entry
from unshackle.core.tracks.tracks import at_resolution from unshackle.core.tracks.tracks import at_resolution
from unshackle.core.utilities import ( from unshackle.core.utilities import (
@@ -244,6 +244,20 @@ def group_videos_by_variant(videos: list[Video], *, merge: bool) -> list[list[Vi
HYBRID_BASE_RANGES = (Video.Range.HDR10P, Video.Range.HDR10) HYBRID_BASE_RANGES = (Video.Range.HDR10P, Video.Range.HDR10)
def convert_scc(subtitle: Subtitle, sub_format: Optional[Union[Subtitle.Codec, str]]) -> Subtitle.Codec:
"""
Convert an SCC subtitle to sub_format when pycaption can write it, else to SRT, and return the new codec.
Only pycaption reads SCC, and nothing later in the pipeline (styling, SDH strip, mux) can.
"""
target = Subtitle.Codec.SubRip
if isinstance(sub_format, Subtitle.Codec) and sub_format in PYCAPTION_WRITERS:
target = sub_format
subtitle.convert(target)
if subtitle.path and not subtitle.path.read_text("utf8").strip():
logging.getLogger("Subtitle").warning(f"SCC subtitle {subtitle.language} has no CC1 captions")
return target
def select_hybrid_bases(videos: Sequence[Video]) -> list[Video]: def select_hybrid_bases(videos: Sequence[Video]) -> list[Video]:
"""The HDR10 or HDR10+ track that is the hybrid base for each height, tallest first. """The HDR10 or HDR10+ track that is the hybrid base for each height, tallest first.
@@ -3414,6 +3428,9 @@ class dl:
with console.status("Converting Subtitles..."): with console.status("Converting Subtitles..."):
sub_conversions: dict[tuple[str, str], int] = {} sub_conversions: dict[tuple[str, str], int] = {}
for subtitle in title.tracks.subtitles: for subtitle in title.tracks.subtitles:
if subtitle.codec == Subtitle.Codec.SCC:
scc_dst = convert_scc(subtitle, sub_format).name
sub_conversions[("SCC", scc_dst)] = sub_conversions.get(("SCC", scc_dst), 0) + 1
if sub_format == "original": if sub_format == "original":
continue continue
if sub_format: if sub_format:
+8
View File
@@ -47,6 +47,7 @@ class Subtitle(Track):
MicroDVD = "SUB" # https://wikipedia.org/wiki/MicroDVD MicroDVD = "SUB" # https://wikipedia.org/wiki/MicroDVD
MPL2 = "MPL2" # MPL2 subtitle format MPL2 = "MPL2" # MPL2 subtitle format
TMP = "TMP" # TMP subtitle format TMP = "TMP" # TMP subtitle format
SCC = "SCC" # https://wikipedia.org/wiki/EIA-608 (Scenarist SCC)
# MPEG-DASH box-encapsulated subtitle formats # MPEG-DASH box-encapsulated subtitle formats
fTTML = "STPP" # https://www.w3.org/TR/2018/REC-ttml-imsc1.0.1-20180424 fTTML = "STPP" # https://www.w3.org/TR/2018/REC-ttml-imsc1.0.1-20180424
fVTT = "WVTT" # https://www.w3.org/TR/webvtt1 fVTT = "WVTT" # https://www.w3.org/TR/webvtt1
@@ -80,6 +81,8 @@ class Subtitle(Track):
return Subtitle.Codec.fTTML return Subtitle.Codec.fTTML
elif mime == "wvtt": elif mime == "wvtt":
return Subtitle.Codec.fVTT return Subtitle.Codec.fVTT
elif mime in ("scc", "x-scc"):
return Subtitle.Codec.SCC
raise ValueError(f"The MIME '{mime}' is not a supported Subtitle Codec") raise ValueError(f"The MIME '{mime}' is not a supported Subtitle Codec")
@staticmethod @staticmethod
@@ -885,6 +888,11 @@ class Subtitle(Track):
# Convert SRT back to CaptionSet for compatibility # Convert SRT back to CaptionSet for compatibility
srt_text = str(srt_subtitles).encode("utf8") srt_text = str(srt_subtitles).encode("utf8")
caption_set = Subtitle.parse(srt_text, Subtitle.Codec.SubRip) caption_set = Subtitle.parse(srt_text, Subtitle.Codec.SubRip)
elif codec == Subtitle.Codec.SCC:
try:
caption_set = pycaption.SCCReader().read(try_ensure_utf8(data).decode("utf8"))
except ValueError as e:
raise pycaption.exceptions.CaptionReadSyntaxError(e) from e
else: else:
raise ValueError(f'Unknown Subtitle format "{codec}"...') raise ValueError(f'Unknown Subtitle format "{codec}"...')
except pycaption.exceptions.CaptionReadSyntaxError as e: except pycaption.exceptions.CaptionReadSyntaxError as e:
+3 -1
View File
@@ -254,7 +254,9 @@ class PycaptionBackend:
"""pycaption: last resort. Flattens positioning/italics (devine #39), so ranked last.""" """pycaption: last resort. Flattens positioning/italics (devine #39), so ranked last."""
name = "pycaption" name = "pycaption"
reads = frozenset({Codec.SubRip, Codec.TimedTextMarkupLang, Codec.WebVTT, Codec.SAMI, Codec.fTTML, Codec.fVTT}) reads = frozenset(
{Codec.SubRip, Codec.TimedTextMarkupLang, Codec.WebVTT, Codec.SAMI, Codec.SCC, Codec.fTTML, Codec.fVTT}
)
writes = frozenset(PYCAPTION_WRITERS) writes = frozenset(PYCAPTION_WRITERS)
def is_available(self) -> bool: def is_available(self) -> bool:
Generated
+4 -4
View File
@@ -1461,16 +1461,16 @@ wheels = [
[[package]] [[package]]
name = "pycaption" name = "pycaption"
version = "2.2.22" version = "2.2.26"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
dependencies = [ dependencies = [
{ name = "beautifulsoup4" }, { name = "beautifulsoup4" },
{ name = "cssutils" }, { name = "cssutils" },
{ name = "lxml" }, { name = "lxml" },
] ]
sdist = { url = "https://files.pythonhosted.org/packages/80/1b/eed6e7a337e1c5807fd88920c100ff06042abca170a80677cb036629d888/pycaption-2.2.22.tar.gz", hash = "sha256:37680491c4b1867c78ce8b537b0004ec1f790db608b545d934475baf95ee0032", size = 112873, upload-time = "2026-05-04T10:00:28.97Z" } sdist = { url = "https://files.pythonhosted.org/packages/d6/63/d68d74e88e278c1b489aeaa83f524899586709713e0da4f1709397604e48/pycaption-2.2.26.tar.gz", hash = "sha256:8ef2fead71c75205c0ebcc947430f77c7896b48631b96ecc63865ee9f3bb0f56", size = 119916, upload-time = "2026-07-02T14:11:31.026Z" }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/9c/ce/82938777b8efe253be1175647bcc408fd4918faaca1b23c04e8158554288/pycaption-2.2.22-py3-none-any.whl", hash = "sha256:d5f672d05539e46fca0fdecb76f8d23b7c9b1a0f62f91268d4046d0e949f862e", size = 126674, upload-time = "2026-05-04T10:00:27.53Z" }, { url = "https://files.pythonhosted.org/packages/b3/de/5ec2a4c89ef256b457f76bba738075502fb04b5c672a1e1948b666d5bee4/pycaption-2.2.26-py3-none-any.whl", hash = "sha256:edc702b901c9188c720c33316fa248076ff9ce60f3a91aa53a3e93b62b370518", size = 134352, upload-time = "2026-07-02T14:11:29.953Z" },
] ]
[[package]] [[package]]
@@ -2264,7 +2264,7 @@ requires-dist = [
{ name = "mediaexport", git = "https://github.com/unshackle-dl/mediaexport?rev=main" }, { name = "mediaexport", git = "https://github.com/unshackle-dl/mediaexport?rev=main" },
{ name = "mutagen", specifier = ">=1.47.0,<2" }, { name = "mutagen", specifier = ">=1.47.0,<2" },
{ name = "protobuf", specifier = ">=4.25.3,<7" }, { name = "protobuf", specifier = ">=4.25.3,<7" },
{ name = "pycaption", specifier = ">=2.2.6,<2.2.27" }, { name = "pycaption", specifier = ">=2.2.24,<2.2.27" },
{ name = "pycountry", specifier = ">=24.6.1" }, { name = "pycountry", specifier = ">=24.6.1" },
{ name = "pycryptodomex", specifier = ">=3.20.0,<4" }, { name = "pycryptodomex", specifier = ">=3.20.0,<4" },
{ name = "pyexecjs", specifier = ">=1.5.1,<2" }, { name = "pyexecjs", specifier = ">=1.5.1,<2" },