diff --git a/client/ayon_core/lib/transcoding.py b/client/ayon_core/lib/transcoding.py index 061a7bd398..8e9ed90d1a 100644 --- a/client/ayon_core/lib/transcoding.py +++ b/client/ayon_core/lib/transcoding.py @@ -134,16 +134,29 @@ def get_transcode_temp_directory(): ) -def get_oiio_info_for_input(filepath, logger=None, subimages=False): +def get_oiio_info_for_input( + filepath: str, + *, + subimages: bool = False, + verbose: bool = True, + logger: logging.Logger = None, +): """Call oiiotool to get information about input and return stdout. + Args: + filepath (str): Path to file. + subimages (bool): include info about subimages in the output. + verbose (bool): get the full metadata about each input image. + logger (logging.Logger): Logger used for logging. + Stdout should contain xml format string. """ args = get_oiio_tool_args( "oiiotool", "--info", - "-v" ) + if verbose: + args.append("-v") if subimages: args.append("-a") @@ -573,7 +586,10 @@ def get_review_layer_name(src_filepath): return None # Load info about file from oiio tool - input_info = get_oiio_info_for_input(src_filepath) + input_info = get_oiio_info_for_input( + src_filepath, + verbose=False, + ) if not input_info: return None @@ -1234,7 +1250,11 @@ def oiio_color_convert( for token in ["#", "%d"]: first_input_path = first_input_path.replace(token, first_frame) - input_info = get_oiio_info_for_input(first_input_path, logger=logger) + input_info = get_oiio_info_for_input( + first_input_path, + verbose=False, + logger=logger, + ) # Collect channels to export input_arg, channels_arg = get_oiio_input_and_channel_args(input_info) @@ -1448,7 +1468,11 @@ def get_rescaled_command_arguments( command_args.extend(["-vf", "{0},{1}".format(scale, pad)]) elif application == "oiiotool": - input_info = get_oiio_info_for_input(input_path, logger=log) + input_info = get_oiio_info_for_input( + input_path, + verbose=False, + logger=log, + ) # Collect channels to export _, channels_arg = get_oiio_input_and_channel_args( input_info, alpha_default=1.0) @@ -1539,7 +1563,11 @@ def _get_image_dimensions(application, input_path, log): # fallback for weird files with width=0, height=0 if (input_width == 0 or input_height == 0) and application == "oiiotool": # Load info about file from oiio tool - input_info = get_oiio_info_for_input(input_path, logger=log) + input_info = get_oiio_info_for_input( + input_path, + verbose=False, + logger=log, + ) if input_info: input_width = int(input_info["width"]) input_height = int(input_info["height"]) @@ -1588,10 +1616,13 @@ def get_oiio_input_and_channel_args(oiio_input_info, alpha_default=None): """Get input and channel arguments for oiiotool. Args: oiio_input_info (dict): Information about input from oiio tool. - Should be output of function `get_oiio_info_for_input`. + Should be output of function 'get_oiio_info_for_input' (can be + called with 'verbose=False'). alpha_default (float, optional): Default value for alpha channel. + Returns: tuple[str, str]: Tuple of input and channel arguments. + """ channel_names = oiio_input_info["channelnames"] review_channels = get_convert_rgb_channels(channel_names) diff --git a/client/ayon_core/plugins/publish/collect_scene_loaded_versions.py b/client/ayon_core/plugins/publish/collect_scene_loaded_versions.py index f509ed807a..54eeefc60b 100644 --- a/client/ayon_core/plugins/publish/collect_scene_loaded_versions.py +++ b/client/ayon_core/plugins/publish/collect_scene_loaded_versions.py @@ -1,3 +1,5 @@ +from __future__ import annotations +from typing import Any import ayon_api import ayon_api.utils @@ -32,6 +34,8 @@ class CollectSceneLoadedVersions(pyblish.api.ContextPlugin): self.log.debug("No loaded containers found in scene.") return + containers = self._filter_invalid_containers(containers) + repre_ids = { container["representation"] for container in containers @@ -78,3 +82,28 @@ class CollectSceneLoadedVersions(pyblish.api.ContextPlugin): self.log.debug(f"Collected {len(loaded_versions)} loaded versions.") context.data["loadedVersions"] = loaded_versions + + def _filter_invalid_containers( + self, + containers: list[dict[str, Any]] + ) -> list[dict[str, Any]]: + """Filter out invalid containers lacking required keys. + + Skip any invalid containers that lack 'representation' or 'name' + keys to avoid KeyError. + """ + # Only filter by what's required for this plug-in instead of validating + # a full container schema. + required_keys = {"name", "representation"} + valid = [] + for container in containers: + missing = [key for key in required_keys if key not in container] + if missing: + self.log.warning( + "Skipping invalid container, missing required keys:" + " {}. {}".format(", ".join(missing), container) + ) + continue + valid.append(container) + + return valid diff --git a/client/ayon_core/plugins/publish/extract_thumbnail.py b/client/ayon_core/plugins/publish/extract_thumbnail.py index adfb4298b9..1d0eac2040 100644 --- a/client/ayon_core/plugins/publish/extract_thumbnail.py +++ b/client/ayon_core/plugins/publish/extract_thumbnail.py @@ -1,8 +1,9 @@ import copy +from dataclasses import dataclass, field, fields import os import subprocess import tempfile -import re +from typing import Dict, Any, List, Tuple, Optional import pyblish.api from ayon_core.lib import ( @@ -15,6 +16,7 @@ from ayon_core.lib import ( path_to_subprocess_arg, run_subprocess, + filter_profiles, ) from ayon_core.lib.transcoding import ( MissingRGBAChannelsError, @@ -26,6 +28,61 @@ from ayon_core.lib.transcoding import ( from ayon_core.lib.transcoding import VIDEO_EXTENSIONS, IMAGE_EXTENSIONS +@dataclass +class ThumbnailDef: + """ + Data class representing the full configuration for selected profile + + Any change of controllable fields in Settings must propagate here! + """ + integrate_thumbnail: bool = False + + target_size: Dict[str, Any] = field( + default_factory=lambda: { + "type": "source", + "resize": {"width": 1920, "height": 1080}, + } + ) + + duration_split: float = 0.5 + + oiiotool_defaults: Dict[str, str] = field( + default_factory=lambda: { + "type": "colorspace", + "colorspace": "color_picking" + } + ) + + ffmpeg_args: Dict[str, List[Any]] = field( + default_factory=lambda: {"input": [], "output": []} + ) + + # Background color defined as (R, G, B, A) tuple. + # Note: Use float for alpha channel (0.0 to 1.0). + background_color: Tuple[int, int, int, float] = (0, 0, 0, 0.0) + + @classmethod + def from_dict(cls, data: Dict[str, Any]) -> "ThumbnailDef": + """ + Creates a ThumbnailDef instance from a dictionary, safely ignoring + any keys in the dictionary that are not fields in the dataclass. + + Args: + data (Dict[str, Any]): The dictionary containing configuration data + + Returns: + MediaConfig: A new instance of the dataclass. + """ + # Get all field names defined in the dataclass + field_names = {f.name for f in fields(cls)} + + # Filter the input dictionary to include only keys matching field names + filtered_data = {k: v for k, v in data.items() if k in field_names} + + # Unpack the filtered dictionary into the constructor + return cls(**filtered_data) + + class ExtractThumbnail(pyblish.api.InstancePlugin): """Create jpg thumbnail from sequence using ffmpeg""" @@ -53,30 +110,7 @@ class ExtractThumbnail(pyblish.api.InstancePlugin): settings_category = "core" enabled = False - integrate_thumbnail = False - target_size = { - "type": "source", - "resize": { - "width": 1920, - "height": 1080 - } - } - background_color = (0, 0, 0, 0.0) - duration_split = 0.5 - # attribute presets from settings - oiiotool_defaults = { - "type": "colorspace", - "colorspace": "color_picking", - "display_and_view": { - "display": "default", - "view": "sRGB" - } - } - ffmpeg_args = { - "input": [], - "output": [] - } - product_names = [] + profiles = [] def process(self, instance): # run main process @@ -99,6 +133,13 @@ class ExtractThumbnail(pyblish.api.InstancePlugin): instance.data["representations"].remove(repre) def _main_process(self, instance): + if not self.profiles: + self.log.debug("No profiles present for extract review thumbnail.") + return + thumbnail_def = self._get_config_from_profile(instance) + if not thumbnail_def: + return + product_name = instance.data["productName"] instance_repres = instance.data.get("representations") if not instance_repres: @@ -131,24 +172,6 @@ class ExtractThumbnail(pyblish.api.InstancePlugin): self.log.debug("Skipping crypto passes.") return - # We only want to process the produces needed from settings. - def validate_string_against_patterns(input_str, patterns): - for pattern in patterns: - if re.match(pattern, input_str): - return True - return False - - product_names = self.product_names - if product_names: - result = validate_string_against_patterns( - product_name, product_names - ) - if not result: - self.log.debug(( - "Product name \"{}\" did not match settings filters: {}" - ).format(product_name, product_names)) - return - # first check for any explicitly marked representations for thumbnail explicit_repres = self._get_explicit_repres_for_thumbnail(instance) if explicit_repres: @@ -193,7 +216,8 @@ class ExtractThumbnail(pyblish.api.InstancePlugin): ) file_path = self._create_frame_from_video( video_file_path, - dst_staging + dst_staging, + thumbnail_def ) if file_path: src_staging, input_file = os.path.split(file_path) @@ -206,7 +230,8 @@ class ExtractThumbnail(pyblish.api.InstancePlugin): if "slate-frame" in repre.get("tags", []): repre_files_thumb = repre_files_thumb[1:] file_index = int( - float(len(repre_files_thumb)) * self.duration_split) + float(len(repre_files_thumb)) * thumbnail_def.duration_split # noqa: E501 + ) input_file = repre_files[file_index] full_input_path = os.path.join(src_staging, input_file) @@ -243,13 +268,13 @@ class ExtractThumbnail(pyblish.api.InstancePlugin): # colorspace data if not repre_thumb_created: repre_thumb_created = self._create_thumbnail_ffmpeg( - full_input_path, full_output_path + full_input_path, full_output_path, thumbnail_def ) # Skip representation and try next one if wasn't created if not repre_thumb_created and oiio_supported: repre_thumb_created = self._create_thumbnail_oiio( - full_input_path, full_output_path + full_input_path, full_output_path, thumbnail_def ) if not repre_thumb_created: @@ -277,7 +302,7 @@ class ExtractThumbnail(pyblish.api.InstancePlugin): new_repre_tags = ["thumbnail"] # for workflows which needs to have thumbnails published as # separate representations `delete` tag should not be added - if not self.integrate_thumbnail: + if not thumbnail_def.integrate_thumbnail: new_repre_tags.append("delete") new_repre = { @@ -399,6 +424,7 @@ class ExtractThumbnail(pyblish.api.InstancePlugin): src_path, dst_path, colorspace_data, + thumbnail_def ): """Create thumbnail using OIIO tool oiiotool @@ -416,7 +442,9 @@ class ExtractThumbnail(pyblish.api.InstancePlugin): str: path to created thumbnail """ self.log.info("Extracting thumbnail {}".format(dst_path)) - resolution_arg = self._get_resolution_arg("oiiotool", src_path) + resolution_arg = self._get_resolution_args( + "oiiotool", src_path, thumbnail_def + ) repre_display = colorspace_data.get("display") repre_view = colorspace_data.get("view") @@ -435,12 +463,13 @@ class ExtractThumbnail(pyblish.api.InstancePlugin): ) # if representation doesn't have display and view then use # oiiotool_defaults - elif self.oiiotool_defaults: - oiio_default_type = self.oiiotool_defaults["type"] + elif thumbnail_def.oiiotool_defaults: + oiiotool_defaults = thumbnail_def.oiiotool_defaults + oiio_default_type = oiiotool_defaults["type"] if "colorspace" == oiio_default_type: - oiio_default_colorspace = self.oiiotool_defaults["colorspace"] + oiio_default_colorspace = oiiotool_defaults["colorspace"] else: - display_and_view = self.oiiotool_defaults["display_and_view"] + display_and_view = oiiotool_defaults["display_and_view"] oiio_default_display = display_and_view["display"] oiio_default_view = display_and_view["view"] @@ -467,18 +496,24 @@ class ExtractThumbnail(pyblish.api.InstancePlugin): return True - def _create_thumbnail_oiio(self, src_path, dst_path): + def _create_thumbnail_oiio(self, src_path, dst_path, thumbnail_def): self.log.debug(f"Extracting thumbnail with OIIO: {dst_path}") try: - resolution_arg = self._get_resolution_arg("oiiotool", src_path) + resolution_arg = self._get_resolution_args( + "oiiotool", src_path, thumbnail_def + ) except RuntimeError: self.log.warning( "Failed to create thumbnail using oiio", exc_info=True ) return False - input_info = get_oiio_info_for_input(src_path, logger=self.log) + input_info = get_oiio_info_for_input( + src_path, + logger=self.log, + verbose=False, + ) try: input_arg, channels_arg = get_oiio_input_and_channel_args( input_info @@ -511,9 +546,11 @@ class ExtractThumbnail(pyblish.api.InstancePlugin): ) return False - def _create_thumbnail_ffmpeg(self, src_path, dst_path): + def _create_thumbnail_ffmpeg(self, src_path, dst_path, thumbnail_def): try: - resolution_arg = self._get_resolution_arg("ffmpeg", src_path) + resolution_arg = self._get_resolution_args( + "ffmpeg", src_path, thumbnail_def + ) except RuntimeError: self.log.warning( "Failed to create thumbnail using ffmpeg", exc_info=True @@ -521,7 +558,7 @@ class ExtractThumbnail(pyblish.api.InstancePlugin): return False ffmpeg_path_args = get_ffmpeg_tool_args("ffmpeg") - ffmpeg_args = self.ffmpeg_args or {} + ffmpeg_args = thumbnail_def.ffmpeg_args or {} jpeg_items = [ subprocess.list2cmdline(ffmpeg_path_args) @@ -561,7 +598,12 @@ class ExtractThumbnail(pyblish.api.InstancePlugin): ) return False - def _create_frame_from_video(self, video_file_path, output_dir): + def _create_frame_from_video( + self, + video_file_path, + output_dir, + thumbnail_def + ): """Convert video file to one frame image via ffmpeg""" # create output file path base_name = os.path.basename(video_file_path) @@ -586,7 +628,7 @@ class ExtractThumbnail(pyblish.api.InstancePlugin): seek_position = 0.0 # Only use timestamp calculation for videos longer than 0.1 seconds if duration > 0.1: - seek_position = duration * self.duration_split + seek_position = duration * thumbnail_def.duration_split # Build command args cmd_args = [] @@ -660,16 +702,17 @@ class ExtractThumbnail(pyblish.api.InstancePlugin): ): os.remove(output_thumb_file_path) - def _get_resolution_arg( + def _get_resolution_args( self, application, input_path, + thumbnail_def ): # get settings - if self.target_size["type"] == "source": + if thumbnail_def.target_size["type"] == "source": return [] - resize = self.target_size["resize"] + resize = thumbnail_def.target_size["resize"] target_width = resize["width"] target_height = resize["height"] @@ -679,6 +722,43 @@ class ExtractThumbnail(pyblish.api.InstancePlugin): input_path, target_width, target_height, - bg_color=self.background_color, + bg_color=thumbnail_def.background_color, log=self.log ) + + def _get_config_from_profile( + self, + instance: pyblish.api.Instance + ) -> Optional[ThumbnailDef]: + """Returns profile if and how repre should be color transcoded.""" + host_name = instance.context.data["hostName"] + product_type = instance.data["productType"] + product_name = instance.data["productName"] + task_data = instance.data["anatomyData"].get("task", {}) + task_name = task_data.get("name") + task_type = task_data.get("type") + filtering_criteria = { + "host_names": host_name, + "product_types": product_type, + "product_names": product_name, + "task_names": task_name, + "task_types": task_type, + } + profile = filter_profiles( + self.profiles, + filtering_criteria, + logger=self.log + ) + + if not profile: + self.log.debug( + "Skipped instance. None of profiles in presets are for" + f' Host: "{host_name}"' + f' | Product types: "{product_type}"' + f' | Product names: "{product_name}"' + f' | Task name "{task_name}"' + f' | Task type "{task_type}"' + ) + return None + + return ThumbnailDef.from_dict(profile) diff --git a/pyproject.toml b/pyproject.toml index b06f812b27..562bb72035 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -37,7 +37,7 @@ opentimelineio = "^0.17.0" speedcopy = "^2.1" qtpy="^2.4.3" pyside6 = "^6.5.2" -pytest-ayon = { git = "https://github.com/ynput/pytest-ayon.git", branch = "chore/align-dependencies" } +pytest-ayon = { git = "https://github.com/ynput/pytest-ayon.git", branch = "develop" } [tool.codespell] # Ignore words that are not in the dictionary. diff --git a/server/settings/conversion.py b/server/settings/conversion.py index 757818a9ff..9da765e366 100644 --- a/server/settings/conversion.py +++ b/server/settings/conversion.py @@ -158,6 +158,46 @@ def _convert_publish_plugins(overrides): _convert_oiio_transcode_0_4_5(overrides["publish"]) +def _convert_extract_thumbnail(overrides): + """ExtractThumbnail config settings did change to profiles.""" + extract_thumbnail_overrides = ( + overrides.get("publish", {}).get("ExtractThumbnail") + ) + if extract_thumbnail_overrides is None: + return + + base_value = { + "product_types": [], + "host_names": [], + "task_types": [], + "task_names": [], + "product_names": [], + "integrate_thumbnail": True, + "target_size": {"type": "source"}, + "duration_split": 0.5, + "oiiotool_defaults": { + "type": "colorspace", + "colorspace": "color_picking", + }, + "ffmpeg_args": {"input": ["-apply_trc gamma22"], "output": []}, + } + for key in ( + "product_names", + "integrate_thumbnail", + "target_size", + "duration_split", + "oiiotool_defaults", + "ffmpeg_args", + ): + if key in extract_thumbnail_overrides: + base_value[key] = extract_thumbnail_overrides.pop(key) + + extract_thumbnail_profiles = extract_thumbnail_overrides.setdefault( + "profiles", [] + ) + extract_thumbnail_profiles.append(base_value) + + def convert_settings_overrides( source_version: str, overrides: dict[str, Any], @@ -166,4 +206,5 @@ def convert_settings_overrides( _convert_imageio_configs_0_4_5(overrides) _convert_imageio_configs_1_6_5(overrides) _convert_publish_plugins(overrides) + _convert_extract_thumbnail(overrides) return overrides diff --git a/server/settings/publish_plugins.py b/server/settings/publish_plugins.py index 5524f7920d..a328465932 100644 --- a/server/settings/publish_plugins.py +++ b/server/settings/publish_plugins.py @@ -410,24 +410,30 @@ class ExtractThumbnailOIIODefaultsModel(BaseSettingsModel): ) -class ExtractThumbnailModel(BaseSettingsModel): - _isGroup = True - enabled: bool = SettingsField(True) +class ExtractThumbnailProfileModel(BaseSettingsModel): + product_types: list[str] = SettingsField( + default_factory=list, title="Product types" + ) + host_names: list[str] = SettingsField( + default_factory=list, title="Host names" + ) + task_types: list[str] = SettingsField( + default_factory=list, title="Task types", enum_resolver=task_types_enum + ) + task_names: list[str] = SettingsField( + default_factory=list, title="Task names" + ) product_names: list[str] = SettingsField( - default_factory=list, - title="Product names" + default_factory=list, title="Product names" ) integrate_thumbnail: bool = SettingsField( - True, - title="Integrate Thumbnail Representation" + True, title="Integrate Thumbnail Representation" ) target_size: ResizeModel = SettingsField( - default_factory=ResizeModel, - title="Target size" + default_factory=ResizeModel, title="Target size" ) background_color: ColorRGBA_uint8 = SettingsField( - (0, 0, 0, 0.0), - title="Background color" + (0, 0, 0, 0.0), title="Background color" ) duration_split: float = SettingsField( 0.5, @@ -444,6 +450,15 @@ class ExtractThumbnailModel(BaseSettingsModel): ) +class ExtractThumbnailModel(BaseSettingsModel): + _isGroup = True + enabled: bool = SettingsField(True) + + profiles: list[ExtractThumbnailProfileModel] = SettingsField( + default_factory=list, title="Profiles" + ) + + def _extract_oiio_transcoding_type(): return [ {"value": "colorspace", "label": "Use Colorspace"}, @@ -1468,22 +1483,30 @@ DEFAULT_PUBLISH_VALUES = { }, "ExtractThumbnail": { "enabled": True, - "product_names": [], - "integrate_thumbnail": True, - "target_size": { - "type": "source" - }, - "duration_split": 0.5, - "oiiotool_defaults": { - "type": "colorspace", - "colorspace": "color_picking" - }, - "ffmpeg_args": { - "input": [ - "-apply_trc gamma22" - ], - "output": [] - } + "profiles": [ + { + "product_types": [], + "host_names": [], + "task_types": [], + "task_names": [], + "product_names": [], + "integrate_thumbnail": True, + "target_size": { + "type": "source" + }, + "duration_split": 0.5, + "oiiotool_defaults": { + "type": "colorspace", + "colorspace": "color_picking" + }, + "ffmpeg_args": { + "input": [ + "-apply_trc gamma22" + ], + "output": [] + } + } + ] }, "ExtractOIIOTranscode": { "enabled": True,