Merge branch 'develop' into enhancement/1470-yn-0067-publisher-crashed-plugins

This commit is contained in:
Jakub Trllo 2025-12-16 18:44:36 +01:00 committed by GitHub
commit f2e014b3f8
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
17 changed files with 526 additions and 231 deletions

View file

@ -6,7 +6,6 @@ import logging
import code
import traceback
from pathlib import Path
import warnings
import click
@ -90,54 +89,6 @@ def addon(ctx):
pass
@main_cli.command()
@click.pass_context
@click.argument("output_json_path")
@click.option("--project", help="Project name", default=None)
@click.option("--asset", help="Folder path", default=None)
@click.option("--task", help="Task name", default=None)
@click.option("--app", help="Application name", default=None)
@click.option(
"--envgroup", help="Environment group (e.g. \"farm\")", default=None
)
def extractenvironments(
ctx, output_json_path, project, asset, task, app, envgroup
):
"""Extract environment variables for entered context to a json file.
Entered output filepath will be created if does not exists.
All context options must be passed otherwise only AYON's global
environments will be extracted.
Context options are "project", "asset", "task", "app"
Deprecated:
This function is deprecated and will be removed in future. Please use
'addon applications extractenvironments ...' instead.
"""
warnings.warn(
(
"Command 'extractenvironments' is deprecated and will be"
" removed in future. Please use"
" 'addon applications extractenvironments ...' instead."
),
DeprecationWarning
)
addons_manager = ctx.obj["addons_manager"]
applications_addon = addons_manager.get_enabled_addon("applications")
if applications_addon is None:
raise RuntimeError(
"Applications addon is not available or enabled."
)
# Please ignore the fact this is using private method
applications_addon._cli_extract_environments(
output_json_path, project, asset, task, app, envgroup
)
@main_cli.command()
@click.pass_context
@click.argument("path", required=True)

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

@ -146,7 +146,15 @@ class BaseCreator(ABC):
project_settings (dict[str, Any]): Project settings.
create_context (CreateContext): Context which initialized creator.
headless (bool): Running in headless mode.
"""
# Attribute 'skip_discovery' is used during discovery phase to skip
# plugins, which can be used to mark base plugins that should not be
# considered as plugins "to use". The discovery logic does NOT use
# the attribute value from parent classes. Each base class has to define
# the attribute again.
skip_discovery = True
# Label shown in UI
label = None
group_label = None
@ -642,7 +650,7 @@ class Creator(BaseCreator):
Creation requires prepared product name and instance data.
"""
skip_discovery = True
# GUI Purposes
# - default_variants may not be used if `get_default_variants`
# is overridden
@ -931,6 +939,8 @@ class Creator(BaseCreator):
class HiddenCreator(BaseCreator):
skip_discovery = True
@abstractmethod
def create(self, instance_data, source_data):
pass
@ -941,6 +951,7 @@ class AutoCreator(BaseCreator):
Can be used e.g. for `workfile`.
"""
skip_discovery = True
def remove_instances(self, instances):
"""Skip removal."""

View file

@ -21,6 +21,13 @@ from .utils import get_representation_path_from_context
class LoaderPlugin(list):
"""Load representation into host application"""
# Attribute 'skip_discovery' is used during discovery phase to skip
# plugins, which can be used to mark base plugins that should not be
# considered as plugins "to use". The discovery logic does NOT use
# the attribute value from parent classes. Each base class has to define
# the attribute again.
skip_discovery = True
product_types: set[str] = set()
product_base_types: Optional[set[str]] = None
representations = set()

View file

@ -138,7 +138,14 @@ def discover_plugins(
for item in modules:
filepath, module = item
result.add_module(module)
all_plugins.extend(classes_from_module(base_class, module))
for cls in classes_from_module(base_class, module):
if cls is base_class:
continue
# Class has defined 'skip_discovery = True'
skip_discovery = cls.__dict__.get("skip_discovery")
if skip_discovery is True:
continue
all_plugins.append(cls)
if base_class not in ignored_classes:
ignored_classes.append(base_class)

View file

@ -299,7 +299,6 @@ def add_ordered_sublayer(layer, contribution_path, layer_id, order=None,
sdf format args metadata if enabled)
"""
# Add the order with the contribution path so that for future
# contributions we can again use it to magically fit into the
# ordering. We put this in the path because sublayer paths do
@ -317,20 +316,25 @@ def add_ordered_sublayer(layer, contribution_path, layer_id, order=None,
# If the layer was already in the layers, then replace it
for index, existing_path in enumerate(layer.subLayerPaths):
args = get_sdf_format_args(existing_path)
existing_layer = args.get("layer_id")
if existing_layer == layer_id:
existing_layer_id = args.get("layer_id")
if existing_layer_id == layer_id:
existing_layer = layer.subLayerPaths[index]
existing_order = args.get("order")
existing_order = int(existing_order) if existing_order else None
if order is not None and order != existing_order:
# We need to move the layer, so we will remove this index
# and then re-insert it below at the right order
log.debug(f"Removing existing layer: {existing_layer}")
del layer.subLayerPaths[index]
break
# Put it in the same position where it was before when swapping
# it with the original, also take over its order metadata
order = args.get("order")
if order is not None:
order = int(order)
else:
order = None
contribution_path = _format_path(contribution_path,
order=order,
order=existing_order,
layer_id=layer_id)
log.debug(
f"Replacing existing layer: {layer.subLayerPaths[index]} "
f"Replacing existing layer: {existing_layer} "
f"-> {contribution_path}"
)
layer.subLayerPaths[index] = contribution_path

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

@ -169,7 +169,9 @@ class ExtractReview(pyblish.api.InstancePlugin):
settings_category = "core"
# Supported extensions
image_exts = {"exr", "jpg", "jpeg", "png", "dpx", "tga", "tiff", "tif"}
image_exts = {
"exr", "jpg", "jpeg", "png", "dpx", "tga", "tiff", "tif", "psd"
}
video_exts = {"mov", "mp4"}
supported_exts = image_exts | video_exts

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"""
@ -48,35 +105,11 @@ class ExtractThumbnail(pyblish.api.InstancePlugin):
"unreal",
"houdini",
"batchdelivery",
"webpublisher",
]
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 +132,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 +171,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 +215,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 +229,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 +267,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 +301,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 +423,7 @@ class ExtractThumbnail(pyblish.api.InstancePlugin):
src_path,
dst_path,
colorspace_data,
thumbnail_def
):
"""Create thumbnail using OIIO tool oiiotool
@ -416,7 +441,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 +462,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 +495,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 +545,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 +557,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 +597,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 +627,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 +701,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 +721,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

@ -14,6 +14,7 @@ Todos:
import os
import tempfile
from typing import List, Optional
import pyblish.api
from ayon_core.lib import (
@ -22,6 +23,7 @@ from ayon_core.lib import (
is_oiio_supported,
run_subprocess,
get_rescaled_command_arguments,
)
@ -31,17 +33,20 @@ class ExtractThumbnailFromSource(pyblish.api.InstancePlugin):
Thumbnail source must be a single image or video filepath.
"""
label = "Extract Thumbnail (from source)"
label = "Extract Thumbnail from source"
# Before 'ExtractThumbnail' in global plugins
order = pyblish.api.ExtractorOrder - 0.00001
def process(self, instance):
# Settings
target_size = {
"type": "resize",
"resize": {"width": 1920, "height": 1080}
}
background_color = (0, 0, 0, 0.0)
def process(self, instance: pyblish.api.Instance):
self._create_context_thumbnail(instance.context)
product_name = instance.data["productName"]
self.log.debug(
"Processing instance with product name {}".format(product_name)
)
thumbnail_source = instance.data.get("thumbnailSource")
if not thumbnail_source:
self.log.debug("Thumbnail source not filled. Skipping.")
@ -69,6 +74,8 @@ class ExtractThumbnailFromSource(pyblish.api.InstancePlugin):
"outputName": "thumbnail",
}
new_repre["tags"].append("delete")
# adding representation
self.log.debug(
"Adding thumbnail representation: {}".format(new_repre)
@ -76,7 +83,11 @@ class ExtractThumbnailFromSource(pyblish.api.InstancePlugin):
instance.data["representations"].append(new_repre)
instance.data["thumbnailPath"] = dst_filepath
def _create_thumbnail(self, context, thumbnail_source):
def _create_thumbnail(
self,
context: pyblish.api.Context,
thumbnail_source: str,
) -> Optional[str]:
if not thumbnail_source:
self.log.debug("Thumbnail source not filled. Skipping.")
return
@ -131,7 +142,7 @@ class ExtractThumbnailFromSource(pyblish.api.InstancePlugin):
self.log.warning("Thumbnail has not been created.")
def _instance_has_thumbnail(self, instance):
def _instance_has_thumbnail(self, instance: pyblish.api.Instance) -> bool:
if "representations" not in instance.data:
self.log.warning(
"Instance does not have 'representations' key filled"
@ -143,14 +154,24 @@ class ExtractThumbnailFromSource(pyblish.api.InstancePlugin):
return True
return False
def create_thumbnail_oiio(self, src_path, dst_path):
def create_thumbnail_oiio(
self,
src_path: str,
dst_path: str,
) -> bool:
self.log.debug("Outputting thumbnail with OIIO: {}".format(dst_path))
oiio_cmd = get_oiio_tool_args(
"oiiotool",
"-a", src_path,
"--ch", "R,G,B",
"-o", dst_path
resolution_args = self._get_resolution_args(
"oiiotool", src_path
)
oiio_cmd = get_oiio_tool_args("oiiotool", "-a", src_path)
if resolution_args:
# resize must be before -o
oiio_cmd.extend(resolution_args)
else:
# resize provides own -ch, must be only one
oiio_cmd.extend(["--ch", "R,G,B"])
oiio_cmd.extend(["-o", dst_path])
self.log.debug("Running: {}".format(" ".join(oiio_cmd)))
try:
run_subprocess(oiio_cmd, logger=self.log)
@ -162,7 +183,15 @@ class ExtractThumbnailFromSource(pyblish.api.InstancePlugin):
)
return False
def create_thumbnail_ffmpeg(self, src_path, dst_path):
def create_thumbnail_ffmpeg(
self,
src_path: str,
dst_path: str,
) -> bool:
resolution_args = self._get_resolution_args(
"ffmpeg", src_path
)
max_int = str(2147483647)
ffmpeg_cmd = get_ffmpeg_tool_args(
"ffmpeg",
@ -171,9 +200,13 @@ class ExtractThumbnailFromSource(pyblish.api.InstancePlugin):
"-probesize", max_int,
"-i", src_path,
"-frames:v", "1",
dst_path
)
ffmpeg_cmd.extend(resolution_args)
# possible resize must be before output args
ffmpeg_cmd.append(dst_path)
self.log.debug("Running: {}".format(" ".join(ffmpeg_cmd)))
try:
run_subprocess(ffmpeg_cmd, logger=self.log)
@ -185,10 +218,37 @@ class ExtractThumbnailFromSource(pyblish.api.InstancePlugin):
)
return False
def _create_context_thumbnail(self, context):
def _create_context_thumbnail(
self,
context: pyblish.api.Context,
):
if "thumbnailPath" in context.data:
return
thumbnail_source = context.data.get("thumbnailSource")
thumbnail_path = self._create_thumbnail(context, thumbnail_source)
context.data["thumbnailPath"] = thumbnail_path
context.data["thumbnailPath"] = self._create_thumbnail(
context, thumbnail_source
)
def _get_resolution_args(
self,
application: str,
input_path: str,
) -> List[str]:
# get settings
if self.target_size["type"] == "source":
return []
resize = self.target_size["resize"]
target_width = resize["width"]
target_height = resize["height"]
# form arg string per application
return get_rescaled_command_arguments(
application,
input_path,
target_width,
target_height,
bg_color=self.background_color,
log=self.log,
)

View file

@ -16,7 +16,7 @@ from ayon_core.lib import (
UISeparatorDef,
UILabelDef,
EnumDef,
filter_profiles
filter_profiles, NumberDef
)
try:
from ayon_core.pipeline.usdlib import (
@ -275,7 +275,7 @@ class CollectUSDLayerContributions(pyblish.api.InstancePlugin,
# the contributions so that we can design a system where custom
# contributions outside the predefined orders are possible to be
# managed. So that if a particular asset requires an extra contribution
# level, you can add itdirectly from the publisher at that particular
# level, you can add it directly from the publisher at that particular
# order. Future publishes will then see the existing contribution and will
# persist adding it to future bootstraps at that order
contribution_layers: Dict[str, int] = {
@ -334,10 +334,7 @@ class CollectUSDLayerContributions(pyblish.api.InstancePlugin,
attr_values[key] = attr_values[key].format(**data)
# Define contribution
order = self.contribution_layers.get(
attr_values["contribution_layer"], 0
)
in_layer_order: int = attr_values.get("contribution_in_layer_order", 0)
if attr_values["contribution_apply_as_variant"]:
contribution = VariantContribution(
instance=instance,
@ -346,18 +343,21 @@ class CollectUSDLayerContributions(pyblish.api.InstancePlugin,
variant_set_name=attr_values["contribution_variant_set_name"],
variant_name=attr_values["contribution_variant"],
variant_is_default=attr_values["contribution_variant_is_default"], # noqa: E501
order=order
order=in_layer_order
)
else:
contribution = SublayerContribution(
instance=instance,
layer_id=attr_values["contribution_layer"],
target_product=attr_values["contribution_target_product"],
order=order
order=in_layer_order
)
asset_product = contribution.target_product
layer_product = "{}_{}".format(asset_product, contribution.layer_id)
layer_order: int = self.contribution_layers.get(
attr_values["contribution_layer"], 0
)
# Layer contribution instance
layer_instance = self.get_or_create_instance(
@ -370,7 +370,7 @@ class CollectUSDLayerContributions(pyblish.api.InstancePlugin,
contribution
)
layer_instance.data["usd_layer_id"] = contribution.layer_id
layer_instance.data["usd_layer_order"] = contribution.order
layer_instance.data["usd_layer_order"] = layer_order
layer_instance.data["productGroup"] = (
instance.data.get("productGroup") or "USD Layer"
@ -561,6 +561,19 @@ class CollectUSDLayerContributions(pyblish.api.InstancePlugin,
items=list(cls.contribution_layers.keys()),
default=default_contribution_layer,
visible=visible),
# TODO: We may want to make the visibility of this optional
# based on studio preference, to avoid complexity when not needed
NumberDef("contribution_in_layer_order",
label="Strength order",
tooltip=(
"The contribution inside the department layer will be "
"made with this offset applied. A higher number means "
"a stronger opinion."
),
default=0,
minimum=-99999,
maximum=99999,
visible=visible),
BoolDef("contribution_apply_as_variant",
label="Add as variant",
tooltip=(
@ -729,7 +742,7 @@ class ExtractUSDLayerContribution(publish.Extractor):
layer=sdf_layer,
contribution_path=path,
layer_id=product_name,
order=None, # unordered
order=contribution.order,
add_sdf_arguments_metadata=True
)
else:

View file

@ -1,6 +1,8 @@
from __future__ import annotations
from abc import ABC, abstractmethod
from dataclasses import dataclass, field
from typing import List, Dict, Optional
from typing import Optional
@dataclass
@ -13,8 +15,8 @@ class TabItem:
class InterpreterConfig:
width: Optional[int]
height: Optional[int]
splitter_sizes: List[int] = field(default_factory=list)
tabs: List[TabItem] = field(default_factory=list)
splitter_sizes: list[int] = field(default_factory=list)
tabs: list[TabItem] = field(default_factory=list)
class AbstractInterpreterController(ABC):
@ -27,7 +29,7 @@ class AbstractInterpreterController(ABC):
self,
width: int,
height: int,
splitter_sizes: List[int],
tabs: List[Dict[str, str]],
):
splitter_sizes: list[int],
tabs: list[dict[str, str]],
) -> None:
pass

View file

@ -1,4 +1,5 @@
from typing import List, Dict
from __future__ import annotations
from typing import Optional
from ayon_core.lib import JSONSettingRegistry
from ayon_core.lib.local_settings import get_launcher_local_dir
@ -11,13 +12,15 @@ from .abstract import (
class InterpreterController(AbstractInterpreterController):
def __init__(self):
def __init__(self, name: Optional[str] = None) -> None:
if name is None:
name = "python_interpreter_tool"
self._registry = JSONSettingRegistry(
"python_interpreter_tool",
name,
get_launcher_local_dir(),
)
def get_config(self):
def get_config(self) -> InterpreterConfig:
width = None
height = None
splitter_sizes = []
@ -54,9 +57,9 @@ class InterpreterController(AbstractInterpreterController):
self,
width: int,
height: int,
splitter_sizes: List[int],
tabs: List[Dict[str, str]],
):
splitter_sizes: list[int],
tabs: list[dict[str, str]],
) -> None:
self._registry.set_item("width", width)
self._registry.set_item("height", height)
self._registry.set_item("splitter_sizes", splitter_sizes)

View file

@ -1,42 +1,42 @@
import os
import sys
import collections
class _CustomSTD:
def __init__(self, orig_std, write_callback):
self.orig_std = orig_std
self._valid_orig = bool(orig_std)
self._write_callback = write_callback
def __getattr__(self, attr):
return getattr(self.orig_std, attr)
def __setattr__(self, key, value):
if key in ("orig_std", "_valid_orig", "_write_callback"):
super().__setattr__(key, value)
else:
setattr(self.orig_std, key, value)
def write(self, text):
if self._valid_orig:
self.orig_std.write(text)
self._write_callback(text)
class StdOEWrap:
def __init__(self):
self._origin_stdout_write = None
self._origin_stderr_write = None
self._listening = False
self.lines = collections.deque()
if not sys.stdout:
sys.stdout = open(os.devnull, "w")
if not sys.stderr:
sys.stderr = open(os.devnull, "w")
if self._origin_stdout_write is None:
self._origin_stdout_write = sys.stdout.write
if self._origin_stderr_write is None:
self._origin_stderr_write = sys.stderr.write
self._listening = True
sys.stdout.write = self._stdout_listener
sys.stderr.write = self._stderr_listener
self._stdout_wrap = _CustomSTD(sys.stdout, self._listener)
self._stderr_wrap = _CustomSTD(sys.stderr, self._listener)
sys.stdout = self._stdout_wrap
sys.stderr = self._stderr_wrap
def stop_listen(self):
self._listening = False
def _stdout_listener(self, text):
def _listener(self, text):
if self._listening:
self.lines.append(text)
if self._origin_stdout_write is not None:
self._origin_stdout_write(text)
def _stderr_listener(self, text):
if self._listening:
self.lines.append(text)
if self._origin_stderr_write is not None:
self._origin_stderr_write(text)

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

@ -422,24 +422,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,
@ -456,6 +462,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"},
@ -491,6 +506,18 @@ class UseDisplayViewModel(BaseSettingsModel):
)
class ExtractThumbnailFromSourceModel(BaseSettingsModel):
"""Thumbnail extraction from source files using ffmpeg and oiiotool."""
enabled: bool = SettingsField(True)
target_size: ResizeModel = SettingsField(
default_factory=ResizeModel, title="Target size"
)
background_color: ColorRGBA_uint8 = SettingsField(
(0, 0, 0, 0.0), title="Background color"
)
class ExtractOIIOTranscodeOutputModel(BaseSettingsModel):
_layout = "expanded"
name: str = SettingsField(
@ -1271,6 +1298,16 @@ class PublishPuginsModel(BaseSettingsModel):
default_factory=ExtractThumbnailModel,
title="Extract Thumbnail"
)
ExtractThumbnailFromSource: ExtractThumbnailFromSourceModel = SettingsField( # noqa: E501
default_factory=ExtractThumbnailFromSourceModel,
title="Extract Thumbnail from source",
description=(
"Extract thumbnails from explicit file set in "
"instance.data['thumbnailSource'] using oiiotool"
" or ffmpeg."
"Used when artist provided thumbnail source."
)
)
ExtractOIIOTranscode: ExtractOIIOTranscodeModel = SettingsField(
default_factory=ExtractOIIOTranscodeModel,
title="Extract OIIO Transcode"
@ -1489,6 +1526,12 @@ DEFAULT_PUBLISH_VALUES = {
},
"ExtractThumbnail": {
"enabled": True,
"profiles": [
{
"product_types": [],
"host_names": [],
"task_types": [],
"task_names": [],
"product_names": [],
"integrate_thumbnail": True,
"target_size": {
@ -1505,6 +1548,18 @@ DEFAULT_PUBLISH_VALUES = {
],
"output": []
}
}
]
},
"ExtractThumbnailFromSource": {
"enabled": True,
"target_size": {
"type": "resize",
"resize": {
"width": 300,
"height": 170
}
},
},
"ExtractOIIOTranscode": {
"enabled": True,