From f85d7f5fa824599e81a6052523e2b7a25f073a0e Mon Sep 17 00:00:00 2001 From: imSp4rky Date: Wed, 11 Nov 2026 10:04:57 -0700 Subject: [PATCH] feat(subtitles): add --sub-font and subtitle.ass_style for converted ASS Converted subtitles get one style on a 1080-line canvas shaped like the video; native SSA/ASS is never restyled. --- docs/dev/rest-api/endpoints.md | 1 + docs/guide/cli-reference.md | 1 + docs/guide/downloading.md | 1 + docs/guide/subtitles.md | 56 +++++ docs/reference/configuration/download.md | 2 + tests/tracks/test_ass_style.py | 248 ++++++++++++++++++++++ unshackle/commands/dl.py | 68 +++++- unshackle/core/api/handlers.py | 11 +- unshackle/core/api/routes.py | 3 + unshackle/core/tracks/subtitle.py | 11 +- unshackle/core/tracks/subtitle_convert.py | 109 +++++++++- 11 files changed, 506 insertions(+), 5 deletions(-) create mode 100644 tests/tracks/test_ass_style.py diff --git a/docs/dev/rest-api/endpoints.md b/docs/dev/rest-api/endpoints.md index dca7e38a..a3756b1c 100644 --- a/docs/dev/rest-api/endpoints.md +++ b/docs/dev/rest-api/endpoints.md @@ -459,6 +459,7 @@ Make a download job. It requires `service` and `title_id`. Every other field is | `forced_s_lang` | string[] | `[]` | Forced subtitle language(s); implies `forced_subs`. | | `exact_lang` | boolean | `false` | A language tag without a region never falls back to another variant. A tag with a region is always exact. | | `sub_format` | string | `null` | Output subtitle format. | +| `sub_font` | string | `null` | Font for subtitles converted to ASS; implies `sub_format` ASS. With another `sub_format` the request returns `400`. | | `video_only` / `audio_only` / `subs_only` / `chapters_only` | boolean | `false` | Track-type restriction (at most one). | | `no_subs` / `no_audio` / `no_chapters` / `no_video` / `no_attachments` | boolean | `false` | Skip a track type. | | `skip_subtitle_errors` | boolean | `false` | Skip a subtitle that fails to download and keep the other tracks. | diff --git a/docs/guide/cli-reference.md b/docs/guide/cli-reference.md index c39af300..f74a64d7 100644 --- a/docs/guide/cli-reference.md +++ b/docs/guide/cli-reference.md @@ -107,6 +107,7 @@ unshackle dl [OPTIONS] SERVICE [SERVICE ARGS...] | `-fsl`, `--forced-s-lang` | none | Language(s) wanted for forced subtitles; implies `-fs`. A `-` prefix excludes. | | `--exact-lang` | off | A tag without a region selects only the variant it means and never falls back to another: `-l es` does not select `es-419`. A tag with a region, such as `es-419`, is always exact. Applies to selection and to sort order. | | `--sub-format` | - | Output subtitle format (`SRT`/`srt`, `VTT`/`webvtt`, `ASS`/`ssa`, `TTML`, `SMI`, ...), or `original` to keep the source format. | +| `--sub-font` | - | Font for subtitles converted to ASS, with the rest of the look from `subtitle.ass_style`. Implies `--sub-format ASS` and overrides a `sub_format` from the configuration file; a typed `--sub-format` of another format is an error. | !!! tip "These flags select languages, they do not order them" Naming languages with `-l` or `-sl` removes the others. To keep every language but put diff --git a/docs/guide/downloading.md b/docs/guide/downloading.md index 6133f808..366b69c5 100644 --- a/docs/guide/downloading.md +++ b/docs/guide/downloading.md @@ -1198,6 +1198,7 @@ authoritative list. | `--forced-subs` | `-fs` | Include forced subtitles. | | `--forced-s-lang` | `-fsl` | Forced subtitle language(s); implies `-fs`. `-` excludes. | | `--sub-format` | | Output subtitle format. | +| `--sub-font` | | Font for subtitles converted to ASS; implies `--sub-format ASS`. | | `--wanted` | `-w` | Episode/season range, an air date, or a track number for a music release (`1-5`, `2x3`). | | `--daily` | | Fill missing air dates from TVDB during `--enrich`. | | `--select-titles` | | Interactively pick episodes or films. | diff --git a/docs/guide/subtitles.md b/docs/guide/subtitles.md index 22eaf2ab..9d49c456 100644 --- a/docs/guide/subtitles.md +++ b/docs/guide/subtitles.md @@ -83,6 +83,7 @@ Subtitle selection happens through `dl` flags. The most important is language se | `-S`, `--subs-only` | Download only subtitle tracks. | | `-ns`, `--no-subs` | Do not download subtitle tracks at all. | | `--sub-format` | Output subtitle format / conversion target (see [conversion](#converting-subtitle-formats)). | +| `--sub-font` | Font for subtitles converted to ASS; implies `--sub-format ass` (see [custom font and style](#custom-font-and-style)). | | `--skip-subtitle-errors` | Skip a subtitle that fails to download instead of aborting the whole title. | ### Choosing languages @@ -404,6 +405,60 @@ The pattern is `{base}.{language}[.forced][.sdh].{extension}`. The a format name like `srt` or `vtt`, or `original` to keep each subtitle in its downloaded format. +## Custom font and style + +Services mostly deliver WebVTT, which players draw in their own plain font. To use a font +of your choice, unshackle converts the subtitles to ASS and gives them one style: + +```shell title="Convert to ASS in a custom font" +unshackle dl --sub-font "Noto Sans" EXAMPLE 81234567 +``` + +`--sub-font` sets only the font name. The rest of the look comes from +[`subtitle.ass_style`](#subtitle-configuration), a mapping of style fields. A field takes its +[pysubs2 name](https://pysubs2.readthedocs.io/en/latest/api-reference.html#ssastyle-style-of-subtitles) +(`primarycolor`) or its ASS name (`PrimaryColour`). Give sizes and margins for a 1080-line +picture. unshackle writes a 1080-line canvas in the shape of the video, and the player scales +it to the screen, so the text has the same size on a 720p, 1080p or 2160p video. + +```yaml title="unshackle.yaml" +subtitle: + ass_style: + fontname: Noto Sans + fontsize: 72 + primarycolor: "&H00FFFFFF" + marginv: 60 +``` + +The fields you leave out keep these defaults: `fontname: Arial`, `fontsize: 72`, `bold: true`, +`outline: 3.6`, `shadow: 1.5`, `backcolor: "&HA0000000"`, `marginl: 100`, `marginr: 100` and +`marginv: 60`. Write colours as `&HAABBGGRR` strings in quotes: in YAML, an unquoted `&` starts +an anchor, and an unquoted number is not read as a colour. + +- A style makes ASS the output format when you set no `--sub-format`. With `--sub-format ass` + or `ssa` the style applies too. With another format, unshackle does not use the style. + A `--sub-font` that you type overrides a `sub_format` from the configuration file, and stops + the download with an error when you also type another `--sub-format`. A `sub_font` from the + configuration file is ignored when the format is not ASS. +- The style applies only to subtitles that unshackle converts into ASS. A subtitle that is + already SSA/ASS keeps its own styles, positions and canvas, and so does its SDH-stripped copy. +- The SDH-stripped copy of a converted track keeps the style. +- unshackle checks the style before it downloads. An unknown field or a value that does not + fit its field stops the download with an error. +- With `output_mode: sidecar` and a `sidecar_format` other than `ass`, the sidecar files do + not use the style. +- Closed captions that unshackle extracts from the video stay SRT. + +!!! note "Player settings" + A player draws an ASS subtitle with its own style, so player options for text subtitles, + such as the mpv options `sub-font` and `sub-font-size`, no longer apply. The text size + follows the height of the video, so on a wide video such as 1920x800 the text is smaller + than on a 1920x1080 video. + +The font must be on your system or in [`directories.fonts`](../reference/configuration/directories.md), +in a folder with the family name, for example `fonts/Noto Sans/NotoSans-Regular.ttf`. unshackle +attaches it to the MKV, as described below. + ## Fonts for styled subtitles Styled SSA/ASS subtitles reference specific fonts by name. When unshackle muxes such a @@ -440,6 +495,7 @@ subtitle: | `group_by` | `type` | `type`, `language` | Whether all tracks of one type stay together, or each language stays next to its own variants. | | `language_priority` | *(unset)* | a list of languages | Languages to sort to the top. The rest keep the default order. Nothing is removed. | | `dedupe_codecs` | *(unset)* | a list of codecs, e.g. `[VTT, STPP, WVTT]` | Keep one copy of a subtitle that the service sends in several codecs: the earliest listed codec wins. | +| `ass_style` | *(unset)* | a mapping of pysubs2 style fields | The look of subtitles converted to ASS; makes ASS the default output format. See [custom font and style](#custom-font-and-style). | ### Which settings to change diff --git a/docs/reference/configuration/download.md b/docs/reference/configuration/download.md index ba149e96..667ee7c8 100644 --- a/docs/reference/configuration/download.md +++ b/docs/reference/configuration/download.md @@ -41,6 +41,7 @@ Common config keys. This is a useful subset, and every `dl` flag works: | `range` | list | `["SDR"]` | Colour range(s): `SDR`, `HLG`, `HDR10`, `HDR10P`, `DV`, `HYBRID`. | | `channels` | float | *(unset)* | Audio channels, e.g. `6` for 5.1. | | `sub_format` | str | *(unset)* | Convert subtitles to this format (`srt`, `vtt`, `original`, ...). | +| `sub_font` | str | *(unset)* | Font for subtitles converted to ASS; implies `sub_format: ass`. Ignored when `sub_format` is another format. | | `forced_subs` | bool | `false` | Include forced subtitle tracks. | | `forced_s_lang` | list | `[]` | Forced subtitle language(s); implies `forced_subs`. A `-` prefix excludes. | | `forced_subs_only` | bool | `false` | Keep only forced subtitle tracks; implies `forced_subs`. | @@ -194,6 +195,7 @@ in or writes them as sidecar files. See [Subtitles](../../guide/subtitles.md) fo | `type_priority` | list | *(unset)* | Ordered ranking of subtitle types (`forced`, `normal`, `sdh`; CC counts as SDH). Types you leave out fall to the end. | | `group_by` | str | `"type"` | Which key groups the subtitle tracks. `type` puts all forced tracks together, then all normal, then all SDH. `language` keeps each language next to its own variants. | | `language_priority` | list | *(unset)* | Languages to sort to the top, in the order you give. The rest keep the default order: original language first, then alphabetical. Nothing is removed. | +| `ass_style` | dict | *(unset)* | Style for subtitles converted to ASS, as pysubs2 or ASS style fields (`fontname`, `fontsize`, `primarycolor` or `PrimaryColour`, ...) for a 1080-line picture. Makes ASS the output format when `sub_format` is unset. See [Custom font and style](../../guide/subtitles.md#custom-font-and-style). | | `dedupe_codecs` | list | *(unset)* | Subtitle codecs in order of preference, by value or name, for example `[VTT, STPP, WVTT]`. When tracks of the same language, forced/SDH/CC type and name come in several listed codecs, unshackle drops the ones in a later codec. It always keeps codecs you leave out. Unset keeps every track. | ```yaml diff --git a/tests/tracks/test_ass_style.py b/tests/tracks/test_ass_style.py new file mode 100644 index 00000000..6e14b7e5 --- /dev/null +++ b/tests/tracks/test_ass_style.py @@ -0,0 +1,248 @@ +"""subtitle.ass_style and --sub-font: the look given to subtitles converted into ASS.""" + +from __future__ import annotations + +import pysubs2 +import pytest + +from unshackle.commands.dl import dl, set_subtitle_look, subtitle_look +from unshackle.core import binaries +from unshackle.core.api.handlers import validate_download_parameters +from unshackle.core.tracks import subtitle_convert as sc +from unshackle.core.tracks.subtitle import Subtitle +from unshackle.core.tracks.video import Video + +Codec = Subtitle.Codec + +VTT = """WEBVTT + +00:00:01.000 --> 00:00:02.000 +[door creaks] + +00:00:03.000 --> 00:00:04.000 +Hello there. +""" + +NATIVE_ASS = """[Script Info] +ScriptType: v4.00+ +PlayResX: 640 +PlayResY: 360 + +[V4+ Styles] +Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding +Style: Default,Arial,20,&H00FFFFFF,&H000000FF,&H00000000,&H00000000,0,0,0,0,100,100,0,0,1,2,1,2,10,10,18,1 +Style: Top,Arial,20,&H00FFFFFF,&H000000FF,&H00000000,&H00000000,0,-1,0,0,100,100,0,0,1,2,1,8,10,10,18,1 + +[Events] +Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text +Dialogue: 0,0:00:01.00,0:00:02.00,Default,,0,0,0,,{\\pos(320,50)}Sign +Dialogue: 0,0:00:03.00,0:00:04.00,Top,,0,0,0,,Hello +""" + + +@pytest.fixture(autouse=True) +def no_subtitleedit(monkeypatch): + monkeypatch.setattr(binaries, "SubtitleEdit", None) + + +def make_sub(tmp_path, name: str, text: str, codec: Codec, style: pysubs2.SSAStyle | None = None) -> Subtitle: + path = tmp_path / name + path.write_text(text, encoding="utf8") + sub = Subtitle(url="https://example.test/x", language="en", codec=codec) + sub.path = path + sub.ass_style = style + sub.ass_video_size = (1440, 1080) + return sub + + +def test_style_fields_override_the_defaults(): + style = sc.build_ass_style({"fontname": "My Font", "marginv": 40, "primarycolor": "&H0000FFFF"}) + assert (style.fontname, style.fontsize, style.bold, style.marginv) == ("My Font", 72, True, 40) + assert style.primarycolor == pysubs2.Color(255, 255, 0, 0) + assert style.backcolor == pysubs2.Color(0, 0, 0, 0xA0) + + +@pytest.mark.parametrize( + "fields", + [ + {"fontsize_px": 50}, + {"primarycolor": "white"}, + {"bold": "false"}, + {"fontname": "A,B"}, + {"fontname": " "}, + {"marginv": "low"}, + {"fontsize": None}, + {"fontsize": {}}, + {"fontsize": -5}, + {"fontsize": float("nan")}, + {"outline": float("inf")}, + {"marginv": True}, + {"marginv": 3.6}, + {"primarycolor": 16777215}, + {"fontname": "A\u2028B"}, + {"fontname": "A\x00B"}, + {"alignment": 10}, + ], +) +def test_bad_style_fields_raise(fields): + with pytest.raises(ValueError): + sc.build_ass_style(fields) + + +def test_ass_field_names_are_accepted(): + style = sc.build_ass_style({"Fontname": "My Font", "PrimaryColour": "&H0000FFFF", "MarginV": 40.0}) + assert (style.fontname, style.primarycolor, style.marginv) == ("My Font", pysubs2.Color(255, 255, 0, 0), 40) + + +def test_the_look_goes_only_to_subtitles_that_are_not_already_ass(tmp_path): + style = sc.build_ass_style({}) + vtt = make_sub(tmp_path, "x.vtt", VTT, Codec.WebVTT) + ass = make_sub(tmp_path, "x.ass", NATIVE_ASS, Codec.SubStationAlphav4) + videos = [ + Video(url="https://example.test/v", language="en"), + Video(url="https://example.test/v", language="en", width=3840, height=1600), + ] + set_subtitle_look([vtt, ass], style, videos) + assert (vtt.ass_style, ass.ass_style) == (style, None) + assert vtt.ass_video_size == ass.ass_video_size == (3840, 1600) + + +def test_a_stripped_styled_track_with_no_cues_left_stays_srt(tmp_path, monkeypatch): + monkeypatch.setattr("unshackle.core.config.config.subtitle", {"sdh_method": "auto"}, raising=False) + sub = make_sub(tmp_path, "x.vtt", VTT.split("00:00:03")[0], Codec.WebVTT, sc.build_ass_style({})) + sub.convert(Codec.SubStationAlphav4) + sub.strip_hearing_impaired() + assert sub.codec == Codec.SubRip + + +def test_a_stripped_native_ass_track_is_not_restyled(tmp_path, monkeypatch): + monkeypatch.setattr("unshackle.core.config.config.subtitle", {"sdh_method": "auto"}, raising=False) + sub = make_sub(tmp_path, "x.ass", NATIVE_ASS, Codec.SubStationAlphav4) + set_subtitle_look([sub], sc.build_ass_style({"fontname": "My Font"}), []) + sub.strip_hearing_impaired() + assert sub.codec == Codec.SubRip + + +def test_vtt_to_ass_gets_the_style_on_a_canvas_shaped_like_the_video(tmp_path): + sub = make_sub(tmp_path, "x.vtt", VTT, Codec.WebVTT, sc.build_ass_style({"fontname": "My Font"})) + sub.convert(Codec.SubStationAlphav4) + + subs = pysubs2.load(str(sub.path)) + assert (subs.info["PlayResX"], subs.info["PlayResY"]) == ("1440", "1080") + assert (subs.info["LayoutResX"], subs.info["LayoutResY"]) == ("1440", "1080") + assert subs.styles["Default"].fontname == "My Font" + assert subs.styles["Default"].fontsize == 72 + + +def test_vtt_to_ass_without_a_style_is_left_as_converted(tmp_path): + sub = make_sub(tmp_path, "x.vtt", VTT, Codec.WebVTT) + sub.convert(Codec.SubStationAlphav4) + + subs = pysubs2.load(str(sub.path)) + assert "PlayResY" not in subs.info + assert subs.styles["Default"].fontname == "Arial" + + +@pytest.mark.parametrize("target", [Codec.SubStationAlphav4, Codec.SubStationAlpha]) +def test_a_native_ass_track_is_never_restyled(tmp_path, target): + sub = make_sub(tmp_path, "x.ass", NATIVE_ASS, Codec.SubStationAlphav4, sc.build_ass_style({})) + sub.convert(target) + + subs = pysubs2.load(str(sub.path)) + assert subs.info["PlayResY"] == "360" + assert subs.styles["Default"].fontsize == 20 + assert "\\pos(320,50)" in subs.events[0].text + + +def test_a_converter_canvas_is_kept_and_the_style_scales_to_it(tmp_path): + path = tmp_path / "x.ass" + path.write_text(NATIVE_ASS, encoding="utf8") + sc.style_substation(path, sc.build_ass_style({"fontname": "My Font"}), (1920, 1080)) + + subs = pysubs2.load(str(path)) + assert (subs.info["PlayResX"], subs.info["PlayResY"]) == ("640", "360") + assert subs.styles["Default"].fontsize == pytest.approx(24) + assert subs.styles["Default"].marginv == 20 + top = subs.styles["Top"] + assert (top.fontname, top.alignment, top.italic) == ("My Font", pysubs2.Alignment.TOP_CENTER, True) + + +def test_restyling_twice_gives_the_same_file(tmp_path): + sub = make_sub(tmp_path, "x.vtt", VTT, Codec.WebVTT, sc.build_ass_style({})) + sub.convert(Codec.SubStationAlphav4) + first = sub.path.read_text("utf8") + sc.style_substation(sub.path, sub.ass_style, sub.ass_video_size) + assert sub.path.read_text("utf8") == first + + +def test_a_stripped_styled_track_stays_styled_ass(tmp_path, monkeypatch): + monkeypatch.setattr("unshackle.core.config.config.subtitle", {"sdh_method": "auto"}, raising=False) + sub = make_sub(tmp_path, "x.vtt", VTT, Codec.WebVTT, sc.build_ass_style({"fontname": "My Font"})) + sub.convert(Codec.SubStationAlphav4) + sub.strip_hearing_impaired() + + assert sub.codec == Codec.SubStationAlphav4 + subs = pysubs2.load(str(sub.path)) + assert subs.styles["Default"].fontname == "My Font" + assert [e.plaintext for e in subs.events] == ["Hello there."] + + +@pytest.mark.parametrize( + ("sub_format", "sub_font", "fields", "expected"), + [ + (None, None, None, (None, None)), + (None, None, {"fontsize": 60}, (Codec.SubStationAlphav4, "Arial")), + (None, "My Font", None, (Codec.SubStationAlphav4, "My Font")), + (Codec.SubStationAlpha, "My Font", {"fontname": "Other"}, (Codec.SubStationAlpha, "My Font")), + (Codec.SubRip, None, {"fontsize": 60}, (Codec.SubRip, None)), + ("original", None, {"fontsize": 60}, ("original", None)), + ], +) +def test_subtitle_look_picks_the_format_and_style(sub_format, sub_font, fields, expected): + out_format, style = subtitle_look(sub_format, sub_font, fields) + assert (out_format, style.fontname if style else None) == expected + + +def test_a_typed_font_overrides_a_format_from_config(): + out_format, style = subtitle_look(Codec.SubRip, "My Font", None, typed_format=False) + assert (out_format, style.fontname if style else None) == (Codec.SubStationAlphav4, "My Font") + + +def test_a_font_from_config_is_ignored_with_another_format(): + assert subtitle_look(Codec.SubRip, "My Font", None, typed_font=False) == (Codec.SubRip, None) + assert subtitle_look("original", "My Font", {"fontsize": 60}, typed_font=False) == ("original", None) + + +@pytest.mark.parametrize( + ("data", "error"), + [ + ({"sub_font": "My Font", "sub_format": "SRT"}, "Invalid sub_font: --sub-font needs ASS subtitles"), + ({"sub_font": "A,B"}, "Invalid sub_font: ass_style fontname"), + ], +) +def test_the_api_refuses_a_sub_font_it_cannot_use(data, error): + assert (validate_download_parameters(data) or "").startswith(error) + + +def test_the_api_takes_a_sub_font_with_ass(): + assert validate_download_parameters({"sub_font": "My Font", "sub_format": "ASS"}) is None + + +@pytest.mark.parametrize( + ("sub_format", "fields"), + [(Codec.SubRip, None), ("original", None), (None, "Arial,72"), (None, {"bold": "yes"})], +) +def test_subtitle_look_refuses_a_style_it_cannot_use(sub_format, fields): + with pytest.raises(ValueError): + subtitle_look(sub_format, "My Font" if fields is None else None, fields) + + +@pytest.mark.parametrize("name", ["..", "../other"]) +def test_a_font_name_that_is_a_path_finds_no_custom_font(tmp_path, monkeypatch, name): + monkeypatch.setattr("unshackle.core.config.config.directories.fonts", tmp_path / "fonts", raising=False) + for folder in ("fonts/My Font", "other"): + (tmp_path / folder).mkdir(parents=True) + (tmp_path / folder / "x.ttf").write_bytes(b"") + (tmp_path / "x.ttf").write_bytes(b"") + assert dl.find_custom_font(None, "My Font") == tmp_path / "fonts/My Font/x.ttf" # type: ignore[arg-type] + assert dl.find_custom_font(None, name) is None # type: ignore[arg-type] diff --git a/unshackle/commands/dl.py b/unshackle/commands/dl.py index 70de3ee2..61eea03c 100644 --- a/unshackle/commands/dl.py +++ b/unshackle/commands/dl.py @@ -103,6 +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.track import Track, assert_fragments_decrypted, has_encrypted_sample_entry from unshackle.core.tracks.tracks import at_resolution from unshackle.core.utilities import ( @@ -152,6 +153,8 @@ from unshackle.core.vault import Vault from unshackle.core.vaults import Vaults if TYPE_CHECKING: + import pysubs2 + from unshackle.core.remote_service import ServerKeySource @@ -277,6 +280,41 @@ def keep_generated_dv_bases(videos: Sequence[Video], bases: Sequence[Video.Range return [v for v in videos if v.range != Video.Range.DV and (v.range not in HYBRID_BASE_RANGES or v.range in bases)] +def subtitle_look( + sub_format: Optional[Union[Subtitle.Codec, str]], + sub_font: Optional[str], + style_fields: Any, + *, + typed_font: bool = True, + typed_format: bool = True, +) -> tuple[Optional[Union[Subtitle.Codec, str]], Optional[pysubs2.SSAStyle]]: + """The subtitle format and ASS style for --sub-font and the subtitle.ass_style config. + + A style makes ASS the format when none is set. Another format leaves the style unused. A typed --sub-font + overrides a format from config, refuses a typed one, and a font from config is ignored like the config style. + """ + if typed_font and sub_font and sub_format is not None and sub_format not in SUBSTATION: + if typed_format: + raise ValueError(f"--sub-font needs ASS subtitles, not {getattr(sub_format, 'name', sub_format)}") + sub_format = None + if not (style_fields or sub_font) or sub_format not in (None, *SUBSTATION): + return sub_format, None + if not isinstance(style_fields, (dict, type(None))): + raise ValueError("subtitle.ass_style must be a mapping of style fields") + style = build_ass_style({**(style_fields or {}), **({"fontname": sub_font} if sub_font else {})}) + return sub_format or Subtitle.Codec.SubStationAlphav4, style + + +def set_subtitle_look( + subtitles: Iterable[Subtitle], style: Optional[pysubs2.SSAStyle], videos: Iterable[Video] +) -> None: + """Give each subtitle that is not already SSA/ASS the style, and every subtitle the size of the first video.""" + size = next(((v.width, v.height) for v in videos if v.width and v.height), None) + for subtitle in subtitles: + subtitle.ass_style = None if subtitle.codec in SUBSTATION else style + subtitle.ass_video_size = size + + def hybrid_gate_failure(videos: Sequence[Video], generate_dv: bool) -> Optional[str]: """The message that stops HYBRID for these selected videos, or None when it can go on.""" if any(v.native_hybrid for v in videos): @@ -575,8 +613,10 @@ class dl: font_name: Font family name to find Returns: - Path to font file, or None if not found + Path to font file, or None if not found. A name that is a path, not one folder name, finds nothing. """ + if Path(font_name).name != font_name or font_name in ("", ".."): + return None family_dir = Path(config.directories.fonts, font_name) if family_dir.exists(): fonts = list(family_dir.glob("*.*tf")) @@ -1041,6 +1081,12 @@ class dl: default=None, help="Set Output Subtitle Format, only converting if necessary. Use 'original' to keep source format.", ) + @click.option( + "--sub-font", + type=str, + default=None, + help="Font for subtitles converted to ASS, with the subtitle.ass_style look. Implies --sub-format ASS.", + ) @click.option("-V", "--video-only", is_flag=True, default=False, help="Only download video tracks.") @click.option("-A", "--audio-only", is_flag=True, default=False, help="Only download audio tracks.") @click.option("-S", "--subs-only", is_flag=True, default=False, help="Only download subtitle tracks.") @@ -1279,6 +1325,7 @@ class dl: LICENSE_KEY_CACHE: dict[UUID, str] = {} ALL_DRM_LOCK = Lock() _remote_service: Optional["ServerKeySource"] = None + typed_params: frozenset[str] = frozenset() def __init__( self, @@ -1305,6 +1352,11 @@ class dl: raise ValueError("A subcommand to invoke was not specified, the main code cannot continue.") self.log = logging.getLogger("download") + self.typed_params = frozenset( + name + for name in ("sub_font", "sub_format") + if ctx.get_parameter_source(name) not in (None, ParameterSource.DEFAULT, ParameterSource.DEFAULT_MAP) + ) self.completed_files: list[Path] = [] # Subtitles skipped under --skip-subtitle-errors, recorded so an embedding caller can # report which weren't available without parsing the console output. See SkippedSubtitle. @@ -1696,6 +1748,7 @@ class dl: forced_s_lang: list[str], exact_lang: bool, sub_format: Optional[Union[Subtitle.Codec, str]], + sub_font: Optional[str], video_only: bool, audio_only: bool, subs_only: bool, @@ -1927,6 +1980,18 @@ class dl: self.log.error("--select-titles and -w/--wanted cannot be used together") sys.exit(1) + try: + sub_format, ass_style = subtitle_look( + sub_format, + sub_font, + config.subtitle.get("ass_style"), + typed_font="sub_font" in self.typed_params, + typed_format="sub_format" in self.typed_params, + ) + except ValueError as e: + self.log.error(str(e)) + sys.exit(1) + if generate_dv and Video.Range.DV in range_ and Video.Range.HYBRID not in range_: self.log.warning("--generate-dv ignored: DV is in the selected ranges") range_, generated_dv_bases = generated_dv_ranges(range_, generate_dv) @@ -3337,6 +3402,7 @@ class dl: shutil.copy2(subtitle.path, original_path) sidecar_original_paths[subtitle.id] = original_path + set_subtitle_look(title.tracks.subtitles, ass_style, title.tracks.videos) with console.status("Converting Subtitles..."): sub_conversions: dict[tuple[str, str], int] = {} for subtitle in title.tracks.subtitles: diff --git a/unshackle/core/api/handlers.py b/unshackle/core/api/handlers.py index d8e12a38..2450edea 100644 --- a/unshackle/core/api/handlers.py +++ b/unshackle/core/api/handlers.py @@ -92,6 +92,7 @@ DEFAULT_DOWNLOAD_PARAMS = { "forced_s_lang": [], "exact_lang": False, "sub_format": None, + "sub_font": None, "video_only": False, "audio_only": False, "subs_only": False, @@ -2253,13 +2254,19 @@ def validate_download_parameters(data: Dict[str, Any]) -> Optional[str]: if value is not None and (not isinstance(value, list) or not all(isinstance(v, str) for v in value)): return f"{key} must be an array of language strings" - from unshackle.commands.dl import dl + from unshackle.commands.dl import dl, subtitle_look try: - cast_dl_params(dl.cli, data) + params = cast_dl_params(dl.cli, data) except click.BadParameter as e: return e.message + if params.get("sub_font"): + try: + subtitle_look(params.get("sub_format"), params["sub_font"], None) + except ValueError as e: + return f"Invalid sub_font: {e}" + return None diff --git a/unshackle/core/api/routes.py b/unshackle/core/api/routes.py index dd96d8ea..086641c9 100644 --- a/unshackle/core/api/routes.py +++ b/unshackle/core/api/routes.py @@ -1036,6 +1036,9 @@ async def download(request: web.Request) -> web.Response: sub_format: type: string description: Output subtitle format such as SRT or VTT, or "original" to keep the source format (default - None) + sub_font: + type: string + description: Font for subtitles converted to ASS, implies sub_format ASS (default - None) video_only: type: boolean description: Only download video tracks (default - false) diff --git a/unshackle/core/tracks/subtitle.py b/unshackle/core/tracks/subtitle.py index b66e40fe..47a543c6 100644 --- a/unshackle/core/tracks/subtitle.py +++ b/unshackle/core/tracks/subtitle.py @@ -9,7 +9,7 @@ from enum import Enum from functools import partial from io import BytesIO from pathlib import Path -from typing import Any, Callable, Iterable, Optional, Sequence, Union +from typing import TYPE_CHECKING, Any, Callable, Iterable, Optional, Sequence, Union import click import pycaption @@ -29,6 +29,9 @@ from unshackle.core.utilities import PYMP4_LOCK, try_ensure_utf8, with_script from unshackle.core.utils.subprocess import log_tool_run from unshackle.core.utils.webvtt import merge_segmented_webvtt, rebase_webvtt +if TYPE_CHECKING: + import pysubs2 + # silence srt library INFO logging logging.getLogger("srt").setLevel(logging.ERROR) @@ -186,6 +189,9 @@ class Subtitle(Track): if self.forced and (self.cc or self.sdh): raise ValueError("A text track cannot be CC/SDH as well as Forced.") + self.ass_style: Optional[pysubs2.SSAStyle] = None + self.ass_video_size: Optional[tuple[int, int]] = None + # TODO: Migrate to new event observer system # Called after Track has been converted to another format self.OnConverted: Optional[Callable[[Subtitle.Codec], None]] = None @@ -1227,6 +1233,7 @@ class Subtitle(Track): format=output_format, ) else: + styled_codec = self.codec if self.ass_style else None if config.subtitle.get("convert_before_strip", True) and self.codec != Subtitle.Codec.SubRip: # Filter reads SRT only; force the (possibly lossy) conversion so content and # codec label stay in sync. convert() relabels path/codec only on real conversion. @@ -1255,6 +1262,8 @@ class Subtitle(Track): log.warning(f"Failed to strip SDH from {self.path.name}, keeping it as is: {e}") else: raise + if styled_codec and self.codec != styled_codec and self.path.read_text("utf-8", errors="replace").strip(): + self.convert(styled_codec) def reverse_rtl(self) -> None: """ diff --git a/unshackle/core/tracks/subtitle_convert.py b/unshackle/core/tracks/subtitle_convert.py index 256fab8e..a9eef155 100644 --- a/unshackle/core/tracks/subtitle_convert.py +++ b/unshackle/core/tracks/subtitle_convert.py @@ -16,12 +16,13 @@ from __future__ import annotations import html import logging +import math import re import subprocess import time from itertools import pairwise from pathlib import Path -from typing import Optional, Protocol +from typing import Any, Mapping, Optional, Protocol import pycaption import pysubs2 @@ -315,6 +316,110 @@ def decode_entities(out: Path) -> None: out.write_text(decoded, encoding="utf8") +ASS_CANVAS_HEIGHT = 1080 +ASS_STYLE_DEFAULTS: dict[str, Any] = { + "fontsize": 72, + "bold": True, + "outline": 3.6, + "shadow": 1.5, + "backcolor": "&HA0000000", + "marginl": 100, + "marginr": 100, + "marginv": 60, +} +NON_NEGATIVE_STYLE_FIELDS = frozenset({"fontsize", "outline", "shadow", "scalex", "scaley"}) + + +def ass_color(text: str) -> pysubs2.Color: + """Read an ``&HAABBGGRR`` colour, the form an ASS style line uses.""" + value = int(text.strip().upper().removeprefix("&H").rstrip("&"), 16) + if not 0 <= value <= 0xFFFFFFFF: + raise ValueError(f"{text!r} is not an &HAABBGGRR colour") + return pysubs2.Color(value & 0xFF, value >> 8 & 0xFF, value >> 16 & 0xFF, value >> 24 & 0xFF) + + +def style_field_value(name: str, current: Any, value: Any) -> Any: + """``value`` as the type of the style field that holds ``current``; ValueError or TypeError when it does not fit.""" + if isinstance(current, pysubs2.Color): + if not isinstance(value, str): + raise ValueError + return ass_color(value) + if isinstance(current, bool) != isinstance(value, bool): + raise ValueError + if isinstance(current, bool): + return value + if isinstance(current, str): + if not isinstance(value, str) or not value.strip() or "," in value or not value.isprintable(): + raise ValueError + return value + number = float(value) + if not math.isfinite(number) or (name in NON_NEGATIVE_STYLE_FIELDS and number < 0): + raise ValueError + if isinstance(current, float): + return number + if not number.is_integer(): + raise ValueError + return type(current)(int(number)) + + +def build_ass_style(fields: Mapping[str, Any]) -> pysubs2.SSAStyle: + """Build the style for converted subtitles from style fields over ``ASS_STYLE_DEFAULTS``. + + A field is a pysubs2 name (``primarycolor``) or an ASS one (``PrimaryColour``). Sizes and margins are for a + 1080-line canvas. A value that does not fit its field raises ValueError. + """ + style = pysubs2.SSAStyle() + for name, value in {**ASS_STYLE_DEFAULTS, **fields}.items(): + field = str(name).lower().replace("colour", "color") + if field not in vars(style): + raise ValueError(f"ass_style has no field {name!r}") + try: + setattr(style, field, style_field_value(field, getattr(style, field), value)) + except (ValueError, TypeError): + raise ValueError(f"ass_style {name}: {value!r} is not valid") from None + return style + + +def style_substation(path: Path, style: pysubs2.SSAStyle, video_size: Optional[tuple[int, int]]) -> None: + """Give every style of a converted SSA/ASS file the look of ``style``. + + A file with no PlayResY gets a 1080-line canvas in the shape of the video. A file with one keeps it, so positions + a converter wrote stay put, and the style scales to it. Each style keeps its own alignment and italic flag. + """ + try: + subs = pysubs2.load(str(path), encoding="utf-8-sig") + except (pysubs2.exceptions.Pysubs2Error, UnicodeDecodeError, ValueError) as e: + log.warning(f"Cannot style {path.name}: {e}") + return + try: + height = float(subs.info["PlayResY"]) + except (KeyError, ValueError): + height = 0.0 + if height <= 0: + width, video_height = video_size or (16, 9) + height = ASS_CANVAS_HEIGHT + subs.info["PlayResX"] = str(round(height * width / video_height)) + subs.info["PlayResY"] = str(ASS_CANVAS_HEIGHT) + if video_size: + subs.info["LayoutResX"], subs.info["LayoutResY"] = str(width), str(video_height) + subs.info["ScaledBorderAndShadow"] = "yes" + + scale = height / ASS_CANVAS_HEIGHT + scaled = style.copy() + for name in ("fontsize", "outline", "shadow", "spacing"): + setattr(scaled, name, getattr(style, name) * scale) + for name in ("marginl", "marginr", "marginv"): + setattr(scaled, name, round(getattr(style, name) * scale)) + + styles = {} + for name, old in subs.styles.items(): + styles[name] = scaled.copy() + styles[name].alignment, styles[name].italic = old.alignment, old.italic + styles["Default"] = scaled + subs.styles = styles + subs.save(str(path), format_=subs.format, encoding="utf-8") + + def finalize(sub: Subtitle, target: Codec, out: Path) -> Path: """Swap the track onto the converted file and fire the OnConverted callback.""" original = sub.path @@ -388,6 +493,8 @@ def run_conversion(sub: Subtitle, target: Codec, *, pin: Optional[str] = None, f continue if target in PLAIN_TEXT_TARGETS: decode_entities(out) + if sub.ass_style and target in SUBSTATION and source not in SUBSTATION: + style_substation(out, sub.ass_style, sub.ass_video_size) return finalize(sub, target, out) finally: if sorted_src: