Merge branch 'develop' into 1595-yn-0304-usd-contributions-customizable-order-strength-per-instance

This commit is contained in:
Roy Nieterau 2025-12-15 20:29:12 +01:00 committed by GitHub
commit 34004ac538
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 304 additions and 100 deletions

View file

@ -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)

View file

@ -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

View file

@ -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)

View file

@ -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.

View file

@ -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

View file

@ -400,24 +400,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,
@ -434,6 +440,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"},
@ -1458,22 +1473,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,