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.
This commit is contained in:
imSp4rky
2026-11-11 10:04:57 -07:00
parent 590a57113b
commit f85d7f5fa8
11 changed files with 506 additions and 5 deletions
+1
View File
@@ -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`. | | `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. | | `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_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). | | `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. | | `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. | | `skip_subtitle_errors` | boolean | `false` | Skip a subtitle that fails to download and keep the other tracks. |
+1
View File
@@ -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. | | `-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. | | `--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-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" !!! 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 Naming languages with `-l` or `-sl` removes the others. To keep every language but put
+1
View File
@@ -1198,6 +1198,7 @@ authoritative list.
| `--forced-subs` | `-fs` | Include forced subtitles. | | `--forced-subs` | `-fs` | Include forced subtitles. |
| `--forced-s-lang` | `-fsl` | Forced subtitle language(s); implies `-fs`. `-` excludes. | | `--forced-s-lang` | `-fsl` | Forced subtitle language(s); implies `-fs`. `-` excludes. |
| `--sub-format` | | Output subtitle format. | | `--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`). | | `--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`. | | `--daily` | | Fill missing air dates from TVDB during `--enrich`. |
| `--select-titles` | | Interactively pick episodes or films. | | `--select-titles` | | Interactively pick episodes or films. |
+56
View File
@@ -83,6 +83,7 @@ Subtitle selection happens through `dl` flags. The most important is language se
| `-S`, `--subs-only` | Download only subtitle tracks. | | `-S`, `--subs-only` | Download only subtitle tracks. |
| `-ns`, `--no-subs` | Do not download subtitle tracks at all. | | `-ns`, `--no-subs` | Do not download subtitle tracks at all. |
| `--sub-format` | Output subtitle format / conversion target (see [conversion](#converting-subtitle-formats)). | | `--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. | | `--skip-subtitle-errors` | Skip a subtitle that fails to download instead of aborting the whole title. |
### Choosing languages ### 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 a format name like `srt` or `vtt`, or `original` to keep each subtitle in its downloaded
format. 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 ## Fonts for styled subtitles
Styled SSA/ASS subtitles reference specific fonts by name. When unshackle muxes such a 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. | | `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. | | `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. | | `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 ### Which settings to change
+2
View File
@@ -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`. | | `range` | list | `["SDR"]` | Colour range(s): `SDR`, `HLG`, `HDR10`, `HDR10P`, `DV`, `HYBRID`. |
| `channels` | float | *(unset)* | Audio channels, e.g. `6` for 5.1. | | `channels` | float | *(unset)* | Audio channels, e.g. `6` for 5.1. |
| `sub_format` | str | *(unset)* | Convert subtitles to this format (`srt`, `vtt`, `original`, ...). | | `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_subs` | bool | `false` | Include forced subtitle tracks. |
| `forced_s_lang` | list | `[]` | Forced subtitle language(s); implies `forced_subs`. A `-` prefix excludes. | | `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`. | | `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. | | `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. | | `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. | | `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. | | `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 ```yaml
+248
View File
@@ -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]
+67 -1
View File
@@ -103,6 +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.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 (
@@ -152,6 +153,8 @@ from unshackle.core.vault import Vault
from unshackle.core.vaults import Vaults from unshackle.core.vaults import Vaults
if TYPE_CHECKING: if TYPE_CHECKING:
import pysubs2
from unshackle.core.remote_service import ServerKeySource 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)] 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]: 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.""" """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): if any(v.native_hybrid for v in videos):
@@ -575,8 +613,10 @@ class dl:
font_name: Font family name to find font_name: Font family name to find
Returns: 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) family_dir = Path(config.directories.fonts, font_name)
if family_dir.exists(): if family_dir.exists():
fonts = list(family_dir.glob("*.*tf")) fonts = list(family_dir.glob("*.*tf"))
@@ -1041,6 +1081,12 @@ class dl:
default=None, default=None,
help="Set Output Subtitle Format, only converting if necessary. Use 'original' to keep source format.", 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("-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("-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.") @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] = {} LICENSE_KEY_CACHE: dict[UUID, str] = {}
ALL_DRM_LOCK = Lock() ALL_DRM_LOCK = Lock()
_remote_service: Optional["ServerKeySource"] = None _remote_service: Optional["ServerKeySource"] = None
typed_params: frozenset[str] = frozenset()
def __init__( def __init__(
self, self,
@@ -1305,6 +1352,11 @@ class dl:
raise ValueError("A subcommand to invoke was not specified, the main code cannot continue.") raise ValueError("A subcommand to invoke was not specified, the main code cannot continue.")
self.log = logging.getLogger("download") 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] = [] self.completed_files: list[Path] = []
# Subtitles skipped under --skip-subtitle-errors, recorded so an embedding caller can # Subtitles skipped under --skip-subtitle-errors, recorded so an embedding caller can
# report which weren't available without parsing the console output. See SkippedSubtitle. # report which weren't available without parsing the console output. See SkippedSubtitle.
@@ -1696,6 +1748,7 @@ class dl:
forced_s_lang: list[str], forced_s_lang: list[str],
exact_lang: bool, exact_lang: bool,
sub_format: Optional[Union[Subtitle.Codec, str]], sub_format: Optional[Union[Subtitle.Codec, str]],
sub_font: Optional[str],
video_only: bool, video_only: bool,
audio_only: bool, audio_only: bool,
subs_only: bool, subs_only: bool,
@@ -1927,6 +1980,18 @@ class dl:
self.log.error("--select-titles and -w/--wanted cannot be used together") self.log.error("--select-titles and -w/--wanted cannot be used together")
sys.exit(1) 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_: 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") self.log.warning("--generate-dv ignored: DV is in the selected ranges")
range_, generated_dv_bases = generated_dv_ranges(range_, generate_dv) range_, generated_dv_bases = generated_dv_ranges(range_, generate_dv)
@@ -3337,6 +3402,7 @@ class dl:
shutil.copy2(subtitle.path, original_path) shutil.copy2(subtitle.path, original_path)
sidecar_original_paths[subtitle.id] = 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..."): 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:
+9 -2
View File
@@ -92,6 +92,7 @@ DEFAULT_DOWNLOAD_PARAMS = {
"forced_s_lang": [], "forced_s_lang": [],
"exact_lang": False, "exact_lang": False,
"sub_format": None, "sub_format": None,
"sub_font": None,
"video_only": False, "video_only": False,
"audio_only": False, "audio_only": False,
"subs_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)): 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" 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: try:
cast_dl_params(dl.cli, data) params = cast_dl_params(dl.cli, data)
except click.BadParameter as e: except click.BadParameter as e:
return e.message 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 return None
+3
View File
@@ -1036,6 +1036,9 @@ async def download(request: web.Request) -> web.Response:
sub_format: sub_format:
type: string type: string
description: Output subtitle format such as SRT or VTT, or "original" to keep the source format (default - None) 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: video_only:
type: boolean type: boolean
description: Only download video tracks (default - false) description: Only download video tracks (default - false)
+10 -1
View File
@@ -9,7 +9,7 @@ from enum import Enum
from functools import partial from functools import partial
from io import BytesIO from io import BytesIO
from pathlib import Path 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 click
import pycaption 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.subprocess import log_tool_run
from unshackle.core.utils.webvtt import merge_segmented_webvtt, rebase_webvtt from unshackle.core.utils.webvtt import merge_segmented_webvtt, rebase_webvtt
if TYPE_CHECKING:
import pysubs2
# silence srt library INFO logging # silence srt library INFO logging
logging.getLogger("srt").setLevel(logging.ERROR) logging.getLogger("srt").setLevel(logging.ERROR)
@@ -186,6 +189,9 @@ class Subtitle(Track):
if self.forced and (self.cc or self.sdh): if self.forced and (self.cc or self.sdh):
raise ValueError("A text track cannot be CC/SDH as well as Forced.") 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 # TODO: Migrate to new event observer system
# Called after Track has been converted to another format # Called after Track has been converted to another format
self.OnConverted: Optional[Callable[[Subtitle.Codec], None]] = None self.OnConverted: Optional[Callable[[Subtitle.Codec], None]] = None
@@ -1227,6 +1233,7 @@ class Subtitle(Track):
format=output_format, format=output_format,
) )
else: 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: 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 # 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. # 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}") log.warning(f"Failed to strip SDH from {self.path.name}, keeping it as is: {e}")
else: else:
raise 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: def reverse_rtl(self) -> None:
""" """
+108 -1
View File
@@ -16,12 +16,13 @@ from __future__ import annotations
import html import html
import logging import logging
import math
import re import re
import subprocess import subprocess
import time import time
from itertools import pairwise from itertools import pairwise
from pathlib import Path from pathlib import Path
from typing import Optional, Protocol from typing import Any, Mapping, Optional, Protocol
import pycaption import pycaption
import pysubs2 import pysubs2
@@ -315,6 +316,110 @@ def decode_entities(out: Path) -> None:
out.write_text(decoded, encoding="utf8") 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: def finalize(sub: Subtitle, target: Codec, out: Path) -> Path:
"""Swap the track onto the converted file and fire the OnConverted callback.""" """Swap the track onto the converted file and fire the OnConverted callback."""
original = sub.path original = sub.path
@@ -388,6 +493,8 @@ def run_conversion(sub: Subtitle, target: Codec, *, pin: Optional[str] = None, f
continue continue
if target in PLAIN_TEXT_TARGETS: if target in PLAIN_TEXT_TARGETS:
decode_entities(out) 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) return finalize(sub, target, out)
finally: finally:
if sorted_src: if sorted_src: