diff --git a/docs/dev/service-architecture.md b/docs/dev/service-architecture.md index da900363..edbbb067 100644 --- a/docs/dev/service-architecture.md +++ b/docs/dev/service-architecture.md @@ -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` and `sdh`, and `forced` cannot combine with either. `Subtitle.Codec` includes `SubRip` (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 (segment merging, WebVTT sanitization, format conversion, and optional SDH stripping), all documented for users in [Subtitles](../guide/subtitles.md). diff --git a/docs/guide/subtitles.md b/docs/guide/subtitles.md index 9d49c456..d4888868 100644 --- a/docs/guide/subtitles.md +++ b/docs/guide/subtitles.md @@ -28,6 +28,7 @@ shown in track listings. The **Extension** column is the file extension used on | `MicroDVD` | SUB | `sub` | MicroDVD | | `MPL2` | MPL2 | `mpl2` | MPL2 | | `TMP` | TMP | `tmp` | TMP | +| `SCC` | SCC | `scc` | Scenarist Closed Captions (CEA-608), read only | | `fTTML` | STPP | `stpp` | MPEG-DASH box-encapsulated TTML (IMSC1) | | `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 muxing (the closest lossless equivalent). Boxed `fTTML` is likewise unwrapped to TTML 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. @@ -302,8 +310,9 @@ styling that survives. `—` means that the backend cannot convert that pair. Reading the table: -- **Keep the original**: leaving `--sub-format` unset (or set to `original`) never - round-trips the file, so every style survives. Only convert when your player cannot +- **Keep the original**: leaving `--sub-format` unset (or set to `original`) does not + 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. - **`subtitleedit`** (SubtitleEdit / `seconv`): the best choice for anything → SRT. It is the only backend that carries colour, and it embeds `{\an8}` tags to preserve diff --git a/pyproject.toml b/pyproject.toml index e268a9c6..9441ea3d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -37,7 +37,7 @@ dependencies = [ "langcodes>=3.4.0,<4", "lxml>=5.2.1,<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", "pyjwt>=2.12.0,<3", "pymediainfo>=6.1.0,<8", diff --git a/tests/tracks/test_subtitle_convert.py b/tests/tracks/test_subtitle_convert.py index 794cf1b1..3e5e0df4 100644 --- a/tests/tracks/test_subtitle_convert.py +++ b/tests/tracks/test_subtitle_convert.py @@ -189,6 +189,78 @@ def test_webvtt_to_srt_conversion(tmp_path, monkeypatch): 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): 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 diff --git a/unshackle/commands/dl.py b/unshackle/commands/dl.py index 8c633949..df0960fa 100644 --- a/unshackle/commands/dl.py +++ b/unshackle/commands/dl.py @@ -103,7 +103,7 @@ from unshackle.core.tracks import Audio, Subtitle, Tracks, Video from unshackle.core.tracks.attachment import Attachment from unshackle.core.tracks.dv_fixup import apply_dv_fixup 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.tracks import at_resolution 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) +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]: """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..."): sub_conversions: dict[tuple[str, str], int] = {} 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": continue if sub_format: diff --git a/unshackle/core/tracks/subtitle.py b/unshackle/core/tracks/subtitle.py index 47a543c6..2bf21599 100644 --- a/unshackle/core/tracks/subtitle.py +++ b/unshackle/core/tracks/subtitle.py @@ -47,6 +47,7 @@ class Subtitle(Track): MicroDVD = "SUB" # https://wikipedia.org/wiki/MicroDVD MPL2 = "MPL2" # MPL2 subtitle format TMP = "TMP" # TMP subtitle format + SCC = "SCC" # https://wikipedia.org/wiki/EIA-608 (Scenarist SCC) # MPEG-DASH box-encapsulated subtitle formats fTTML = "STPP" # https://www.w3.org/TR/2018/REC-ttml-imsc1.0.1-20180424 fVTT = "WVTT" # https://www.w3.org/TR/webvtt1 @@ -80,6 +81,8 @@ class Subtitle(Track): return Subtitle.Codec.fTTML elif mime == "wvtt": 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") @staticmethod @@ -885,6 +888,11 @@ class Subtitle(Track): # Convert SRT back to CaptionSet for compatibility srt_text = str(srt_subtitles).encode("utf8") 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: raise ValueError(f'Unknown Subtitle format "{codec}"...') except pycaption.exceptions.CaptionReadSyntaxError as e: diff --git a/unshackle/core/tracks/subtitle_convert.py b/unshackle/core/tracks/subtitle_convert.py index a9eef155..4721de45 100644 --- a/unshackle/core/tracks/subtitle_convert.py +++ b/unshackle/core/tracks/subtitle_convert.py @@ -254,7 +254,9 @@ class PycaptionBackend: """pycaption: last resort. Flattens positioning/italics (devine #39), so ranked last.""" 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) def is_available(self) -> bool: diff --git a/uv.lock b/uv.lock index ade1c5a0..7f80596a 100644 --- a/uv.lock +++ b/uv.lock @@ -1461,16 +1461,16 @@ wheels = [ [[package]] name = "pycaption" -version = "2.2.22" +version = "2.2.26" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "beautifulsoup4" }, { name = "cssutils" }, { 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 = [ - { 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]] @@ -2264,7 +2264,7 @@ requires-dist = [ { name = "mediaexport", git = "https://github.com/unshackle-dl/mediaexport?rev=main" }, { name = "mutagen", specifier = ">=1.47.0,<2" }, { 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 = "pycryptodomex", specifier = ">=3.20.0,<4" }, { name = "pyexecjs", specifier = ">=1.5.1,<2" },