refactor(tracks): name and match resolutions by one standard-height rule

standard_resolution() replaces the aspect-ratio logic in naming and is shared by -q matching, export names and progress/hybrid labels, so 1920x1088, 1916x1076, portrait and DCI sizes get standard names.
This commit is contained in:
imSp4rky
2026-10-31 22:05:26 -06:00
parent d5a8ed2a39
commit 86f26c31d5
13 changed files with 190 additions and 84 deletions
+11 -2
View File
@@ -105,8 +105,8 @@ Every variable below is valid in both output and folder templates. unshackle tak
| `title_type` | Media kind of the title | `movie`, `series`, `music` | | `title_type` | Media kind of the title | `movie`, `series`, `music` |
| `year` | Release year | `2024` | | `year` | Release year | `2024` |
| `source` | Service tag / class name (empty with `--no-source`) | `EXAMPLE` | | `source` | Service tag / class name (empty with `--no-source`) | `EXAMPLE` |
| `quality` | Resolution with scan suffix | `1080p`, `2160p`, `576i` | | `quality` | Standard resolution with scan suffix | `1080p`, `2160p`, `576i` |
| `resolution` | Resolution number only | `1080` | | `resolution` | Standard resolution number only | `1080` |
| `video` | Video codec | `H.264`, `H.265` | | `video` | Video codec | `H.264`, `H.265` |
| `hdr` | Dynamic-range label | `HDR`, `HDR10P`, `DV`, `DV.HDR`, `DV.HDR10P`, `HLG` | | `hdr` | Dynamic-range label | `HDR`, `HDR10P`, `DV`, `DV.HDR`, `DV.HDR10P`, `HLG` |
| `hfr` | High frame rate marker (frame rate > 30) | `HFR` | | `hfr` | High frame rate marker (frame rate > 30) | `HFR` |
@@ -115,6 +115,15 @@ Every variable below is valid in both output and folder templates. unshackle tak
| `repack` | `REPACK` when the `--repack` flag is used | `REPACK` | | `repack` | `REPACK` when the `--repack` flag is used | `REPACK` |
| `lang_tag` | Result of your `language_tags` rules, if configured | `MULTi` | | `lang_tag` | Result of your `language_tags` rules, if configured | `MULTi` |
!!! note "`quality` and `resolution` use the standard height"
unshackle names a video by the nearest standard height (4320, 2160, 1440, 1080, 720, 576, 540,
480, 360 or 240) when it is within 10%. It measures the short side and the height that a 16:9
picture of the same width has, and uses the larger one. So a cropped 1920x800, a padded
1920x1088 and a 1080x1920 portrait video are all `1080p`. A short side within 16 lines of
2160, 1440 or 1080 gives that height, so an ultrawide 2560x1080 is `1080p`. A size with no
standard height near it, such as 1600x900, keeps its own height. `dl -q` and the
`dl --export` file name use the same rule.
!!! note "How `language_tags` rules match" !!! note "How `language_tags` rules match"
unshackle tries the rules in order, and they compare languages with the same [matching rules](downloading.md#exact-vs-fuzzy-matching) as `-l`: a rule condition of `en` matches `en-US`, `en-GB`, and any other `en-*` form, but `es-419` matches only `es-419`. Within a single rule, **all** conditions must match (AND logic) for it to apply. The first rule that fully matches wins. If none match, unshackle removes `{lang_tag?}` from the filename cleanly. unshackle tries the rules in order, and they compare languages with the same [matching rules](downloading.md#exact-vs-fuzzy-matching) as `-l`: a rule condition of `en` matches `en-US`, `en-GB`, and any other `en-*` form, but `es-419` matches only `es-419`. Within a single rule, **all** conditions must match (AND logic) for it to apply. The first rule that fully matches wins. If none match, unshackle removes `{lang_tag?}` from the filename cleanly.
-1
View File
@@ -76,7 +76,6 @@ def test_codec_names_renames_video_and_each_hdr_part(monkeypatch: pytest.MonkeyP
height=2160, height=2160,
format="HEVC", format="HEVC",
scan_type="Progressive", scan_type="Progressive",
other_display_aspect_ratio=["16:9"],
hdr_format_commercial="HDR10+", hdr_format_commercial="HDR10+",
hdr_format="Dolby Vision, Version 1.0, Profile 8.1", hdr_format="Dolby Vision, Version 1.0, Profile 8.1",
transfer_characteristics=None, transfer_characteristics=None,
-1
View File
@@ -32,7 +32,6 @@ def hdr_tag(
height=1080, height=1080,
format="AV1", format="AV1",
scan_type="Progressive", scan_type="Progressive",
other_display_aspect_ratio=["16:9"],
hdr_format_commercial=commercial, hdr_format_commercial=commercial,
hdr_format=hdr_format, hdr_format=hdr_format,
hdr_format_profile=profile, hdr_format_profile=profile,
+4 -4
View File
@@ -418,7 +418,7 @@ def test_a_conversion_run_removes_its_rpus_so_the_next_base_makes_its_own(
monkeypatch.setattr(Hybrid, "level_6", level_6) monkeypatch.setattr(Hybrid, "level_6", level_6)
monkeypatch.setattr(hybrid_mod.console, "print", lambda *a, **k: None) monkeypatch.setattr(hybrid_mod.console, "print", lambda *a, **k: None)
Hybrid([SimpleNamespace(id="b", path=base, height=2160, range=Video.Range.HDR10P)], "TEST") Hybrid([SimpleNamespace(id="b", path=base, width=None, height=2160, range=Video.Range.HDR10P)], "TEST")
assert seen == {"stale_l6": False, "active_fraction": 0.9} assert seen == {"stale_l6": False, "active_fraction": 0.9}
assert not (tmp_path / "RPU.bin").exists() and not (tmp_path / "RPU_L6.bin").exists() assert not (tmp_path / "RPU.bin").exists() and not (tmp_path / "RPU_L6.bin").exists()
@@ -446,8 +446,8 @@ def test_a_failed_resolution_removes_its_crop_edits_and_keeps_the_dv_files(
monkeypatch.setattr(Hybrid, "injecting", failed_inject) monkeypatch.setattr(Hybrid, "injecting", failed_inject)
monkeypatch.setattr(hybrid_mod.console, "print", lambda *a, **k: None) monkeypatch.setattr(hybrid_mod.console, "print", lambda *a, **k: None)
videos = [ videos = [
SimpleNamespace(id="b", path=tmp_path / "base.mkv", height=2160, range=Video.Range.HDR10), SimpleNamespace(id="b", path=tmp_path / "base.mkv", width=None, height=2160, range=Video.Range.HDR10),
SimpleNamespace(id="d", path=tmp_path / "dv.mkv", height=1080, range=Video.Range.DV), SimpleNamespace(id="d", path=tmp_path / "dv.mkv", width=None, height=1080, range=Video.Range.DV),
] ]
with pytest.raises(ValueError): with pytest.raises(ValueError):
@@ -559,7 +559,7 @@ def test_hybrid_asks_for_hdr10plus_only_for_a_conversion_without_dv(
def test_the_hybrid_step_gets_the_base_alone_when_there_is_no_dv(tmp_path: Path) -> None: def test_the_hybrid_step_gets_the_base_alone_when_there_is_no_dv(tmp_path: Path) -> None:
base = SimpleNamespace(id="base", path=tmp_path / "base.mp4", height=2160, range=Video.Range.HDR10P) base = SimpleNamespace(id="base", path=tmp_path / "base.mp4", width=None, height=2160, range=Video.Range.HDR10P)
tracks = hybrid_source_tracks(base, None) # type: ignore[arg-type] tracks = hybrid_source_tracks(base, None) # type: ignore[arg-type]
+119
View File
@@ -0,0 +1,119 @@
"""Tests for the standard resolution in the filename context, the export name and -q matching."""
from __future__ import annotations
import types
from typing import Optional
import pytest
from unshackle.core.config import config
from unshackle.core.export_name import resolution
from unshackle.core.titles.movie import Movie
from unshackle.core.tracks import Video
from unshackle.core.tracks.tracks import at_resolution
from unshackle.core.utilities import standard_resolution
class DummyService:
pass
@pytest.fixture(autouse=True)
def clean_config(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(config, "tag_rules", [])
monkeypatch.setattr(config, "language_tags", {})
monkeypatch.setattr(config, "codec_names", {})
def quality(width: Optional[int], height: Optional[int], scan_type: str = "Progressive") -> str:
movie = Movie(id_="movie-0001", service=DummyService, name="The Film", year=2024, language="en")
video = types.SimpleNamespace(
width=width,
height=height,
format="AVC",
scan_type=scan_type,
hdr_format_commercial=None,
hdr_format=None,
transfer_characteristics="BT.709",
transfer_characteristics_original=None,
frame_rate="25.000",
)
media_info = types.SimpleNamespace(video_tracks=[video], audio_tracks=[])
return str(movie.build_base_template_context(media_info)["quality"])
@pytest.mark.parametrize(
("width", "height", "expected"),
[
(3840, 2160, 2160),
(1920, 1080, 1080),
(1280, 720, 720),
(2560, 1440, 1440),
(1920, 1088, 1080), # padded to a multiple of 16
(960, 544, 540),
(640, 368, 360),
(1916, 1076, 1080), # a few lines short
(3836, 2156, 2160),
(1920, 800, 1080), # scope crop
(1920, 1036, 1080), # flat crop
(3840, 1600, 2160),
(1280, 544, 720),
(4096, 2160, 2160), # DCI
(4096, 1716, 2160),
(2048, 858, 1080),
(1440, 1080, 1080), # 4:3 pillarbox
(960, 720, 720),
(720, 576, 576), # anamorphic SD
(704, 576, 576),
(720, 480, 480),
(854, 480, 480),
(1080, 1920, 1080), # portrait
(1080, 1350, 1080),
(720, 1280, 720),
(2560, 1080, 1080), # ultrawide
(3440, 1440, 1440),
(1600, 900, 900), # no standard height near it
],
)
def test_standard_resolution(width: int, height: int, expected: int) -> None:
assert standard_resolution(width, height) == expected
def test_a_picture_with_one_side_uses_it() -> None:
assert standard_resolution(None, 1088) == 1080
def test_a_picture_with_no_size_has_no_resolution() -> None:
assert standard_resolution(None, None) is None
def test_the_filename_quality_uses_the_standard_resolution() -> None:
assert quality(1920, 1088) == "1080p"
assert quality(1920, 800) == "1080p"
def test_an_interlaced_picture_keeps_the_i_suffix() -> None:
assert quality(1920, 1088, scan_type="Interlaced") == "1080i"
def test_a_video_track_with_no_size_gives_an_empty_quality() -> None:
assert quality(None, None) == ""
def video(width: int, height: int) -> Video:
return Video(url="https://example.test/v.mpd", language="en", codec=Video.Codec.AVC, width=width, height=height)
def test_the_export_name_uses_the_standard_resolution() -> None:
assert resolution(video(1080, 1920)) == 1080
assert resolution(video(4096, 2160)) == 2160
@pytest.mark.parametrize(("width", "height"), [(1920, 1080), (1920, 800), (1916, 1076), (1920, 1088)])
def test_q_1080_matches_odd_1080p_encodes(width: int, height: int) -> None:
assert at_resolution(video(width, height), 1080)
def test_q_1080_does_not_match_720p() -> None:
assert not at_resolution(video(1280, 720), 1080)
-1
View File
@@ -104,7 +104,6 @@ def test_a_quality_rule_matches_the_built_context(monkeypatch: pytest.MonkeyPatc
height=2160, height=2160,
format="HEVC", format="HEVC",
scan_type="Progressive", scan_type="Progressive",
other_display_aspect_ratio=["16:9"],
hdr_format_commercial=None, hdr_format_commercial=None,
hdr_format=None, hdr_format=None,
transfer_characteristics=None, transfer_characteristics=None,
+8 -28
View File
@@ -120,6 +120,7 @@ from unshackle.core.utilities import (
partition_exclusions, partition_exclusions,
plain_subtitle_tags, plain_subtitle_tags,
resolve_sort_langs, resolve_sort_langs,
standard_resolution,
suggest_font_packages, suggest_font_packages,
time_elapsed_since, time_elapsed_since,
) )
@@ -2723,9 +2724,7 @@ class dl:
v for v in non_hybrid_tracks if v.range in non_hybrid_ranges v for v in non_hybrid_tracks if v.range in non_hybrid_ranges
] ]
for resolution in quality: for resolution in quality:
if any(v.height == resolution for v in deliverable_pool): if any(at_resolution(v, resolution) for v in deliverable_pool):
continue
if any(v.width and int(v.width * 9 / 16) == resolution for v in deliverable_pool):
continue continue
missing_resolutions.append(resolution) missing_resolutions.append(resolution)
@@ -2741,12 +2740,7 @@ class dl:
if non_hybrid_ranges and non_hybrid_tracks: if non_hybrid_ranges and non_hybrid_tracks:
non_hybrid_selected = [ non_hybrid_selected = [
v v for v in non_hybrid_tracks if any(at_resolution(v, res) for res in quality)
for v in non_hybrid_tracks
if any(
v.height == res or (v.width and int(v.width * (9 / 16)) == res)
for res in quality
)
] ]
title.tracks.videos = Tracks.merge_video_selections( title.tracks.videos = Tracks.merge_video_selections(
hybrid_selected, non_hybrid_selected hybrid_selected, non_hybrid_selected
@@ -2758,9 +2752,7 @@ class dl:
title.tracks.by_resolutions(quality) title.tracks.by_resolutions(quality)
for resolution in quality: for resolution in quality:
if any(v.height == resolution for v in title.tracks.videos): if any(at_resolution(v, resolution) for v in title.tracks.videos):
continue
if any(v.width and int(v.width * 9 / 16) == resolution for v in title.tracks.videos):
continue continue
missing_resolutions.append(resolution) missing_resolutions.append(resolution)
@@ -2818,11 +2810,7 @@ class dl:
candidates = [ candidates = [
t t
for t in non_hybrid_tracks for t in non_hybrid_tracks
if ( if (not resolution or at_resolution(t, resolution))
not resolution
or t.height == resolution
or (t.width and int(t.width * (9 / 16)) == resolution)
)
and (not color_range or t.range == color_range) and (not color_range or t.range == color_range)
and (not codec or t.codec == codec) and (not codec or t.codec == codec)
and (vlang is None or str(t.language) == vlang) and (vlang is None or str(t.language) == vlang)
@@ -2851,11 +2839,7 @@ class dl:
candidates = [ candidates = [
t t
for t in title.tracks.videos for t in title.tracks.videos
if ( if (not resolution or at_resolution(t, resolution))
not resolution
or t.height == resolution
or (t.width and int(t.width * (9 / 16)) == resolution)
)
and (not color_range or t.range == color_range) and (not color_range or t.range == color_range)
and (not codec or t.codec == codec) and (not codec or t.codec == codec)
and (vlang is None or str(t.language) == vlang) and (vlang is None or str(t.language) == vlang)
@@ -2893,11 +2877,7 @@ class dl:
candidates = [ candidates = [
t t
for t in fallback_pool for t in fallback_pool
if ( if (not resolution or at_resolution(t, resolution))
not resolution
or t.height == resolution
or (t.width and int(t.width * (9 / 16)) == resolution)
)
and (not color_range or t.range == color_range) and (not color_range or t.range == color_range)
and (not codec or t.codec == codec) and (not codec or t.codec == codec)
and (vlang is None or str(t.language) == vlang) and (vlang is None or str(t.language) == vlang)
@@ -3653,7 +3633,7 @@ class dl:
head = next((v for v in video_tracks if v), None) head = next((v for v in video_tracks if v), None)
if head: if head:
if len(quality) > 1: if len(quality) > 1:
task_description += f" {head.height}p" task_description += f" {standard_resolution(head.width, head.height)}p"
if len(range_) > 1: if len(range_) > 1:
task_description += f" {head.range.name}" task_description += f" {head.range.name}"
if len(vcodec) > 1: if len(vcodec) > 1:
+4 -3
View File
@@ -10,6 +10,7 @@ import time
from typing import Any, Callable, Optional from typing import Any, Callable, Optional
from unshackle.core.constants import AnyTrack from unshackle.core.constants import AnyTrack
from unshackle.core.utilities import standard_resolution
JOB_PROGRESS_TERMINAL_STATES = {"Downloaded", "Decrypted", "[yellow]SKIPPED"} JOB_PROGRESS_TERMINAL_STATES = {"Downloaded", "Decrypted", "[yellow]SKIPPED"}
PROGRESS_EMIT_INTERVAL = 0.25 PROGRESS_EMIT_INTERVAL = 0.25
@@ -26,9 +27,9 @@ def track_progress_label(track: AnyTrack) -> str:
track_type = type(track).__name__ track_type = type(track).__name__
if track_type == "Video": if track_type == "Video":
parts = ["video"] parts = ["video"]
height = getattr(track, "height", None) resolution = standard_resolution(getattr(track, "width", None), getattr(track, "height", None))
if height: if resolution:
parts.append(f"{height}p") parts.append(f"{resolution}p")
track_range = getattr(track, "range", None) track_range = getattr(track, "range", None)
if track_range is not None: if track_range is not None:
parts.append(track_range.value) parts.append(track_range.value)
+3 -9
View File
@@ -15,9 +15,8 @@ from typing import Any, Optional
from unshackle.core.constants import AnyTrack from unshackle.core.constants import AnyTrack
from unshackle.core.titles import Episode, Movie, Song, Title_T from unshackle.core.titles import Episode, Movie, Song, Title_T
from unshackle.core.tracks import Audio, Video from unshackle.core.tracks import Audio, Video
from unshackle.core.utilities import sanitize_filename from unshackle.core.utilities import sanitize_filename, standard_resolution
STANDARD_HEIGHTS = (4320, 2160, 1440, 1080, 720, 576, 480, 360)
MANIFEST_ORDER = (Video.Descriptor.DASH, Video.Descriptor.HLS, Video.Descriptor.ISM, Video.Descriptor.URL) MANIFEST_ORDER = (Video.Descriptor.DASH, Video.Descriptor.HLS, Video.Descriptor.ISM, Video.Descriptor.URL)
MAX_SCOPE_PARTS = 3 MAX_SCOPE_PARTS = 3
MAX_TITLE_LENGTH = 100 MAX_TITLE_LENGTH = 100
@@ -107,13 +106,8 @@ def scope_part(titles: Sequence[Title_T], seasons: Mapping[int, Collection[int]]
def resolution(track: Video) -> Optional[int]: def resolution(track: Video) -> Optional[int]:
"""The resolution a release name uses: a letterboxed 1920x800 is 1080p, from its width.""" """The resolution a release name uses, the same as the {resolution} template variable."""
if not track.height: return standard_resolution(track.width, track.height) if track.height else None
return None
height = track.height
if track.width and track.width > height:
height = max(height, track.width * 9 // 16)
return next((std for std in STANDARD_HEIGHTS if abs(height - std) <= 16), height)
def audio_token(track: Audio) -> str: def audio_token(track: Audio) -> str:
+4 -30
View File
@@ -14,7 +14,7 @@ from unshackle.core.constants import (
VIDEO_CODEC_MAP, VIDEO_CODEC_MAP,
) )
from unshackle.core.tracks import Tracks, Video from unshackle.core.tracks import Tracks, Video
from unshackle.core.utilities import spoken_language from unshackle.core.utilities import spoken_language, standard_resolution
def rename(name: Optional[str]) -> str: def rename(name: Optional[str]) -> str:
@@ -119,40 +119,14 @@ class Title:
context["edition"] = " ".join(first_track.edition) context["edition"] = " ".join(first_track.edition)
if primary_video_track: if primary_video_track:
width = getattr(primary_video_track, "width", primary_video_track.height) resolution = standard_resolution(primary_video_track.width, primary_video_track.height)
resolution = min(width, primary_video_track.height)
try:
dar = getattr(primary_video_track, "other_display_aspect_ratio", None) or []
if dar and dar[0]:
aspect_ratio = [int(float(plane)) for plane in str(dar[0]).split(":")]
if len(aspect_ratio) == 1:
aspect_ratio.append(1)
ratio = aspect_ratio[0] / aspect_ratio[1]
if ratio not in (16 / 9, 4 / 3, 9 / 16, 3 / 4):
if abs(width - 3840) <= 50:
width = 3840
elif abs(width - 2560) <= 50:
width = 2560
elif abs(width - 1920) <= 50 or abs(width - 1620) <= 50:
width = 1920
elif abs(width - 1280) <= 50 or abs(width - 1080) <= 50:
width = 1280
resolution = int(max(width, primary_video_track.height) * (9 / 16))
track_height = primary_video_track.height
if abs(resolution - track_height) <= 10 or track_height in (2160, 1440, 1080, 720, 480):
resolution = track_height
# garbage DAR strings or a missing height fall back to the plain min(width, height)
except (ValueError, TypeError, ZeroDivisionError):
pass
scan_suffix = "i" if str(getattr(primary_video_track, "scan_type", "")).lower() == "interlaced" else "p" scan_suffix = "i" if str(getattr(primary_video_track, "scan_type", "")).lower() == "interlaced" else "p"
context.update( context.update(
{ {
"quality": f"{resolution}{scan_suffix}", "quality": f"{resolution}{scan_suffix}" if resolution else "",
"resolution": str(resolution), "resolution": str(resolution) if resolution else "",
"video": rename(VIDEO_CODEC_MAP.get(primary_video_track.format, primary_video_track.format)), "video": rename(VIDEO_CODEC_MAP.get(primary_video_track.format, primary_video_track.format)),
} }
) )
+6 -2
View File
@@ -14,7 +14,7 @@ from rich.rule import Rule
from unshackle.core.binaries import FFMPEG, FFProbe, HDR10PlusTool from unshackle.core.binaries import FFMPEG, FFProbe, HDR10PlusTool
from unshackle.core.config import config from unshackle.core.config import config
from unshackle.core.console import console from unshackle.core.console import console
from unshackle.core.utilities import log_event from unshackle.core.utilities import log_event, standard_resolution
from unshackle.core.utils import dovi from unshackle.core.utils import dovi
from unshackle.core.utils.subprocess import run_step from unshackle.core.utils.subprocess import run_step
@@ -136,7 +136,11 @@ class Hybrid:
hdr10_track = next((v for v in videos if v.range == Video.Range.HDR10), None) hdr10_track = next((v for v in videos if v.range == Video.Range.HDR10), None)
hdr10p_track = next((v for v in videos if v.range == Video.Range.HDR10P), None) hdr10p_track = next((v for v in videos if v.range == Video.Range.HDR10P), None)
track_for_res = hdr10_track or hdr10p_track track_for_res = hdr10_track or hdr10p_track
self.resolution = f"{track_for_res.height}p" if track_for_res and track_for_res.height else "Unknown" self.resolution = (
f"{standard_resolution(track_for_res.width, track_for_res.height)}p"
if track_for_res and track_for_res.height
else "Unknown"
)
console.print(Padding(Rule(f"[rule.text]HDR10+DV Hybrid ({self.resolution})"), (1, 2))) console.print(Padding(Rule(f"[rule.text]HDR10+DV Hybrid ({self.resolution})"), (1, 2)))
+8 -3
View File
@@ -34,6 +34,7 @@ from unshackle.core.utilities import (
matching_languages, matching_languages,
original_tag, original_tag,
sanitize_filename, sanitize_filename,
standard_resolution,
valid_language, valid_language,
) )
from unshackle.core.utils.collections import as_list, flatten from unshackle.core.utils.collections import as_list, flatten
@@ -44,8 +45,12 @@ MP4BOX_OPEN_FAILED = re.compile(r"error while opening|Error opening file|Invalid
def at_resolution(video: Video, res: int) -> bool: def at_resolution(video: Video, res: int) -> bool:
"""Whether a track has this height, or this height from a 16:9 width (a letterboxed encode).""" """Whether a track has this height, this height from a 16:9 width, or this standard resolution (a padded encode)."""
return video.height == res or bool(video.width and int(video.width * 9 / 16) == res) return (
video.height == res
or bool(video.width and int(video.width * 9 / 16) == res)
or standard_resolution(video.width, video.height) == res
)
def file_track_ids(path: Path) -> dict[str, int]: def file_track_ids(path: Path) -> dict[str, int]:
@@ -573,7 +578,7 @@ class Tracks:
for group in groups.values(): for group in groups.values():
matches = [x for x in group if x.height == resolution] matches = [x for x in group if x.height == resolution]
if not matches: if not matches:
matches = [x for x in group if x.width and int(x.width * (9 / 16)) == resolution] matches = [x for x in group if at_resolution(x, resolution)]
keep.update(id(x) for x in matches[: per_resolution or None]) keep.update(id(x) for x in matches[: per_resolution or None])
self.videos = [x for x in self.videos if id(x) in keep] self.videos = [x for x in self.videos if id(x) in keep]
+23
View File
@@ -122,6 +122,29 @@ def import_module_by_path(path: Path) -> ModuleType:
return module return module
RESOLUTION_TIERS = (4320, 2160, 1440, 1080, 720, 576, 540, 480, 360, 240)
def standard_resolution(width: Optional[int], height: Optional[int]) -> Optional[int]:
"""The resolution a release name uses: the nearest standard height within 10%, from the short side or a 16:9 width.
A cropped 1920x800, a padded 1920x1088 and a 1080x1920 portrait picture are all 1080. A size with no
standard height near it keeps its own, and a picture with no size gives None.
"""
sides = [side for side in (width, height) if side]
if not sides:
return None
short, long = min(sides), max(sides)
# naming policy: a short side near 2160, 1440 or 1080 names the picture, so an ultrawide 2560x1080 is 1080
# as other media tools name it, even though a 1440 scope crop has the same size
by_height = next((tier for tier in (2160, 1440, 1080) if abs(short - tier) <= 16), None)
if by_height:
return by_height
equivalent = max(short, long * 9 / 16)
tier = min(RESOLUTION_TIERS, key=lambda t: abs(t - equivalent))
return tier if abs(tier - equivalent) <= tier / 10 else round(equivalent)
def sanitize_filename(filename: str, spacer: str = ".", unicode: Optional[bool] = None) -> str: def sanitize_filename(filename: str, spacer: str = ".", unicode: Optional[bool] = None) -> str:
""" """
Sanitise a string to be filename safe. Sanitise a string to be filename safe.