diff --git a/poetry.lock b/poetry.lock index c407037..69a2d0c 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1367,6 +1367,17 @@ files = [ {file = "trove_classifiers-2024.3.3-py3-none-any.whl", hash = "sha256:3a84096861b385ec422c79995d1f6435dde47a9b63adaa3c886e53232ba7e6e0"}, ] +[[package]] +name = "typing-extensions" +version = "4.10.0" +description = "Backported and Experimental Type Hints for Python 3.8+" +optional = false +python-versions = ">=3.8" +files = [ + {file = "typing_extensions-4.10.0-py3-none-any.whl", hash = "sha256:69b1a937c3a517342112fb4c6df7e72fc39a38e7891a5730ed4985b5214b5475"}, + {file = "typing_extensions-4.10.0.tar.gz", hash = "sha256:b0abd7c89e8fb96f98db18d86106ff1d90ab692004eb746cf6eda2682f91b3cb"}, +] + [[package]] name = "urllib3" version = "2.2.1" @@ -1495,4 +1506,4 @@ testing = ["big-O", "jaraco.functools", "jaraco.itertools", "more-itertools", "p [metadata] lock-version = "2.0" python-versions = ">=3.10,<3.13" -content-hash = "ac4449b035965e2886814c800a32aa55c80c0ffb963f2392feb582ce0c449cc5" +content-hash = "e93cb8ea36a9e5dd133467e4cfee2a4df8b45410029025c7ed22f736dc9f7ded" diff --git a/pyproject.toml b/pyproject.toml index a85b90e..b7cc54b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -15,6 +15,7 @@ keywords = ["vhs-decode", "ld-decode", "cvbs-decode", "tbc", "rf capture"] [tool.poetry.dependencies] python = ">=3.10,<3.13" pywin32 = [{ version = "^306", platform = "win32", source = "pypi" }] +typing-extensions = "^4.10.0" [tool.poetry.scripts] tbc-video-export = "tbc_video_export.__main__:main" diff --git a/src/tbc_video_export/common/enums.py b/src/tbc_video_export/common/enums.py index 3e2f709..441333f 100644 --- a/src/tbc_video_export/common/enums.py +++ b/src/tbc_video_export/common/enums.py @@ -175,12 +175,66 @@ def __str__(self) -> str: return self.name.upper() -class ProfileType(Enum): - """Profile types for FFmpeg.""" +class VideoBitDepthType(Enum): + """Video bitdepth types for profiles.""" + + BIT8 = "8bit" + BIT10 = "10bit" + BIT16 = "16bit" + + +class ProfileVideoType(Enum): + """Video types for profiles.""" + + LOSSLESS = "lossless" + + +class VideoFormatType(Enum): + """Video format types for profiles.""" + + GRAY = { + 8: "gray8", + 10: "gray16le", + 16: "gray16le", + } + YUV420 = { + 8: "yuv420p", + 10: "yuv420p10le", + 16: "yuv420p16le", + } + YUV422 = { + 8: "yuv422p", + 10: "yuv422p10le", + 16: "yuv422p16le", + } + YUV444 = { + 8: "yuv444p", + 10: "yuv444p10le", + 16: "yuv444p16le", + } - DEFAULT = TBCType.CHROMA | TBCType.COMBINED - LUMA = TBCType.LUMA + @classmethod + def get_new_format(cls, current_format: str, new_bitdepth: int) -> str | None: + """Return new format from current format and new bitdepth.""" + return next( + ( + member.value.get(new_bitdepth) + for member in cls + for _, v in member.value.items() + if current_format == v + ), + None, + ) - def __str__(self) -> str: - """Return formatted enum name as string.""" - return self.name.upper() + @classmethod + def get_bitdepth(cls, current_format: str) -> int | None: + """Return bitdepth of current format.""" + return next( + ( + k + for member in cls + for k, v in member.value.items() + if current_format == v + ), + None, + ) diff --git a/src/tbc_video_export/common/exceptions.py b/src/tbc_video_export/common/exceptions.py index eb1e8fc..758dd6b 100644 --- a/src/tbc_video_export/common/exceptions.py +++ b/src/tbc_video_export/common/exceptions.py @@ -88,7 +88,7 @@ class InvalidProfileError(Exception): def __init__(self, message: str, config_path: Path | None = None) -> None: super().__init__(message) - self.config_path = config_path + self.config_path = config_path if config_path is not None else "[internal]" class InvalidFilterProfileError(Exception): diff --git a/src/tbc_video_export/common/file_helper.py b/src/tbc_video_export/common/file_helper.py index 530f9d9..923149e 100644 --- a/src/tbc_video_export/common/file_helper.py +++ b/src/tbc_video_export/common/file_helper.py @@ -9,6 +9,7 @@ from tbc_video_export.common.enums import FlagHelper, ProcessName, TBCType from tbc_video_export.common.tbc_json_helper import TBCJsonHelper from tbc_video_export.common.utils import files +from tbc_video_export.config.config import GetProfileFilter if TYPE_CHECKING: from tbc_video_export.config.config import Config @@ -25,8 +26,8 @@ def __init__(self, opts: Opts, config: Config) -> None: self._opts = opts self._config = config - self._profile = self._config.get_profile(self._opts.profile) - self._profile_luma = self._config.get_profile(self._opts.profile_luma) + self._profile = self._config.get_profile(GetProfileFilter(self._opts.profile)) + self._video_subtype = self._opts.video_profile # initially set both input and output files to the input file # file without file extension @@ -154,7 +155,7 @@ def output_video_file_luma(self) -> Path: This is used when two-step is enabled when merging. """ return self.get_output_file_from_ext( - f"{consts.TWO_STEP_OUT_FILE_LUMA_SUFFIX}.{self._profile_luma.video_profile.container}" + f"{consts.TWO_STEP_OUT_FILE_LUMA_SUFFIX}.{self.output_container}" ) def get_log_file(self, process_name: ProcessName, tbc_type: TBCType): diff --git a/src/tbc_video_export/config/config.py b/src/tbc_video_export/config/config.py index 6bcc3b8..c0f05d5 100644 --- a/src/tbc_video_export/config/config.py +++ b/src/tbc_video_export/config/config.py @@ -3,12 +3,13 @@ import json import logging from contextlib import suppress +from dataclasses import dataclass from functools import cached_property from pathlib import Path from typing import TYPE_CHECKING from tbc_video_export.common import consts, exceptions -from tbc_video_export.common.utils import FlatList, files +from tbc_video_export.common.utils import files from tbc_video_export.config.default import DEFAULT_CONFIG from tbc_video_export.config.profile import ( Profile, @@ -18,8 +19,8 @@ ) if TYPE_CHECKING: - from tbc_video_export.common.enums import ProfileType - from tbc_video_export.config.json import JsonConfig, JsonProfile + from tbc_video_export.common.enums import ProfileVideoType, VideoSystem + from tbc_video_export.config.json import JsonConfig class Config: @@ -32,7 +33,7 @@ class Config: def __init__(self) -> None: self._data: JsonConfig - self._additional_filters: list[ProfileFilter] = [] + self._additional_filters: list[str] = [] with suppress(FileNotFoundError, PermissionError, json.JSONDecodeError): # attempt to load the file if it exists @@ -45,12 +46,16 @@ def __init__(self) -> None: if not getattr(self, "_data", False): self._data = DEFAULT_CONFIG - self._profiles: list[Profile] = self._generate_profiles() + self.profiles: list[Profile] = [] - @property - def profiles(self) -> list[Profile]: - """Return list of available profiles.""" - return self._profiles + try: + for json_profile in self._data["profiles"]: + for profile in self._generate_profile(json_profile["name"]): + self.profiles.append(profile) + except KeyError as e: + raise exceptions.InvalidProfileError( + "Configuration file missing required fields.", self.get_config_file() + ) from e @cached_property def video_profiles(self) -> list[ProfileVideo]: @@ -83,57 +88,84 @@ def filter_profiles(self) -> list[ProfileFilter]: ) from e @property - def additional_filters(self) -> list[ProfileFilter]: + def additional_filters(self) -> list[str]: """Return list of additional filter profiles.""" return self._additional_filters - def get_profile(self, name: str) -> Profile: - """Return a profile from a name.""" - return next(p for p in self.profiles if p.name == name) + def add_additional_filter(self, filter_name: str) -> None: + """Append a filter to use.""" + self._additional_filters.append(filter_name) + + def get_profile(self, profile_filter: GetProfileFilter) -> Profile: + """Return a profile from a filter.""" + try: + profile = next( + (profile for profile in self.profiles if profile_filter.match(profile)), + None, + ) + + if profile is None: + raise exceptions.InvalidProfileError( + f"Could not find profile {profile_filter.name}." + ) + + return profile + except KeyError as e: + raise exceptions.InvalidProfileError( + "Could not load profiles.", self.get_config_file() + ) from e + except exceptions.InvalidProfileError as e: + raise exceptions.InvalidProfileError(str(e), self.get_config_file()) from e - def get_profile_names(self, profile_type: ProfileType) -> list[str]: - """Return a list of profile names for a given profile type.""" - return [p.name for p in self._get_profiles_from_type(profile_type)] + def get_profile_names(self) -> list[str]: + """Return a list of unique profile names for a given profile type.""" + return list(dict.fromkeys(profile.name for profile in self.profiles)) - def get_default_profile(self, profile_type: ProfileType) -> Profile: - """Return the first default profile for a given profile type.""" - profiles = self._get_profiles_from_type(profile_type) + def get_default_profile(self) -> Profile: + """Return the first default profile.""" + profile = next( + (profile for profile in self.profiles if profile.is_default), None + ) - if profile := next((p for p in profiles if p.is_default), False): - profile = profiles[0] - else: + if profile is None: raise exceptions.InvalidProfileError( "Unable to find default profile.", self.get_config_file() ) - return self.get_profile(profile.name) + return profile - def add_additional_filter_profile(self, profile: ProfileFilter) -> None: - """Add an additional filter to be used when encoding.""" - self._additional_filters.append(profile) + def get_video_profiles_for_profile(self, profile_name: str) -> list[ProfileVideo]: + """Return list of video profiles for a given profile.""" + video_profiles: list[ProfileVideo] = [] - # regenerate profiles on filter add - self._profiles = self._generate_profiles() + for profile in self.profiles: + if profile.name == profile_name: + video_profiles.append(profile.video_profile) - @staticmethod - def get_subprofile_descriptions(profile: Profile, show_format: bool) -> str: - """Return a comma separated list of sub profile descriptions for a profile.""" - # whether to append the profile video format to the profile name - sub_profiles = FlatList( - f"{profile.video_profile.description} {profile.video_format}" - if show_format - else profile.video_profile.description - ) + return video_profiles - if profile.audio_profile is not None: - sub_profiles.append(profile.audio_profile.description) + def get_audio_profile_names(self) -> list[str]: + """Return all audio profile names..""" + return [audio_profile.name for audio_profile in self.audio_profiles] + + def get_filter_profile(self, filter_name: str) -> ProfileFilter: + """Return filter profile from filter name.""" + filter_profile = next( + ( + filter_profile + for filter_profile in self.filter_profiles + if filter_profile.name == filter_name + ), + None, + ) - if profile.filter_profiles is not None: - sub_profiles.append( - profile.description for profile in profile.filter_profiles + if filter_profile is None: + raise exceptions.InvalidProfileError( + f"Unable to find filter profile {filter_name}.", + self.get_config_file(), ) - return ", ".join(sub_profiles.data) + return filter_profile @staticmethod def dump_default_config(file_name: Path) -> None: @@ -144,8 +176,6 @@ def dump_default_config(file_name: Path) -> None: f"Unable to create {file_name}, already exists" ) - # remove - with Path.open(file_name, "w", encoding="utf-8") as file: json.dump(DEFAULT_CONFIG, file, ensure_ascii=False, indent=4) except PermissionError as e: @@ -175,112 +205,137 @@ def get_config_file() -> Path | None: return None - def _generate_profiles(self) -> list[Profile]: - try: - return [ - Profile( - p, - self._get_video_profile(p["name"]), - self._get_audio_profile(p["name"]), - self._get_filter_profiles(p["name"]) + self._additional_filters, - ) - for p in self._data["profiles"] - ] - except KeyError as e: - raise exceptions.InvalidProfileError( - "Could not load profiles.", self.get_config_file() - ) from e - except exceptions.InvalidProfileError as e: - raise exceptions.InvalidProfileError(str(e), self.get_config_file()) from e + def get_profile_filters(self, profile: Profile) -> tuple[list[str], list[str]]: + """Adds profile filters to list opts.""" + video_filters: list[str] = [] + other_filters: list[str] = [] + + filter_profiles = profile.filter_profiles - def _get_profiles_from_type(self, profile_type: ProfileType) -> list[Profile]: - """Return a list of profiles for a given type.""" - return [p for p in self.profiles if p.profile_type is profile_type] + # populate filters + for vf in (profile.video_filter for profile in filter_profiles): + if vf is not None: + video_filters.append(vf) - def _get_raw_profile_data(self, profile_name: str) -> JsonProfile: + for of in (profile.other_filter for profile in filter_profiles): + if of is not None: + other_filters.append(of) + + # add additional video profile filters + for name in profile.video_profile.filter_profiles_additions: + self._add_filter(name, video_filters, other_filters) + + # add additional opt filters + for name in self._additional_filters: + self._add_filter(name, video_filters, other_filters) + + return video_filters, other_filters + + def _add_filter( + self, filter_name: str, video_filters: list[str], other_filters: list[str] + ) -> None: + """Add ProfileFilter to lists from name.""" + filter_profile = self.get_filter_profile(filter_name) + + if (vf := filter_profile.video_filter) is not None: + video_filters.append(vf) + + if (of := filter_profile.other_filter) is not None: + other_filters.append(of) + + def _generate_profile(self, profile_name: str) -> list[Profile]: try: - return next( + profile_data = next( profile for profile in self._data["profiles"] if profile["name"] == profile_name ) - except KeyError as e: - raise exceptions.InvalidProfileError( - "Could not load profiles.", self.get_config_file() - ) from e - - def _get_video_profile(self, profile_name: str) -> ProfileVideo: - """Return a video profile for a given profile name.""" - profile_data = self._get_raw_profile_data(profile_name) - video_profile_name = profile_data["video_profile"] - # return first video profile matching name - if ( - video_profile := next( + # get video profile(s) for profile + if isinstance(profile_data["video_profile"], list): + video_profiles = [ + ProfileVideo(json_profile) + for json_profile in self._data["video_profiles"] + for profile_name in profile_data["video_profile"] + if json_profile["name"] == profile_name + ] + else: + video_profiles = [ + next( + ProfileVideo(json_profile) + for json_profile in self._data["video_profiles"] + if json_profile["name"] == profile_name + ) + ] + + # get audio profile for profile + audio_profile = next( ( - profile - for profile in self.video_profiles - if profile.name.lower() == video_profile_name.lower() + ProfileAudio(json_profile) + for json_profile in self._data["audio_profiles"] + if json_profile["name"] == profile_data["audio_profile"] ), None, ) - ) is None: - raise exceptions.InvalidProfileError( - f"Unable to find video profile {video_profile_name} " - f"for profile {profile_name}.", - self.get_config_file(), - ) - return video_profile + # get filter profile(s) for profile + filter_profiles = [ + ProfileFilter(json_profile) + for json_profile in self._data["filter_profiles"] + if "filter_profiles" in profile_data + for profile_name in profile_data["filter_profiles"] + if json_profile["name"] == profile_name + ] + + profiles: list[Profile] = [] - def _get_audio_profile(self, profile_name: str) -> ProfileAudio | None: - """Return a audio profile for a given profile name.""" - profile_data = self._get_raw_profile_data(profile_name) + for video_profile in video_profiles: + profile = Profile( + profile_data, video_profile, audio_profile, filter_profiles + ) - # no profile given - if (audio_profile_name := profile_data["audio_profile"]) is None: - return None + # set profile overrides + if (override := video_profile.filter_profiles_override) is not None: + profile.filter_profiles = [ + ProfileFilter(json_profile) + for json_profile in self._data["filter_profiles"] + for filter_name in override + if json_profile["name"] == filter_name + ] - # return first audio profile matching name - if ( - audio_profile := next( - ( - profile - for profile in self.audio_profiles - if profile.name.lower() == audio_profile_name.lower() - ), - None, - ) - ) is None: + profiles.append(profile) + + return profiles + except KeyError as e: raise exceptions.InvalidProfileError( - f"Unable to find audio profile {audio_profile_name} " - f"for profile {profile_name}.", - self.get_config_file(), - ) + "Unable to generate profiles.", self.get_config_file() + ) from e - return audio_profile - def _get_filter_profiles(self, profile_name: str) -> list[ProfileFilter]: - """Return all filter profiles for a given profile name.""" - profile_data = self._get_raw_profile_data(profile_name) - filter_names = profile_data["filter_profiles"] +@dataclass +class GetProfileFilter: + """Container class for get profile filter params.""" - # no filter profiles for profile - if filter_names is None: - return [] + name: str + video_type: ProfileVideoType | None = None + video_system: VideoSystem | None = None - filter_profiles = [ - profile - for name in filter_names - for profile in self.filter_profiles - if profile.name.lower() == name.lower() - ] + def match(self, profile: Profile) -> bool: + """Returns true if profile matches filter.""" + video_profile = profile.video_profile - # ensure we found all the profiles - if len(filter_profiles) < len(filter_names): - raise exceptions.InvalidProfileError( - f"Unable to find filter profile(s) for profile {profile_name}.", - self.get_config_file(), - ) + if profile.name != self.name: + return False + + if ( + self.video_type is not None + and video_profile.profile_type is not self.video_type + ): + return False + + if ( + self.video_system is not None and video_profile.video_system is not None + ) and video_profile.video_system is not self.video_system: + return False - return filter_profiles + return True diff --git a/src/tbc_video_export/config/default.py b/src/tbc_video_export/config/default.py index fa31a57..fab8e99 100644 --- a/src/tbc_video_export/config/default.py +++ b/src/tbc_video_export/config/default.py @@ -8,234 +8,71 @@ DEFAULT_CONFIG: JsonConfig = { "profiles": [ { - "name": "ffv1_10bit", - "type": None, - "default": True, - "include_vbi": None, - "video_profile": "ffv1", - "video_format": "yuv422p10le", - "audio_profile": "flac", - "filter_profiles": [], - }, - { - "name": "ffv1_10bit_pcm", - "type": None, - "default": None, - "include_vbi": None, - "video_profile": "ffv1", - "video_format": "yuv422p10le", - "audio_profile": "pcm_24", - "filter_profiles": [], - }, - { - "name": "ffv1_8bit", - "type": None, - "default": None, - "include_vbi": None, - "video_profile": "ffv1", - "video_format": "yuv422p", - "audio_profile": "flac", - "filter_profiles": [], - }, - { - "name": "ffv1_8bit_pcm", - "type": None, - "default": None, - "include_vbi": None, - "video_profile": "ffv1", - "video_format": "yuv422p", - "audio_profile": "pcm_24", - "filter_profiles": [], - }, - { - "name": "ffv1_444", - "type": None, - "default": None, - "include_vbi": None, - "video_profile": "ffv1", - "video_format": "yuv444p", - "audio_profile": "flac", - "filter_profiles": [], - }, - { - "name": "ffv1_444_pcm", - "type": None, - "default": None, - "include_vbi": None, - "video_profile": "ffv1", - "video_format": "yuv444p", - "audio_profile": "pcm_24", - "filter_profiles": [], - }, - { - "name": "ffv1_luma", - "type": "luma", + "name": "ffv1", "default": True, - "include_vbi": None, - "video_profile": "ffv1", - "video_format": "y8", - "audio_profile": "flac", - "filter_profiles": [], - }, - { - "name": "ffv1_luma_pcm", - "type": "luma", - "default": None, - "include_vbi": None, "video_profile": "ffv1", - "video_format": "y8", "audio_profile": "pcm_24", "filter_profiles": [], }, - { - "name": "ffv1_luma_no_audio", - "type": "luma", - "default": None, - "include_vbi": None, - "video_profile": "ffv1", - "video_format": "y8", - "audio_profile": None, - "filter_profiles": [], - }, { "name": "prores_hq", - "type": None, - "default": None, - "include_vbi": None, "video_profile": "prores_422_hq", - "video_format": "yuv422p10le", "audio_profile": "pcm_24", "filter_profiles": [], }, { "name": "prores_4444xq", - "type": None, - "default": None, - "include_vbi": None, "video_profile": "prores_4444_xq", - "video_format": "yuv444p10le", "audio_profile": "pcm_24", "filter_profiles": [], }, { "name": "v210", - "type": None, - "default": None, - "include_vbi": None, "video_profile": "v210", - "video_format": "yuv422p10le", "audio_profile": "pcm_24", "filter_profiles": [], }, { "name": "v410", - "type": None, - "default": None, - "include_vbi": None, "video_profile": "v410", - "video_format": "yuv422p10le", "audio_profile": "pcm_24", "filter_profiles": [], }, { - "name": "d10_pal", - "type": None, - "default": None, - "include_vbi": True, - "video_profile": "d10_mpeg2_pal", - "video_format": "yuv422p", + "name": "d10", + "video_profile": [ + "d10_mpeg2_pal", + "d10_mpeg2_ntsc", + ], "audio_profile": "pcm_24", - "filter_profiles": ["resize_pal_d10"], - }, - { - "name": "x264_web", - "type": None, - "default": None, - "include_vbi": None, - "video_profile": "x264_web", - "video_format": "yuv420p", - "audio_profile": "aac_320", - "filter_profiles": ["bwdif", "colorlevels32"], - }, - { - "name": "x264_lossless", - "type": None, - "default": None, - "include_vbi": None, - "video_profile": "x264_lossless", - "video_format": "yuv422p10le", - "audio_profile": "flac", "filter_profiles": [], }, { - "name": "x264_lossless_8bit", - "type": None, - "default": None, - "include_vbi": None, - "video_profile": "x264_lossless", - "video_format": "yuv422p", - "audio_profile": "flac", - "filter_profiles": [], - }, - { - "name": "x265_web", - "type": None, - "default": None, - "include_vbi": None, - "video_profile": "x265_web", - "video_format": "yuv420p", - "audio_profile": "aac_320", + "name": "x264", + "video_profile": [ + "x264_web", + "x264_lossless", + ], + "audio_profile": "aac", "filter_profiles": ["bwdif", "colorlevels32"], }, { - "name": "x265_lossless", - "type": None, - "default": None, - "include_vbi": None, - "video_profile": "x265_lossless", - "video_format": "yuv422p10le", - "audio_profile": "flac", - "filter_profiles": [], - }, - { - "name": "x265_lossless_8bit", - "type": None, - "default": None, - "include_vbi": None, - "video_profile": "x265_lossless", - "video_format": "yuv422p", - "audio_profile": "flac", - "filter_profiles": [], - }, - { - "name": "av1_web", - "type": None, - "default": None, - "include_vbi": None, - "video_profile": "av1_web", - "video_format": "yuv420p", - "audio_profile": "aac_320", + "name": "x265", + "video_profile": [ + "x265_web", + "x265_lossless", + ], + "audio_profile": "aac", "filter_profiles": ["bwdif", "colorlevels32"], }, { - "name": "av1_lossless", - "type": None, - "default": None, - "include_vbi": None, - "video_profile": "av1_lossless", - "video_format": "yuv422p10le", - "audio_profile": "flac", - "filter_profiles": [], - }, - { - "name": "av1_lossless_8bit", - "type": None, - "default": None, - "include_vbi": None, - "video_profile": "av1_lossless", - "video_format": "yuv422p", - "audio_profile": "flac", - "filter_profiles": [], + "name": "av1", + "video_profile": [ + "av1_web", + "av1_lossless", + ], + "audio_profile": "aac", + "filter_profiles": ["bwdif", "colorlevels32"], }, ], "video_profiles": [ @@ -243,8 +80,8 @@ "name": "ffv1", "description": "FFV1", "codec": "ffv1", + "video_format": "yuv422p10le", "container": "mkv", - "output_format": None, "opts": [ "-coder", 1, @@ -259,16 +96,17 @@ "-g", 1, ], + "type": "lossless", }, { "name": "prores_422_hq", "description": "ProRes 422 HQ", "codec": "prores", + "video_format": "yuv422p10le", "container": "mov", - "output_format": None, "opts": [ "-profile:v", - 3, + "hq", "-vendor", "apl0", "-bits_per_mb", @@ -283,11 +121,11 @@ "name": "prores_4444_xq", "description": "ProRes 4444 XQ", "codec": "prores", + "video_format": "yuv444p10le", "container": "mov", - "output_format": None, "opts": [ "-profile:v", - 5, + "xq", "-vendor", "apl0", "-bits_per_mb", @@ -302,22 +140,23 @@ "name": "v210", "description": "V210", "codec": "v210", + "video_format": "yuv422p10le", "container": "mov", - "output_format": None, - "opts": None, + "type": "lossless", }, { "name": "v410", "description": "V410", "codec": "v410", + "video_format": "yuv422p10le", "container": "mov", - "output_format": None, - "opts": None, + "type": "lossless", }, { "name": "d10_mpeg2_pal", "description": "D10 (Sony IMX/XDCAM)", "codec": "mpeg2video", + "video_format": "yuv422p", "container": "mxf", "output_format": "mxf_d10", "opts": [ @@ -348,11 +187,16 @@ "-rc_init_occupancy", "2000000", ], + "video_system": "pal", + "filter_profiles_additions": [ + "resize_pal_d10", + ], }, { "name": "d10_mpeg2_ntsc", "description": "D10 (Sony IMX/XDCAM) *BROKEN*", "codec": "mpeg2video", + "video_format": "yuv422p", "container": "mxf", "output_format": "mxf_d10", "opts": [ @@ -383,12 +227,16 @@ "-rc_init_occupancy", "1668328", ], + "video_system": "ntsc", + "filter_profiles_additions": [ + "resize_ntsc_d10", + ], }, { "name": "x264_web", - "description": "x264 (Web)", + "description": "H.264 AVC - Web", "codec": "libx264", - "output_format": None, + "video_format": "yuv420p", "container": "mov", "opts": [ "-crf", @@ -407,17 +255,24 @@ }, { "name": "x264_lossless", - "description": "x264 (Lossless)", + "description": "H.264 AVC - Lossless", "codec": "libx264", - "output_format": None, + "video_format": "yuv420p", "container": "mov", - "opts": ["-qp", 0, "-preset", "veryslow"], + "opts": [ + "-qp", + 0, + "-preset", + "veryslow", + ], + "filter_profiles_override": [], + "type": "lossless", }, { "name": "x265_web", - "description": "x265 (Web)", + "description": "H.265 HEVC - Web", "codec": "libx265", - "output_format": None, + "video_format": "yuv420p", "container": "mov", "opts": [ "-crf", @@ -436,9 +291,9 @@ }, { "name": "x265_lossless", - "description": "x265 (Lossless)", + "description": "H.265 HEVC - Lossless", "codec": "libx265", - "output_format": None, + "video_format": "yuv420p", "container": "mov", "opts": [ "-preset", @@ -448,12 +303,14 @@ "-x265-params", "lossless=1", ], + "filter_profiles_override": [], + "type": "lossless", }, { "name": "av1_web", - "description": "AV1 (Web)", + "description": "AV1 - Web", "codec": "libaom-av1", - "output_format": None, + "video_format": "yuv420p", "container": "mp4", "opts": [ "-crf", @@ -472,9 +329,9 @@ }, { "name": "av1_lossless", - "description": "AV1 (Lossless)", + "description": "AV1 - Lossless", "codec": "libaom-av1", - "output_format": None, + "video_format": "yuv420p", "container": "mkv", "opts": [ "-crf", @@ -492,6 +349,8 @@ "-aom-params", "lossless=1", ], + "filter_profiles_override": [], + "type": "lossless", }, ], "audio_profiles": [ @@ -501,9 +360,18 @@ "codec": "flac", "opts": ["-compression_level", 12], }, - {"name": "pcm_24", "description": "PCM", "codec": "pcm_s24le", "opts": None}, { - "name": "aac_320", + "name": "pcm_16", + "description": "PCM 16-bits", + "codec": "pcm_s16le", + }, + { + "name": "pcm_24", + "description": "PCM 24-bits", + "codec": "pcm_s24le", + }, + { + "name": "aac", "description": "AAC", "codec": "aac", "opts": ["-ar", 48000, "-b:a", "320K"], @@ -514,49 +382,41 @@ "name": "bwdif", "description": "BWDIF", "video_filter": "bwdif", - "other_filter": None, }, { "name": "colorlevels32", "description": "Colorlevels (32)", "video_filter": "colorlevels=rimin=32/255:gimin=32/255:bimin=32/255", - "other_filter": None, }, { "name": "map_l_to_lr", "description": "Map L to L+R", - "video_filter": None, "other_filter": "[2:a]pan=stereo|FL=FL|FR=FL", }, { "name": "map_r_to_lr", "description": "Map R to L+R", - "video_filter": None, "other_filter": "[2:a]pan=stereo|FR=FR|FL=FR", }, { "name": "resize_pal_standard", "description": "Resize to PAL standard SD resoluton (720x576)", "video_filter": "scale=720x576:flags=lanczos,setdar=4/3", - "other_filter": None, }, { "name": "resize_ntsc_standard", "description": "Resize to NTSC standard SD resoluton (720x480)", "video_filter": "scale=720x480:flags=lanczos,setdar=4/3", - "other_filter": None, }, { "name": "resize_pal_d10", "description": "Resize and pad to D10 (Sony IMX/XDCAM) resoluton (720x608)", # noqa: E501 "video_filter": "scale=720x608:flags=lanczos", - "other_filter": None, }, { "name": "resize_ntsc_d10", "description": "Resize and pad to D10 (Sony IMX/XDCAM) resoluton (720x512)", # noqa: E501 "video_filter": "scale=720x512:flags=lanczos", - "other_filter": None, }, ], } diff --git a/src/tbc_video_export/config/json.py b/src/tbc_video_export/config/json.py index 79a0fb4..3de8cab 100644 --- a/src/tbc_video_export/config/json.py +++ b/src/tbc_video_export/config/json.py @@ -1,6 +1,9 @@ from __future__ import annotations -from typing import TypedDict +from typing import TYPE_CHECKING, TypedDict + +if TYPE_CHECKING: + from typing_extensions import NotRequired class JsonConfig(TypedDict): @@ -16,13 +19,11 @@ class JsonProfile(TypedDict): """Raw mapping of profile from JSON.""" name: str - type: str | None - default: bool | None - include_vbi: bool | None - video_profile: str - video_format: str - audio_profile: str | None - filter_profiles: list[str] | None + default: NotRequired[bool] + include_vbi: NotRequired[bool] + video_profile: str | list[str] + audio_profile: NotRequired[str] + filter_profiles: NotRequired[list[str]] class JsonSubProfile(TypedDict): @@ -32,24 +33,36 @@ class JsonSubProfile(TypedDict): description: str +class JsonSupportedFormats(TypedDict): + """Raw mapping of supported formats from JSON.""" + + bitdepth: int + format: str + + class JsonSubProfileVideo(JsonSubProfile): """Raw mapping of video subprofile from JSON.""" container: str - output_format: str | None + output_format: NotRequired[str] codec: str - opts: list[str | int] | None + opts: NotRequired[list[str | int]] + video_system: NotRequired[str] + video_format: str + filter_profiles_additions: NotRequired[list[str]] + filter_profiles_override: NotRequired[list[str]] + type: NotRequired[str] # noqa: A003 class JsonSubProfileAudio(JsonSubProfile): """Raw mapping of audio subprofile from JSON.""" codec: str - opts: list[str | int] | None + opts: NotRequired[list[str | int]] class JsonSubProfileFilter(JsonSubProfile): """Raw mapping of filter subprofile from JSON.""" - video_filter: str | None - other_filter: str | None + video_filter: NotRequired[str] + other_filter: NotRequired[str] diff --git a/src/tbc_video_export/config/profile.py b/src/tbc_video_export/config/profile.py index 8116a97..877a0cf 100644 --- a/src/tbc_video_export/config/profile.py +++ b/src/tbc_video_export/config/profile.py @@ -3,8 +3,8 @@ from typing import TYPE_CHECKING from tbc_video_export.common import exceptions -from tbc_video_export.common.enums import ProfileType -from tbc_video_export.common.utils import FlatList +from tbc_video_export.common.enums import ProfileVideoType, VideoSystem +from tbc_video_export.common.utils import FlatList, ansi if TYPE_CHECKING: from tbc_video_export.config.json import ( @@ -24,57 +24,50 @@ def __init__( profile: JsonProfile, video_profile: ProfileVideo, audio_profile: ProfileAudio | None, - filter_profiles: list[ProfileFilter] | None, + filter_profiles: list[ProfileFilter], ) -> None: self._profile = profile self.video_profile = video_profile self.audio_profile = audio_profile - self.filter_profiles = filter_profiles - - if not all( - key in self._profile for key in ("name", "video_profile", "video_format") - ): - raise exceptions.InvalidProfileError( - "Profile requires at least a name, video_profile and video_format" - ) + self._filter_profiles = filter_profiles @property def name(self) -> str: """Return profile name.""" return self._profile["name"] - @property - def profile_type(self) -> ProfileType: - """Returns profile type.""" - return ( - ProfileType[self._profile["type"].upper()] - if "type" in self._profile and self._profile["type"] is not None - else ProfileType.DEFAULT - ) - @property def include_vbi(self) -> bool: """Returns True if the profile contains include_vbi as True.""" - return ( - self._profile["include_vbi"] - if "include_vbi" in self._profile - and self._profile["include_vbi"] is not None - else False - ) + return self._profile["include_vbi"] if "include_vbi" in self._profile else False @property def is_default(self) -> bool: """Returns True if the profile is flagged as default.""" - return ( - self._profile["default"] - if "default" in self._profile and self._profile["default"] is not None - else False - ) + return self._profile["default"] if "default" in self._profile else False @property - def video_format(self) -> str: - """Return profile name.""" - return self._profile["video_format"] + def filter_profiles(self) -> list[ProfileFilter]: + """Return filter profiles.""" + return self._filter_profiles + + @filter_profiles.setter + def filter_profiles(self, filter_profiles: list[ProfileFilter]) -> None: + """Set filter profiles.""" + self._filter_profiles = filter_profiles + + def __str__(self) -> str: # noqa: D105 + data = f"--{self.name} {'(default)' if self.is_default else ''}\n" + + data += str(self.video_profile) + + if self.audio_profile is not None: + data += str(self.audio_profile) + + if self.include_vbi: + data += f" {ansi.dim('Include VBI')}\t{self.include_vbi}\n" + + return data class SubProfile: @@ -83,15 +76,6 @@ class SubProfile: def __init__(self, profile: JsonSubProfile): self._profile = profile - # ensure required fields are set - if "name" not in self._profile: - raise exceptions.InvalidProfileError("Video profile missing name.") - - if "description" not in self._profile: - raise exceptions.InvalidProfileError( - f"Video profile {self._profile['name']} is missing description." - ) - @property def name(self) -> str: """Return profile name.""" @@ -109,12 +93,7 @@ class ProfileVideo(SubProfile): def __init__(self, profile: JsonSubProfileVideo) -> None: super().__init__(profile) self._profile = profile - - # ensure required fields are set - if not all(key in self._profile for key in ("container", "codec")): - raise exceptions.InvalidProfileError( - f"Video profile {self._profile['name']} is missing container or codec." - ) + self._video_format = self._profile["video_format"] @property def container(self) -> str: @@ -124,7 +103,9 @@ def container(self) -> str: @property def output_format(self) -> str | None: """Return the output format.""" - return self._profile["output_format"] + return ( + self._profile["output_format"] if "output_format" in self._profile else None + ) @property def codec(self) -> str: @@ -134,7 +115,104 @@ def codec(self) -> str: @property def opts(self) -> FlatList | None: """Return the video opts if they exist.""" - return FlatList(self._profile["opts"]) + return FlatList(self._profile["opts"]) if "opts" in self._profile else None + + @property + def video_format(self) -> str: + """Return the video format.""" + return self._video_format + + @video_format.setter + def video_format(self, video_format: str) -> None: + """Set video format.""" + self._video_format = video_format + + @property + def filter_profiles_additions(self) -> list[str]: + """Return the additional filters if they exists.""" + return ( + self._profile["filter_profiles_additions"] + if "filter_profiles_additions" in self._profile + else [] + ) + + @property + def filter_profiles_override(self) -> list[str] | None: + """Return the filters to override parent filters if they exists.""" + return ( + self._profile["filter_profiles_override"] + if "filter_profiles_override" in self._profile + else None + ) + + @property + def profile_type(self) -> ProfileVideoType | None: + """Return the video profile type.""" + try: + return ProfileVideoType(self._profile["type"]) + except (KeyError, ValueError): + return None + + @property + def video_system(self) -> VideoSystem | None: + """Return the video system filter.""" + if "video_system" in self._profile: + try: + return VideoSystem(self._profile["video_system"]) + except (KeyError, ValueError) as e: + raise exceptions.InvalidProfileError( + f"Video profile {self._profile['name']} contains unknown " + f"video_system." + ) from e + + return None + + def __str__(self) -> str: # noqa: D105 + data = " " + data += ( + f"--{ansi.bold(self.profile_type.value)} " + if self.profile_type is not None + else "" + ) + + if data == " ": + data += "default" + + data += "\n" + data += f" {ansi.dim('Description')}\t{self.description} [{self.name}]\n" + data += f" {ansi.dim('Video Codec')}\t{self.codec}\n" + + if self.opts is not None: + data += f" {ansi.dim('Video Opts')}\t{self.opts}\n" + + data += f" {ansi.dim('Format')}\t{self.video_format}\n" + data += f" {ansi.dim('Container')}\t{self.container}" + + if self.output_format is not None: + data += f" ({self.output_format})" + + data += "\n" + + if self.filter_profiles_additions or self.filter_profiles_override is not None: + data += f" {ansi.dim('Filters')}\n" + if self.filter_profiles_additions: + data += ( + f" {ansi.dim('Additions')}\t" + f"{', '.join(self.filter_profiles_additions)}\n" + ) + + if self.filter_profiles_override is not None: + data += ( + f" {ansi.dim('Override')}\t" + f"{', '.join(self.filter_profiles_override)}\n" + ) + + if self.video_system is not None: + data += f" {ansi.dim('System')}\t{self.video_system}\n" + + # data += "\n" + + return data class ProfileAudio(SubProfile): @@ -144,21 +222,24 @@ def __init__(self, profile: JsonSubProfileAudio) -> None: super().__init__(profile) self._profile = profile - # ensure required fields are set - if "codec" not in self._profile and self._profile["codec"]: - raise exceptions.InvalidProfileError( - f"Audio profile {self._profile['name']} is missing codec." - ) - @property def codec(self) -> str: """Return the audio codec.""" return self._profile["codec"] @property - def opts(self) -> FlatList | None: + def opts(self) -> FlatList: """Return the audio opts if they exist.""" - return FlatList(self._profile["opts"]) + return ( + FlatList(self._profile["opts"]) if "opts" in self._profile else FlatList() + ) + + def __str__(self) -> str: # noqa: D105 + data = f" {ansi.dim('Audio Codec:')}\t{self.codec}\n" + if self.opts: + data += f" {ansi.dim('Audio Opts')}\t{self.opts}\n" + + return data class ProfileFilter(SubProfile): @@ -168,15 +249,6 @@ def __init__(self, profile: JsonSubProfileFilter) -> None: super().__init__(profile) self._profile = profile - # ensure required fields are set - if not any( - key in self._profile and self._profile[key] is not None - for key in ("video_filter", "other_filter") - ): - raise exceptions.InvalidProfileError( - f"Filter profile {self._profile['name']} has no filters." - ) - @property def video_filter(self) -> str | None: """Return the video filter.""" diff --git a/src/tbc_video_export/opts/opt_actions.py b/src/tbc_video_export/opts/opt_actions.py index 5d4e7d1..f001abd 100644 --- a/src/tbc_video_export/opts/opt_actions.py +++ b/src/tbc_video_export/opts/opt_actions.py @@ -5,14 +5,19 @@ from typing import TYPE_CHECKING from tbc_video_export.common import consts -from tbc_video_export.common.enums import ProfileType +from tbc_video_export.common.enums import ( + ProfileVideoType, + VideoBitDepthType, + VideoFormatType, +) from tbc_video_export.common.utils import ansi +from tbc_video_export.config.config import GetProfileFilter if TYPE_CHECKING: from collections.abc import Sequence from typing import Any - from tbc_video_export.config import Config, SubProfile + from tbc_video_export.config import Config class ActionDumpConfig(argparse.Action): @@ -69,7 +74,8 @@ class ActionListProfiles(argparse.Action): """ def __init__(self, config: Config, nargs: int = 0, **kwargs: Any) -> None: - self._profiles = config.profiles + self._config = config + self._profile_names = config.get_profile_names() self._profiles_filters = config.filter_profiles super().__init__(nargs=nargs, **kwargs) @@ -81,86 +87,129 @@ def __call__( # noqa: D102 option_strings: str, # noqa: ARG002 **kwargs: Any, # noqa: ARG002 ) -> None: + self._print_profiles() + parser.exit() + + def _print_profiles(self) -> None: logging.getLogger("console").info(ansi.underlined("Profiles\n")) - for profile in self._profiles: - sub_profiles: list[SubProfile] = [profile.video_profile] + for profile_name in self._profile_names: + profile = self._config.get_profile(GetProfileFilter(profile_name)) + video_profiles = self._config.get_video_profiles_for_profile(profile_name) - logging.getLogger("console").info( - f"{profile.name}{' (default)' if profile.is_default else ''}" + data = ( + f"--{ansi.bold(profile.name)} " + f"{'(default)' if profile.is_default else ''}\n" ) - if profile.profile_type is not ProfileType.DEFAULT: - logging.getLogger("console").info( - f" {ansi.dim('Profile Type:')} {profile.profile_type}" - ) + for vp in video_profiles: + data += f"{vp}\n" - output_format = ( - f" ({profile.video_profile.output_format})" - if profile.video_profile.output_format is not None - else "" - ) + if profile.audio_profile is not None: + data += str(profile.audio_profile) - logging.getLogger("console").info( - f" {ansi.dim('Container:')}\t{profile.video_profile.container}" - f"{output_format}\n" - f" {ansi.dim('Video Codec:')}\t{profile.video_profile.codec} " - f"({profile.video_format})" - ) + if profile.include_vbi: + data += f" {ansi.dim('Include VBI')}\t{profile.include_vbi}\n" - if video_opts := profile.video_profile.opts: - logging.getLogger("console").info( - f" {ansi.dim('Video Opts:')}\t{video_opts}" - ) + logging.getLogger("console").info(data) - if profile.audio_profile is not None: - sub_profiles.append(profile.audio_profile) - logging.getLogger("console").info( - f" {ansi.dim('Audio Codec:')}\t{profile.audio_profile.codec}" - ) - if profile.audio_profile.opts: - logging.getLogger("console").info( - f" {ansi.dim('Audio Opts:')}\t{profile.audio_profile.opts}" - ) - if profile.include_vbi: - logging.getLogger("console").info( - f" {ansi.dim('Include VBI:')}\t{profile.include_vbi}" - ) - - if profile.filter_profiles: - for _profile in profile.filter_profiles: - sub_profiles.append(_profile) - logging.getLogger("console").info( - f" {ansi.dim('Filter:')}\t{_profile.video_filter}" - ) - - logging.getLogger("console").info( - f" {ansi.dim('Sub Profiles:')}\t" - f"{', '.join(profile.name for profile in sub_profiles)}" - ) +class ActionSetVideoBitDepthType(argparse.Action): + """Set video format type with alias opts.""" + + def __init__(self, nargs: int = 0, **kwargs: Any) -> None: + super().__init__(nargs=nargs, **kwargs) + + def __call__( # noqa: D102 + self, + parser: argparse.ArgumentParser, # noqa: ARG002 + namespace: argparse.Namespace, + values: str | Sequence[Any] | None, # noqa: ARG002 + option_strings: str, + **kwargs: Any, # noqa: ARG002 + ) -> None: + # no need to check errors here, as option_strings can only be + # VideoBitDepthType values + match VideoBitDepthType(option_strings[2:].lower()): + case VideoBitDepthType.BIT8: + namespace.video_bitdepth = 8 - logging.getLogger("console").info("") + case VideoBitDepthType.BIT10: + namespace.video_bitdepth = 10 - logging.getLogger("console").info(ansi.underlined("Filter Profiles\n")) + case VideoBitDepthType.BIT16: + namespace.video_bitdepth = 16 - for profile in self._profiles_filters: - logging.getLogger("console").info(profile.name) - logging.getLogger("console").info( - f" {ansi.dim('Description:')} {profile.description}" - ) +class ActionSetVideoFormatType(argparse.Action): + """Set video format type with alias opts.""" - profile_filter = ( - profile.video_filter - if profile.video_filter is not None - else profile.other_filter - ) + def __init__(self, nargs: int = 0, **kwargs: Any) -> None: + super().__init__(nargs=nargs, **kwargs) - logging.getLogger("console").info( - f" {ansi.dim('Filter:')} {profile_filter}" - ) + def __call__( # noqa: D102 + self, + parser: argparse.ArgumentParser, # noqa: ARG002 + namespace: argparse.Namespace, + values: str | Sequence[Any] | None, # noqa: ARG002 + option_strings: str, + **kwargs: Any, # noqa: ARG002 + ) -> None: + for format_type in VideoFormatType: + if format_type.name.lower() == option_strings[2:].lower(): + namespace.video_format = format_type - logging.getLogger("console").info("") - parser.exit() +class ActionSetProfile(argparse.Action): + """Set profile with alias opts.""" + + def __init__(self, nargs: int = 0, **kwargs: Any) -> None: + super().__init__(nargs=nargs, **kwargs) + + def __call__( # noqa: D102 + self, + parser: argparse.ArgumentParser, # noqa: ARG002 + namespace: argparse.Namespace, + values: str | Sequence[Any] | None, # noqa: ARG002 + option_strings: str, + **kwargs: Any, # noqa: ARG002 + ) -> None: + # no need to check errors here, as option_strings can only be + # valid profile names + namespace.profile = option_strings[2:].lower() + + +class ActionSetVideoType(argparse.Action): + """Set video profile type with alias opts.""" + + def __init__(self, nargs: int = 0, **kwargs: Any) -> None: + super().__init__(nargs=nargs, **kwargs) + + def __call__( # noqa: D102 + self, + parser: argparse.ArgumentParser, # noqa: ARG002 + namespace: argparse.Namespace, + values: str | Sequence[Any] | None, # noqa: ARG002 + option_strings: str, + **kwargs: Any, # noqa: ARG002 + ) -> None: + # no need to check errors here, as option_strings can only be + # ProfileVideoType values + namespace.video_profile = ProfileVideoType(option_strings[2:].lower()) + + +class ActionSetAudioOverride(argparse.Action): + """Set audio profile override type with alias opts.""" + + def __init__(self, nargs: int = 0, **kwargs: Any) -> None: + super().__init__(nargs=nargs, **kwargs) + + def __call__( # noqa: D102 + self, + parser: argparse.ArgumentParser, # noqa: ARG002 + namespace: argparse.Namespace, + values: str | Sequence[Any] | None, # noqa: ARG002 + option_strings: str, + **kwargs: Any, # noqa: ARG002 + ) -> None: + namespace.audio_profile = option_strings[2:].replace("-", "_").lower() diff --git a/src/tbc_video_export/opts/opt_types.py b/src/tbc_video_export/opts/opt_types.py index c996122..a0417d6 100644 --- a/src/tbc_video_export/opts/opt_types.py +++ b/src/tbc_video_export/opts/opt_types.py @@ -2,14 +2,17 @@ from typing import TYPE_CHECKING -from tbc_video_export.common import exceptions -from tbc_video_export.common.enums import ChromaDecoder, FieldOrder, VideoSystem +from tbc_video_export.common.enums import ( + ChromaDecoder, + FieldOrder, + ProfileVideoType, + VideoSystem, +) if TYPE_CHECKING: import argparse from tbc_video_export.config import Config - from tbc_video_export.config.profile import ProfileFilter class TypeVideoSystem: @@ -50,24 +53,22 @@ class TypeAdditionalFilter: def __init__(self, config: Config) -> None: self._config = config - def __call__(self, value: str) -> ProfileFilter: # noqa: D102 - profile = next( - ( - profile - for profile in self._config.filter_profiles - if profile.name == value - ), - None, - ) - - if profile is None: - raise exceptions.InvalidFilterProfileError( - f"Could not find filter profile '{value}'. See --list-profiles." - ) + def __call__(self, value: str) -> str: # noqa: D102 + # add to config + self._config.add_additional_filter(value) + return value + + +class TypeOverrideAudioProfile: + """Return ProfileFilter if it exists.""" + def __init__(self, config: Config) -> None: + self._config = config + + def __call__(self, value: str) -> str: # noqa: D102 # add to config - self._config.add_additional_filter_profile(profile) - return profile + self._config.add_additional_filter(value) + return value class TypeChromaDecoder: @@ -84,3 +85,19 @@ def __call__(self, value: str) -> ChromaDecoder: # noqa: D102 f"argument --chroma-decoder: invalid ChromaDecoder value: '{value}', " f"check --help for available options." ) + + +class TypeVideoProfile: + """Return ProfileVideoType value if it exists.""" + + def __init__(self, parser: argparse.ArgumentParser) -> None: + self._parser = parser + + def __call__(self, value: str) -> ProfileVideoType: # noqa: D102 + try: + return ProfileVideoType[value.upper()] + except KeyError: + self._parser.error( + f"argument --video-profile: invalid ProfileVideoType value: " + f"'{value}', check --help for available options." + ) diff --git a/src/tbc_video_export/opts/opt_validators.py b/src/tbc_video_export/opts/opt_validators.py index 1382d7f..20825d8 100644 --- a/src/tbc_video_export/opts/opt_validators.py +++ b/src/tbc_video_export/opts/opt_validators.py @@ -8,7 +8,7 @@ from typing import TYPE_CHECKING from tbc_video_export.common import exceptions -from tbc_video_export.common.enums import ProfileType, VideoSystem +from tbc_video_export.common.enums import VideoSystem from tbc_video_export.common.utils import ansi from tbc_video_export.opts.opts import AudioTrackOpt, Opts @@ -26,7 +26,8 @@ def validate_opts( _validate_line_opts(parser, opts) _validate_video_system(state, parser, opts) _validate_ansi_support(opts) - _validate_luma_only_opts(state, parser, opts) + _validate_luma_only_opts(parser, opts) + _validate_video_format(parser, opts) def _validate_line_opts(parser: argparse.ArgumentParser, opts: Opts) -> None: @@ -80,6 +81,12 @@ def _validate_video_system( ) +def _validate_video_format(parser: argparse.ArgumentParser, opts: Opts) -> None: + # require bitdepth if format set + if opts.video_format is not None and opts.video_bitdepth is None: + parser.error("setting a video format requires a bitdepth.\n") + + def _validate_ansi_support(opts: Opts) -> None: # check if ansi is supported on Windows and disable progress if not if not ansi.has_ansi_support(): @@ -99,26 +106,12 @@ def _validate_ansi_support(opts: Opts) -> None: opts.no_progress = True -def _validate_luma_only_opts( - state: ProgramState, parser: argparse.ArgumentParser, opts: Opts -) -> None: +def _validate_luma_only_opts(parser: argparse.ArgumentParser, opts: Opts) -> None: # check luma only redundant opts - if opts.luma_only or opts.luma_4fsc: - if opts.chroma_decoder is not None: - parser.error( - "arguments --chroma-decoder: not allowed with --luma-only or " - "--luma-4fsc (redundant)" - ) - - if opts.profile != state.config.get_default_profile(ProfileType.DEFAULT).name: - parser.error( - "arguments --profile: not allowed with --luma-only or " - "--luma-4fsc (redundant), try --profile-luma" - ) - - elif opts.profile_luma != state.config.get_default_profile(ProfileType.LUMA).name: + if (opts.luma_only or opts.luma_4fsc) and opts.chroma_decoder is not None: parser.error( - "arguments --profile-luma: only allowed with --luma-only or --luma-4fsc" + "arguments --chroma-decoder: not allowed with --luma-only or " + "--luma-4fsc (redundant)" ) diff --git a/src/tbc_video_export/opts/opts.py b/src/tbc_video_export/opts/opts.py index d8e6f0c..e011703 100644 --- a/src/tbc_video_export/opts/opts.py +++ b/src/tbc_video_export/opts/opts.py @@ -7,7 +7,13 @@ if TYPE_CHECKING: from pathlib import Path - from tbc_video_export.common.enums import ChromaDecoder, FieldOrder, VideoSystem + from tbc_video_export.common.enums import ( + ChromaDecoder, + FieldOrder, + ProfileVideoType, + VideoFormatType, + VideoSystem, + ) class Opts(argparse.Namespace): @@ -85,12 +91,6 @@ class Opts(argparse.Namespace): export_metadata_keep_going: bool # ffmpeg - profile: str - profile_luma: str - profile_container: str | None - profile_additional_filters: list[str] - append_video_filter: str | None - append_other_filter: str | None audio_track: list[AudioTrackOpt] metadata: list[list[str]] metadata_file: list[Path] @@ -100,6 +100,21 @@ class Opts(argparse.Namespace): thread_queue_size: int checksum: bool + # profile + profile: str + profile_luma: str + + # profile overrides + profile_container: str | None + profile_additional_filters: list[str] + append_video_filter: str | None + append_other_filter: str | None + + video_profile: ProfileVideoType | None + video_format: VideoFormatType | None + video_bitdepth: int | None + audio_profile: str | None + def convert_opt( self, program_opt_name: str, target_opt_name: str ) -> str | tuple[str, str] | None: diff --git a/src/tbc_video_export/opts/opts_ffmpeg.py b/src/tbc_video_export/opts/opts_ffmpeg.py index 3e6eae9..ec2504c 100644 --- a/src/tbc_video_export/opts/opts_ffmpeg.py +++ b/src/tbc_video_export/opts/opts_ffmpeg.py @@ -2,108 +2,18 @@ from typing import TYPE_CHECKING -from tbc_video_export.common import consts -from tbc_video_export.common.enums import FieldOrder, ProfileType -from tbc_video_export.opts import opt_actions, opt_types, opt_validators +from tbc_video_export.common.enums import FieldOrder +from tbc_video_export.opts import opt_types, opt_validators if TYPE_CHECKING: import argparse - from tbc_video_export.config import Config - -def add_ffmpeg_opts(config: Config, parent: argparse.ArgumentParser) -> None: +def add_ffmpeg_opts(parent: argparse.ArgumentParser) -> None: """Add FFmpeg opts to the parent arg parser.""" - # chroma/combined profiles - profile_names = config.get_profile_names(ProfileType.DEFAULT) - profile_default = config.get_default_profile(ProfileType.DEFAULT).name - # ffmpeg arguments ffmpeg_opts = parent.add_argument_group("ffmpeg") - ffmpeg_opts.add_argument( - "--profile", - type=str, - choices=profile_names, - default=profile_default, - metavar="profile_name", - help="Specify an FFmpeg profile to use. " - f"(default: {profile_default})\n" - "See --list-profiles to see the available profiles." - "\n\n", - ) - - # luma profiles - luma_profile_names = config.get_profile_names(ProfileType.LUMA) - luma_profile_default = config.get_default_profile(ProfileType.LUMA).name - - ffmpeg_opts.add_argument( - "--profile-luma", - type=str, - choices=luma_profile_names, - default=luma_profile_default, - metavar="profile_name", - help="Specify an FFmpeg profile to use for Luma. " - f"(default: {luma_profile_default})\n" - "See --list-profiles to see the available profiles." - "\n\n", - ) - - ffmpeg_opts.add_argument( - "--profile-container", - type=str, - metavar="profile_container", - help="Override an FFmpeg profile to use a specific container. Compatibility \n" - "with profile is not guaranteed." - "\n\n", - ) - - ffmpeg_opts.add_argument( - "--profile-add-filter", - dest="profile_additional_filters", - action="append", - default=[], - type=opt_types.TypeAdditionalFilter(config), - metavar="filter_name", - help="Use an additional filter profile when encoding. Compatibility \n" - "with profile is not guaranteed.\n" - "You can use this option muiltiple times." - "\n\n", - ) - - ffmpeg_opts.add_argument( - "--list-profiles", - action=opt_actions.ActionListProfiles, - config=config, - help="Show available profiles.\n\n" - f"You can view this in the browser here:\n" - f"{consts.PROJECT_URL_WIKI_PROFILES}\n\n", - ) - - ffmpeg_opts.add_argument( - "--append-video-filter", - type=str, - metavar="filter", - help="Add a custom filter to the video segment of the complex filter.\n" - "Compatibility with profile is not guaranteed.\n" - "Use --dry-run to ensure your filter looks correct before encoding.\n\n" - "Examples:\n" - '--append-video-filter "scale=3480x2160:flags=lanczos,setdar=4/3"' - "\n\n", - ) - - ffmpeg_opts.add_argument( - "--append-other-filter", - type=str, - metavar="filter", - help="Add a custom filter to the end of the complex filter.\n" - "Compatibility with profile is not guaranteed.\n" - "Use --dry-run to ensure your filter looks correct before encoding.\n\n" - "Examples:\n" - '--append-other-filter "[2:a]loudnorm=i=-14"' - "\n\n", - ) - ffmpeg_opts.add_argument( "--audio-track", dest="audio_track", diff --git a/src/tbc_video_export/opts/opts_parser.py b/src/tbc_video_export/opts/opts_parser.py index bb66ce0..4e2cab6 100644 --- a/src/tbc_video_export/opts/opts_parser.py +++ b/src/tbc_video_export/opts/opts_parser.py @@ -2,15 +2,22 @@ import argparse import os +from itertools import chain from typing import TYPE_CHECKING from tbc_video_export.common import consts -from tbc_video_export.common.enums import VideoSystem +from tbc_video_export.common.enums import ( + ProfileVideoType, + VideoBitDepthType, + VideoFormatType, + VideoSystem, +) from tbc_video_export.opts import ( opt_actions, opt_types, opts_ffmpeg, opts_ldtools, + opts_profile, ) from tbc_video_export.opts.opts import Opts @@ -29,6 +36,7 @@ def parse_opts( usage=f"{consts.APPLICATION_NAME} [options] input_file [output_file]\n\n" f"See --help or {consts.PROJECT_URL_WIKI_COMMANDLIST}\n" "---", + epilog=f"Output/profile customization:\n{_get_opt_aliases()}", ) if (cpu_count := os.cpu_count()) is None: @@ -209,7 +217,29 @@ def parse_opts( "\n\n", ) - opts_ffmpeg.add_ffmpeg_opts(config, parser) + opts_ffmpeg.add_ffmpeg_opts(parser) + opts_profile.add_profile_opts(config, parser) opts = parser.parse_intermixed_args(args, namespace=Opts()) return (parser, opts) + + +def _get_opt_aliases() -> str: + pix_fmts = ", ".join( + sorted( + set( + chain( + *((f"--{v}" for _, v in d.value.items()) for d in VideoFormatType) + ) + ) + ) + ) + out_str = f" Pixel Formats:\n {pix_fmts}\n\n" + + bitdepths = ", ".join([f"--{t.value}" for t in VideoBitDepthType]) + out_str += f" Bit Depths:\n {bitdepths}\n\n" + + video_types = ", ".join([f"--{t.value}" for t in ProfileVideoType]) + out_str += f" Video Profile Types:\n {video_types}\n\n" + + return out_str diff --git a/src/tbc_video_export/opts/opts_profile.py b/src/tbc_video_export/opts/opts_profile.py new file mode 100644 index 0000000..09911e8 --- /dev/null +++ b/src/tbc_video_export/opts/opts_profile.py @@ -0,0 +1,179 @@ +from __future__ import annotations + +import argparse +from typing import TYPE_CHECKING + +from tbc_video_export.common import consts +from tbc_video_export.common.enums import ( + ProfileVideoType, + VideoBitDepthType, + VideoFormatType, +) +from tbc_video_export.opts import opt_actions, opt_types + +if TYPE_CHECKING: + from tbc_video_export.config import Config + + +def add_profile_opts(config: Config, parent: argparse.ArgumentParser) -> None: # noqa: C901 + """Add profile opts to the parent arg parser.""" + # chroma/combined profiles + profile_default = config.get_default_profile().name + + # ffmpeg arguments + profile_opts = parent.add_argument_group("profile") + + profile_opts.add_argument( + "--profile", + type=str, + choices=config.get_profile_names(), + default=profile_default, + metavar="profile_name", + help="Specify an FFmpeg profile to use. " + f"(default: {profile_default})\n" + "See --list-profiles to see the available profiles.\n" + "Note: These are also accessible directly, e.g. --x264" + "\n\n", + ) + + profile_opts.add_argument( + "--list-profiles", + action=opt_actions.ActionListProfiles, + config=config, + help="Show available profiles.\n\n" + f"You can view this in the browser here:\n" + f"{consts.PROJECT_URL_WIKI_PROFILES}\n\n", + ) + + profile_opts.add_argument( + "--video-profile", + type=opt_types.TypeVideoProfile(parent), + metavar="profile", + help="Specify an video profile to use.\n" + "Not all video profiles are available for every profile, see --list-profiles.\n" + "Available profiles:\n\n " + + "\n ".join(f"{e.value}" for e in ProfileVideoType) + + "\n\n" + "Note: These are accessible by changing arguments e.g. --lossless, you \n" + "should rarely have to force a video profile." + "\n\n", + ) + + profile_opts.add_argument( + "--audio-profile", + type=str, + metavar="profile", + help="Specify a video profile to use.\n" + "Not all audio profiles are compatible with every profile or container.\n" + "Available profiles:\n\n " + + "\n ".join(f"{e.value}" for e in ProfileVideoType) + + "\n\n" + "Note: These are available as arguments, e.g. --pcm24" + "\n\n", + ) + + profile_opts.add_argument( + "--profile-container", + "--container", + type=str, + metavar="profile_container", + help="Override an FFmpeg profile to use a specific container. Compatibility \n" + "with profile is not guaranteed." + "\n\n", + ) + + profile_opts.add_argument( + "--profile-add-filter", + "--add-filter", + dest="profile_additional_filters", + action="append", + default=[], + type=opt_types.TypeAdditionalFilter(config), + metavar="filter_name", + help="Use an additional filter profile when encoding. Compatibility \n" + "with profile is not guaranteed.\n" + "You can use this option muiltiple times." + "\n\n", + ) + + profile_opts.add_argument( + "--append-video-filter", + "--append-vf", + type=str, + metavar="filter", + help="Add a custom filter to the video segment of the complex filter.\n" + "Compatibility with profile is not guaranteed.\n" + "Use --dry-run to ensure your filter looks correct before encoding.\n\n" + "Examples:\n" + '--append-video-filter "scale=3480x2160:flags=lanczos,setdar=4/3"' + "\n\n", + ) + + profile_opts.add_argument( + "--append-other-filter", + "--append-of", + type=str, + metavar="filter", + help="Add a custom filter to the end of the complex filter.\n" + "Compatibility with profile is not guaranteed.\n" + "Use --dry-run to ensure your filter looks correct before encoding.\n\n" + "Examples:\n" + '--append-other-filter "[2:a]loudnorm=i=-14"' + "\n\n", + ) + + # add aliases + # video bit depth alias + video_bitdepth_opts = parent.add_argument_group("video bitdepth aliases") + for bitdepth in VideoBitDepthType: + video_bitdepth_opts.add_argument( + f"--{bitdepth.value}", + dest="video_bitdepth", + action=opt_actions.ActionSetVideoBitDepthType, + help=argparse.SUPPRESS, + ) + + # video format aliases + video_format_opts = parent.add_argument_group("video format aliases") + + for video_format in VideoFormatType: + video_format_opts.add_argument( + f"--{video_format.name.lower()}", + dest="video_format", + action=opt_actions.ActionSetVideoFormatType, + help=argparse.SUPPRESS, + ) + + # profile aliases + profile_opts = parent.add_argument_group("profile alises") + + for profile_name in config.get_profile_names(): + profile_opts.add_argument( + f"--{profile_name}", + default=profile_default, + dest="profile_name", + action=opt_actions.ActionSetProfile, + help=argparse.SUPPRESS, + ) + + # video profile type aliases + video_type_opts = parent.add_argument_group("video profile type alises") + + for video_type in ProfileVideoType: + video_type_opts.add_argument( + f"--{video_type.value}", + dest="video_type", + action=opt_actions.ActionSetVideoType, + help=argparse.SUPPRESS, + ) + + # audio profile aliases + audio_type_opts = parent.add_argument_group("audio profile alises") + + for audio_type in config.get_audio_profile_names(): + audio_type_opts.add_argument( + f"--{audio_type.replace('_', '-')}", + type=str, + action=opt_actions.ActionSetAudioOverride, + help=argparse.SUPPRESS, + ) diff --git a/src/tbc_video_export/process/wrapper/wrapper_ffmpeg.py b/src/tbc_video_export/process/wrapper/wrapper_ffmpeg.py index 15882e0..4fa122c 100644 --- a/src/tbc_video_export/process/wrapper/wrapper_ffmpeg.py +++ b/src/tbc_video_export/process/wrapper/wrapper_ffmpeg.py @@ -11,13 +11,14 @@ PipeType, ProcessName, TBCType, + VideoFormatType, VideoSystem, ) from tbc_video_export.common.utils import FlatList, ansi from tbc_video_export.process.wrapper.wrapper import Wrapper if TYPE_CHECKING: - from tbc_video_export.config.profile import Profile + from tbc_video_export.config.profile import Profile, ProfileVideo from tbc_video_export.process.wrapper.pipe import Pipe from tbc_video_export.process.wrapper.wrapper import WrapperConfig from tbc_video_export.program_state import ProgramState @@ -216,31 +217,26 @@ def _get_vbi_crop_filter(self) -> str: case VideoSystem.NTSC | VideoSystem.PAL_M: return "crop=iw:ih-19:0:17" - def _get_filter_complex_opts(self) -> FlatList: # noqa: C901, PLR0912 - """Return opts for filter complex.""" - field_filter = f"setfield={self._get_field_order()}" - common_filters: list[str] = [field_filter] + def _get_filters(self) -> tuple[list[str], list[str]]: + """Return tuple containing video and other filters.""" + video_filters: list[str] = [] other_filters: list[str] = [] - # add full vertical -> vbi drop + # add full vertical -> vbi crop if self._state.opts.vbi or self._get_profile().include_vbi: - common_filters.append(self._get_vbi_crop_filter()) + video_filters.append(self._get_vbi_crop_filter()) - if (filter_profiles := self._get_profile().filter_profiles) is not None: - for vf in (profile.video_filter for profile in filter_profiles): - if vf is not None: - common_filters.append(vf) + _vf, _of = self._state.config.get_profile_filters(self._get_profile()) - for of in (profile.other_filter for profile in filter_profiles): - if of is not None: - other_filters.append(of) + # set video filters + video_filters += _vf if self._state.opts.force_anamorphic or self._state.opts.letterbox: - common_filters.append(self._get_widescreen_aspect_ratio_filter()) + video_filters.append(self._get_widescreen_aspect_ratio_filter()) # override profile colorlevels if set with opt if self._state.opts.force_black_level is not None: - common_filters.append( + video_filters.append( "colorlevels=" f"rimin={self._state.opts.force_black_level[0]}/255:" f"gimin={self._state.opts.force_black_level[1]}/255:" @@ -248,12 +244,25 @@ def _get_filter_complex_opts(self) -> FlatList: # noqa: C901, PLR0912 ) if self._state.opts.append_video_filter is not None: - common_filters.append(self._state.opts.append_video_filter) + video_filters.append(self._state.opts.append_video_filter) + + # set other filters + other_filters += _of if self._state.opts.append_other_filter is not None: other_filters.append(self._state.opts.append_other_filter) - filters_opts = f",{','.join(common_filters)}" + return video_filters, other_filters + + def _get_filter_complex_opts(self) -> FlatList: # noqa: C901, PLR0912 + """Return opts for filter complex.""" + field_filter = f"setfield={self._get_field_order()}" + video_filters, other_filters = self._get_filters() + + # add setfield to start of filters + video_filters.insert(0, field_filter) + + video_filters_opts = ",".join(video_filters) other_filters_str = ",".join(other_filters) other_filters_opts = f",{other_filters_str}" if len(other_filters_str) else "" @@ -276,15 +285,15 @@ def _get_filter_complex_opts(self) -> FlatList: # noqa: C901, PLR0912 complex_filter = ( f"[0:v]format=gray16le[luma];[1:v]format=yuv444p16le[chroma];" f"[luma]extractplanes=y[y];[chroma]extractplanes=u+v[u][v];" - f"[y][u][v]mergeplanes={mergeplanes}:format=yuv444p16le" - f"{filters_opts}[v_output]" + f"[y][u][v]mergeplanes={mergeplanes}:format=yuv444p16le," + f"{video_filters_opts}[v_output]" f"{other_filters_opts}" ) case ExportMode.LUMA_EXTRACTED: # extract Y from a Y/C input complex_filter = ( - f"[0:v]extractplanes=y{filters_opts}" + f"[0:v]extractplanes=y,{video_filters_opts}" f"[v_output]" f"{other_filters_opts}" ) @@ -292,18 +301,18 @@ def _get_filter_complex_opts(self) -> FlatList: # noqa: C901, PLR0912 case ExportMode.LUMA_4FSC: # interleve tbc fields complex_filter = ( - f"[0:v]il=l=i:c=i{filters_opts}" + f"[0:v]il=l=i:c=i,{video_filters_opts}" f"[v_output]" f"{other_filters_opts}" ) case _ as mode if mode is ExportMode.LUMA and self._state.opts.two_step: - # luma step in two-step should not use any filters + # luma step in two-step should not use any filters (excluding setfield) complex_filter = f"[0:v]{field_filter}[v_output]" case _: complex_filter = ( - f"[0:v]null{filters_opts}[v_output]{other_filters_opts}" + f"[0:v]{video_filters_opts}[v_output]{other_filters_opts}" ) return FlatList(("-filter_complex", complex_filter)) @@ -401,7 +410,7 @@ def _get_color_opts(self) -> FlatList | None: def _get_format_opts(self) -> FlatList: """Return opts for output format.""" - return FlatList(("-pix_fmt", self._get_profile().video_format)) + return FlatList(("-pix_fmt", self._get_profile_video_format())) def _get_codec_opts(self) -> FlatList: """Return opts containing codecs for inputs.""" @@ -504,6 +513,47 @@ def _get_profile(self) -> Profile: """Return the profile in state.""" return self._state.profile + def _get_video_profile(self) -> ProfileVideo: + """Return the video profile in state.""" + return self._state.profile.video_profile + + def _get_profile_video_format(self) -> str: + """Return the video format in state.""" + video_format = self._get_profile().video_profile.video_format + + # if two step, set to gray8/16le + if self._is_two_step_luma_mode() or self._config.export_mode in ( + ExportMode.LUMA, + ExportMode.LUMA_4FSC, + ExportMode.LUMA_EXTRACTED, + ): + depth = ( + 16 + if self._state.opts.video_bitdepth is None + else self._state.opts.video_bitdepth + ) + + if (new_format := VideoFormatType.GRAY.value.get(depth)) is not None: + video_format = new_format + + # check bitdepth override + if (depth := self._state.opts.video_bitdepth) is not None and ( + new_format := VideoFormatType.get_new_format(video_format, depth) + ) is not None: + video_format = new_format + + # if override opts, ensure format for bitdepth and set + # does not set the luma format when in two-step mode + if ( + (vf := self._state.opts.video_format) is not None + and (depth := self._state.opts.video_bitdepth) is not None + and (new_format := vf.value.get(depth)) is not None + and not self._is_two_step_luma_mode() + ): + video_format = new_format + + return video_format + def _is_two_step_luma_mode(self) -> bool: """Return True if this wrapper is in luma mode while two-step is enabled.""" return self._state.opts.two_step and self._config.export_mode is ExportMode.LUMA diff --git a/src/tbc_video_export/program_state.py b/src/tbc_video_export/program_state.py index 85013e1..d7d0e94 100644 --- a/src/tbc_video_export/program_state.py +++ b/src/tbc_video_export/program_state.py @@ -10,16 +10,17 @@ ChromaDecoder, ExportMode, FlagHelper, - ProfileType, TBCType, VideoSystem, ) from tbc_video_export.common.utils import ansi +from tbc_video_export.config.config import GetProfileFilter from tbc_video_export.process.parser.export_state import ExportState if TYPE_CHECKING: from tbc_video_export.common.file_helper import FileHelper - from tbc_video_export.config.config import Config, Profile + from tbc_video_export.config.config import Config + from tbc_video_export.config.profile import Profile from tbc_video_export.opts import Opts @@ -145,23 +146,16 @@ def dry_run(self) -> bool: """Whether the program will execute the procs or just print them.""" return self.opts.dry_run - @cached_property - def profiles(self) -> dict[ProfileType, Profile]: - """List of profiles loaded.""" - return { - ProfileType.DEFAULT: self.config.get_profile(self.opts.profile), - ProfileType.LUMA: self.config.get_profile(self.opts.profile_luma), - } - @property def profile(self) -> Profile: - """Selected profiles for Luma/Chroma.""" - match self.current_export_mode: - case ExportMode.CHROMA_COMBINED | ExportMode.CHROMA_MERGE: - return self.profiles[ProfileType.DEFAULT] - - case ExportMode.LUMA | ExportMode.LUMA_EXTRACTED | ExportMode.LUMA_4FSC: - return self.profiles[ProfileType.LUMA] + """Return selected profile.""" + return self.config.get_profile( + GetProfileFilter( + self.opts.profile, + self.opts.video_profile, + self.video_system, + ) + ) @cached_property def total_frames(self) -> int: @@ -217,16 +211,8 @@ def __str__(self) -> str: if self.opts.two_step: output_file.append(str(self.file_helper.output_video_file_luma)) - luma_profile = self.profiles[ProfileType.LUMA] - luma_subprofiles = self.config.get_subprofile_descriptions( - luma_profile, True - ) - - profile.append(f"{luma_profile.name} ({luma_subprofiles})") - output_file.append(str(self.file_helper.output_video_file)) - sub_profiles = self.config.get_subprofile_descriptions(self.profile, True) - profile.append(f"{self.profile.name} ({sub_profiles})") + profile.append(f"{self.profile.name}") output_files = ", ".join(output_file) profiles = ( diff --git a/tests/test_wrappers_ffmpeg.py b/tests/test_wrappers_ffmpeg.py index 631e17d..5b5ed26 100644 --- a/tests/test_wrappers_ffmpeg.py +++ b/tests/test_wrappers_ffmpeg.py @@ -7,7 +7,9 @@ from unittest.mock import patch from tbc_video_export.common.enums import TBCType -from tbc_video_export.common.exceptions import InvalidFilterProfileError +from tbc_video_export.common.exceptions import ( + InvalidProfileError, +) from tbc_video_export.common.file_helper import FileHelper from tbc_video_export.config import Config as ProgramConfig from tbc_video_export.opts import opts_parser @@ -277,14 +279,27 @@ def test_ffmpeg_append_filters_opt(self) -> None: # noqa: D102 self.assertTrue(any("[v_output],TEST_OTHER_FILTER" in cmds for cmds in cmd)) def test_ffmpeg_add_invalid_filter_profile_opt(self) -> None: # noqa: D102 - with self.assertRaises(InvalidFilterProfileError): - _, __ = self.parse_opts( - [ - str(self.path), - "pal_svideo", - "--profile-add-filter", - "invalid", - ] + _, opts = self.parse_opts( + [ + str(self.path), + "pal_svideo", + "--profile-add-filter", + "invalid", + ] + ) + + self.files = FileHelper(opts, self.config) + state = ProgramState(opts, self.config, self.files) + + with self.assertRaises(InvalidProfileError): + WrapperFFmpeg( + state, + WrapperConfig[tuple[Pipe], None]( + state.current_export_mode, + TBCType.CHROMA, + input_pipes=(self.pipe, self.pipe), + output_pipes=None, + ), ) def test_ffmpeg_test_invalid_filter_str_opt(self) -> None: # noqa: D102 diff --git a/tests/test_wrappers_ffmpeg_formats.py b/tests/test_wrappers_ffmpeg_formats.py new file mode 100644 index 0000000..0aad6b5 --- /dev/null +++ b/tests/test_wrappers_ffmpeg_formats.py @@ -0,0 +1,155 @@ +from __future__ import annotations + +import unittest +from functools import partial +from pathlib import Path + +from tbc_video_export.common.enums import TBCType +from tbc_video_export.common.file_helper import FileHelper +from tbc_video_export.config import Config as ProgramConfig +from tbc_video_export.opts import opts_parser +from tbc_video_export.process.wrapper import WrapperConfig +from tbc_video_export.process.wrapper.pipe import Pipe, PipeFactory +from tbc_video_export.process.wrapper.wrapper_ffmpeg import WrapperFFmpeg +from tbc_video_export.program_state import ProgramState + + +class TestWrappersFFmpeg(unittest.TestCase): + """Tests for ffmpeg wrapper.""" + + def setUp(self) -> None: # noqa: D102 + self.path = Path.joinpath(Path(__file__).parent, "files", "pal_svideo") + + self.config = ProgramConfig() + self.parse_opts = partial(opts_parser.parse_opts, self.config) + + self.pipe = PipeFactory.create_dummy_pipe() + + def test_ffmpeg_format_10bit(self) -> None: # noqa: D102 + _, opts = self.parse_opts([str(self.path), "pal_svideo", "--10bit"]) + self.files = FileHelper(opts, self.config) + state = ProgramState(opts, self.config, self.files) + + ffmpeg_wrapper = WrapperFFmpeg( + state, + WrapperConfig[tuple[Pipe], None]( + state.current_export_mode, + TBCType.CHROMA, + input_pipes=(self.pipe, self.pipe), + output_pipes=None, + ), + ) + + self.assertTrue( + {"-pix_fmt", "yuv422p10le"}.issubset(ffmpeg_wrapper.command.data) + ) + + def test_ffmpeg_format_16bit(self) -> None: # noqa: D102 + _, opts = self.parse_opts([str(self.path), "pal_svideo", "--16bit"]) + self.files = FileHelper(opts, self.config) + state = ProgramState(opts, self.config, self.files) + + ffmpeg_wrapper = WrapperFFmpeg( + state, + WrapperConfig[tuple[Pipe], None]( + state.current_export_mode, + TBCType.CHROMA, + input_pipes=(self.pipe, self.pipe), + output_pipes=None, + ), + ) + + self.assertTrue( + {"-pix_fmt", "yuv422p16le"}.issubset(ffmpeg_wrapper.command.data) + ) + + def test_ffmpeg_format_yuv444_8bit(self) -> None: # noqa: D102 + _, opts = self.parse_opts([str(self.path), "pal_svideo", "--yuv444", "--8bit"]) + self.files = FileHelper(opts, self.config) + state = ProgramState(opts, self.config, self.files) + + ffmpeg_wrapper = WrapperFFmpeg( + state, + WrapperConfig[tuple[Pipe], None]( + state.current_export_mode, + TBCType.CHROMA, + input_pipes=(self.pipe, self.pipe), + output_pipes=None, + ), + ) + + self.assertTrue({"-pix_fmt", "yuv444p"}.issubset(ffmpeg_wrapper.command.data)) + + def test_ffmpeg_format_yuv444_10bit(self) -> None: # noqa: D102 + _, opts = self.parse_opts([str(self.path), "pal_svideo", "--yuv444", "--10bit"]) + self.files = FileHelper(opts, self.config) + state = ProgramState(opts, self.config, self.files) + + ffmpeg_wrapper = WrapperFFmpeg( + state, + WrapperConfig[tuple[Pipe], None]( + state.current_export_mode, + TBCType.CHROMA, + input_pipes=(self.pipe, self.pipe), + output_pipes=None, + ), + ) + + self.assertTrue( + {"-pix_fmt", "yuv444p10le"}.issubset(ffmpeg_wrapper.command.data) + ) + + def test_ffmpeg_format_yuv444_16bit(self) -> None: # noqa: D102 + _, opts = self.parse_opts([str(self.path), "pal_svideo", "--yuv444", "--16bit"]) + self.files = FileHelper(opts, self.config) + state = ProgramState(opts, self.config, self.files) + + ffmpeg_wrapper = WrapperFFmpeg( + state, + WrapperConfig[tuple[Pipe], None]( + state.current_export_mode, + TBCType.CHROMA, + input_pipes=(self.pipe, self.pipe), + output_pipes=None, + ), + ) + + self.assertTrue( + {"-pix_fmt", "yuv444p16le"}.issubset(ffmpeg_wrapper.command.data) + ) + + def test_ffmpeg_format_luma(self) -> None: # noqa: D102 + _, opts = self.parse_opts([str(self.path), "pal_svideo", "--luma-only"]) + self.files = FileHelper(opts, self.config) + state = ProgramState(opts, self.config, self.files) + + ffmpeg_wrapper = WrapperFFmpeg( + state, + WrapperConfig[tuple[Pipe], None]( + state.current_export_mode, + TBCType.CHROMA, + input_pipes=(self.pipe, self.pipe), + output_pipes=None, + ), + ) + + self.assertTrue({"-pix_fmt", "gray16le"}.issubset(ffmpeg_wrapper.command.data)) + + def test_ffmpeg_format_luma_16bit(self) -> None: # noqa: D102 + _, opts = self.parse_opts( + [str(self.path), "pal_svideo", "--luma-only", "--16bit"] + ) + self.files = FileHelper(opts, self.config) + state = ProgramState(opts, self.config, self.files) + + ffmpeg_wrapper = WrapperFFmpeg( + state, + WrapperConfig[tuple[Pipe], None]( + state.current_export_mode, + TBCType.CHROMA, + input_pipes=(self.pipe, self.pipe), + output_pipes=None, + ), + ) + + self.assertTrue({"-pix_fmt", "gray16le"}.issubset(ffmpeg_wrapper.command.data)) diff --git a/tests/test_wrappers_ntsc_composite.py b/tests/test_wrappers_ntsc_composite.py index 72979f7..6894126 100644 --- a/tests/test_wrappers_ntsc_composite.py +++ b/tests/test_wrappers_ntsc_composite.py @@ -172,7 +172,7 @@ def test_ffmpeg_opts_ntsc_cvbs(self) -> None: "-thread_queue_size 1024", "-i PIPE_IN", "-filter_complex", - "[0:v]null,setfield=tff[v_output]", + "[0:v]setfield=tff[v_output]", "-map [v_output]", "-timecode 00:00:00:00", "-framerate ntsc", @@ -292,7 +292,7 @@ def test_ffmpeg_opts_ntsc_cvbs_luma(self) -> None: "-colorspace smpte170m", "-color_primaries smpte170m", "-color_trc bt709", - "-pix_fmt y8", + "-pix_fmt gray16le", f"-c:v {state.profile.video_profile.codec}", f"{state.profile.video_profile.opts}", f"-c:a {state.profile.audio_profile.codec}", # pyright: ignore [reportOptionalMemberAccess] @@ -409,7 +409,7 @@ def test_ffmpeg_opts_ntsc_cvbs_luma_4fsc(self) -> None: "-colorspace smpte170m", "-color_primaries smpte170m", "-color_trc bt709", - "-pix_fmt y8", + "-pix_fmt gray16le", f"-c:v {state.profile.video_profile.codec}", f"{state.profile.video_profile.opts}", f"-c:a {state.profile.audio_profile.codec}", # pyright: ignore [reportOptionalMemberAccess] diff --git a/tests/test_wrappers_ntsc_composite_ld.py b/tests/test_wrappers_ntsc_composite_ld.py index 485e679..0fdb7a4 100644 --- a/tests/test_wrappers_ntsc_composite_ld.py +++ b/tests/test_wrappers_ntsc_composite_ld.py @@ -190,7 +190,7 @@ def test_ffmpeg_opts_ntsc_ld(self) -> None: "-thread_queue_size 1024", "-i PIPE_IN", "-filter_complex", - "[0:v]null,setfield=tff[v_output]", + "[0:v]setfield=tff[v_output]", "-map [v_output]", "-timecode 00:00:00:00", "-framerate ntsc", @@ -310,7 +310,7 @@ def test_ffmpeg_opts_ntsc_ld_luma(self) -> None: "-colorspace smpte170m", "-color_primaries smpte170m", "-color_trc bt709", - "-pix_fmt y8", + "-pix_fmt gray16le", f"-c:v {state.profile.video_profile.codec}", f"{state.profile.video_profile.opts}", f"-c:a {state.profile.audio_profile.codec}", # pyright: ignore [reportOptionalMemberAccess] @@ -427,7 +427,7 @@ def test_ffmpeg_opts_ntsc_ld_luma_4fsc(self) -> None: "-colorspace smpte170m", "-color_primaries smpte170m", "-color_trc bt709", - "-pix_fmt y8", + "-pix_fmt gray16le", f"-c:v {state.profile.video_profile.codec}", f"{state.profile.video_profile.opts}", f"-c:a {state.profile.audio_profile.codec}", # pyright: ignore [reportOptionalMemberAccess] diff --git a/tests/test_wrappers_ntsc_svideo.py b/tests/test_wrappers_ntsc_svideo.py index f6c9409..047b9ca 100644 --- a/tests/test_wrappers_ntsc_svideo.py +++ b/tests/test_wrappers_ntsc_svideo.py @@ -329,7 +329,7 @@ def test_ffmpeg_opts_ntsc_svideo_luma(self) -> None: "-thread_queue_size 1024", "-i PIPE_IN", "-filter_complex", - "[0:v]null,setfield=tff[v_output]", + "[0:v]setfield=tff[v_output]", "-map [v_output]", "-timecode 00:00:00:00", "-framerate ntsc", @@ -337,7 +337,7 @@ def test_ffmpeg_opts_ntsc_svideo_luma(self) -> None: "-colorspace smpte170m", "-color_primaries smpte170m", "-color_trc bt709", - "-pix_fmt y8", + "-pix_fmt gray16le", f"-c:v {state.profile.video_profile.codec}", f"{state.profile.video_profile.opts}", f"-c:a {state.profile.audio_profile.codec}", # pyright: ignore [reportOptionalMemberAccess] @@ -454,7 +454,7 @@ def test_ffmpeg_opts_ntsc_svideo_luma_4fsc(self) -> None: "-colorspace smpte170m", "-color_primaries smpte170m", "-color_trc bt709", - "-pix_fmt y8", + "-pix_fmt gray16le", f"-c:v {state.profile.video_profile.codec}", f"{state.profile.video_profile.opts}", f"-c:a {state.profile.audio_profile.codec}", # pyright: ignore [reportOptionalMemberAccess] diff --git a/tests/test_wrappers_pal_composite_.py b/tests/test_wrappers_pal_composite_.py index 890ca3d..a7ac0d7 100644 --- a/tests/test_wrappers_pal_composite_.py +++ b/tests/test_wrappers_pal_composite_.py @@ -171,7 +171,7 @@ def test_default_ffmpeg_opts_cvbs_pal(self) -> None: "-thread_queue_size 1024", "-i PIPE_IN", "-filter_complex", - "[0:v]null,setfield=tff[v_output]", + "[0:v]setfield=tff[v_output]", "-map [v_output]", "-timecode 00:00:00:00", "-framerate pal", @@ -291,7 +291,7 @@ def test_ffmpeg_opts_pal_cvbs_luma(self) -> None: "-colorspace bt470bg", "-color_primaries bt470bg", "-color_trc bt709", - "-pix_fmt y8", + "-pix_fmt gray16le", f"-c:v {state.profile.video_profile.codec}", f"{state.profile.video_profile.opts}", f"-c:a {state.profile.audio_profile.codec}", # pyright: ignore [reportOptionalMemberAccess] @@ -408,7 +408,7 @@ def test_ffmpeg_opts_pal_cvbs_luma_4fsc(self) -> None: "-colorspace bt470bg", "-color_primaries bt470bg", "-color_trc bt709", - "-pix_fmt y8", + "-pix_fmt gray16le", f"-c:v {state.profile.video_profile.codec}", f"{state.profile.video_profile.opts}", f"-c:a {state.profile.audio_profile.codec}", # pyright: ignore [reportOptionalMemberAccess] diff --git a/tests/test_wrappers_pal_composite_ld.py b/tests/test_wrappers_pal_composite_ld.py index d50dde8..1fc592f 100644 --- a/tests/test_wrappers_pal_composite_ld.py +++ b/tests/test_wrappers_pal_composite_ld.py @@ -178,7 +178,7 @@ def test_ffmpeg_opts_pal_ld(self) -> None: "-thread_queue_size 1024", "-i PIPE_IN", "-filter_complex", - "[0:v]null,setfield=tff[v_output]", + "[0:v]setfield=tff[v_output]", "-map [v_output]", "-timecode 00:00:00:00", "-framerate pal", @@ -298,7 +298,7 @@ def test_ffmpeg_opts_pal_ld_luma(self) -> None: "-colorspace bt470bg", "-color_primaries bt470bg", "-color_trc bt709", - "-pix_fmt y8", + "-pix_fmt gray16le", f"-c:v {state.profile.video_profile.codec}", f"{state.profile.video_profile.opts}", f"-c:a {state.profile.audio_profile.codec}", # pyright: ignore [reportOptionalMemberAccess] @@ -415,7 +415,7 @@ def test_ffmpeg_opts_pal_ld_luma_4fsc(self) -> None: "-colorspace bt470bg", "-color_primaries bt470bg", "-color_trc bt709", - "-pix_fmt y8", + "-pix_fmt gray16le", f"-c:v {state.profile.video_profile.codec}", f"{state.profile.video_profile.opts}", f"-c:a {state.profile.audio_profile.codec}", # pyright: ignore [reportOptionalMemberAccess] diff --git a/tests/test_wrappers_pal_svideo.py b/tests/test_wrappers_pal_svideo.py index 5c5f029..4a8f6e0 100644 --- a/tests/test_wrappers_pal_svideo.py +++ b/tests/test_wrappers_pal_svideo.py @@ -327,7 +327,7 @@ def test_ffmpeg_opts_pal_svideo_luma(self) -> None: "-thread_queue_size 1024", "-i PIPE_IN", "-filter_complex", - "[0:v]null,setfield=tff[v_output]", + "[0:v]setfield=tff[v_output]", "-map [v_output]", "-timecode 00:00:00:00", "-framerate pal", @@ -335,7 +335,7 @@ def test_ffmpeg_opts_pal_svideo_luma(self) -> None: "-colorspace bt470bg", "-color_primaries bt470bg", "-color_trc bt709", - "-pix_fmt y8", + "-pix_fmt gray16le", f"-c:v {state.profile.video_profile.codec}", f"{state.profile.video_profile.opts}", f"-c:a {state.profile.audio_profile.codec}", # pyright: ignore [reportOptionalMemberAccess] @@ -452,7 +452,7 @@ def test_ffmpeg_opts_pal_svideo_luma_4fsc(self) -> None: "-colorspace bt470bg", "-color_primaries bt470bg", "-color_trc bt709", - "-pix_fmt y8", + "-pix_fmt gray16le", f"-c:v {state.profile.video_profile.codec}", f"{state.profile.video_profile.opts}", f"-c:a {state.profile.audio_profile.codec}", # pyright: ignore [reportOptionalMemberAccess] diff --git a/tests/test_wrappers_palm_svideo.py b/tests/test_wrappers_palm_svideo.py index 8479957..7501468 100644 --- a/tests/test_wrappers_palm_svideo.py +++ b/tests/test_wrappers_palm_svideo.py @@ -289,7 +289,7 @@ def test_ffmpeg_opts_palm_svideo_luma(self) -> None: "-thread_queue_size 1024", "-i PIPE_IN", "-filter_complex", - "[0:v]null,setfield=tff[v_output]", + "[0:v]setfield=tff[v_output]", "-map [v_output]", "-timecode 00:00:00:00", "-framerate ntsc", @@ -297,7 +297,7 @@ def test_ffmpeg_opts_palm_svideo_luma(self) -> None: "-colorspace bt470bg", "-color_primaries bt470bg", "-color_trc bt709", - "-pix_fmt y8", + "-pix_fmt gray16le", f"-c:v {state.profile.video_profile.codec}", f"{state.profile.video_profile.opts}", f"-c:a {state.profile.audio_profile.codec}", # pyright: ignore [reportOptionalMemberAccess] @@ -414,7 +414,7 @@ def test_ffmpeg_opts_palm_svideo_luma_4fsc(self) -> None: "-colorspace bt470bg", "-color_primaries bt470bg", "-color_trc bt709", - "-pix_fmt y8", + "-pix_fmt gray16le", f"-c:v {state.profile.video_profile.codec}", f"{state.profile.video_profile.opts}", f"-c:a {state.profile.audio_profile.codec}", # pyright: ignore [reportOptionalMemberAccess]