mirror of
https://github.com/unshackle-dl/unshackle.git
synced 2026-11-11 19:45:06 +00:00
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:
@@ -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. |
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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. |
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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]
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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:
|
||||
"""
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user