mirror of
https://github.com/unshackle-dl/unshackle.git
synced 2026-11-14 04:55:07 +00:00
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:
@@ -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).
|
||||
|
||||
+11
-2
@@ -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
|
||||
|
||||
+1
-1
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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" },
|
||||
|
||||
Reference in New Issue
Block a user