Merge branch 'develop' into bugfix/yn-0118-editorial-publishing-with-no-audio-product

This commit is contained in:
Jakub Ježek 2025-11-06 12:33:38 +01:00 committed by GitHub
commit 3f49ad6791
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 331 additions and 77 deletions

View file

@ -604,7 +604,11 @@ class EnumDef(AbstractAttrDef):
if value is None: if value is None:
return copy.deepcopy(self.default) return copy.deepcopy(self.default)
return list(self._item_values.intersection(value)) return [
v
for v in value
if v in self._item_values
]
def is_value_valid(self, value: Any) -> bool: def is_value_valid(self, value: Any) -> bool:
"""Check if item is available in possible values.""" """Check if item is available in possible values."""

View file

@ -110,6 +110,15 @@ def deprecated(new_destination):
return _decorator(func) return _decorator(func)
class MissingRGBAChannelsError(ValueError):
"""Raised when we can't find channels to use as RGBA for conversion in
input media.
This may be other channels than solely RGBA, like Z-channel. The error is
raised when no matching 'reviewable' channel was found.
"""
def get_transcode_temp_directory(): def get_transcode_temp_directory():
"""Creates temporary folder for transcoding. """Creates temporary folder for transcoding.
@ -388,6 +397,10 @@ def get_review_info_by_layer_name(channel_names):
... ...
] ]
This tries to find suitable outputs good for review purposes, by
searching for channel names like RGBA, but also XYZ, Z, N, AR, AG, AB
channels.
Args: Args:
channel_names (list[str]): List of channel names. channel_names (list[str]): List of channel names.
@ -396,7 +409,6 @@ def get_review_info_by_layer_name(channel_names):
""" """
layer_names_order = [] layer_names_order = []
rgba_by_layer_name = collections.defaultdict(dict)
channels_by_layer_name = collections.defaultdict(dict) channels_by_layer_name = collections.defaultdict(dict)
for channel_name in channel_names: for channel_name in channel_names:
@ -405,45 +417,95 @@ def get_review_info_by_layer_name(channel_names):
if "." in channel_name: if "." in channel_name:
layer_name, last_part = channel_name.rsplit(".", 1) layer_name, last_part = channel_name.rsplit(".", 1)
channels_by_layer_name[layer_name][channel_name] = last_part # R, G, B, A or X, Y, Z, N, AR, AG, AB, RED, GREEN, BLUE, ALPHA
if last_part.lower() not in { channel = last_part.upper()
"r", "red", if channel not in {
"g", "green", # Detect RGBA channels
"b", "blue", "R", "G", "B", "A",
"a", "alpha" # Support fully written out rgba channel names
"RED", "GREEN", "BLUE", "ALPHA",
# Allow detecting of x, y and z channels, and normal channels
"X", "Y", "Z", "N",
# red, green and blue alpha/opacity, for colored mattes
"AR", "AG", "AB"
}: }:
continue continue
if layer_name not in layer_names_order: if layer_name not in layer_names_order:
layer_names_order.append(layer_name) layer_names_order.append(layer_name)
# R, G, B or A
channel = last_part[0].upper() channels_by_layer_name[layer_name][channel] = channel_name
rgba_by_layer_name[layer_name][channel] = channel_name
# Put empty layer or 'rgba' to the beginning of the list # Put empty layer or 'rgba' to the beginning of the list
# - if input has R, G, B, A channels they should be used for review # - if input has R, G, B, A channels they should be used for review
# NOTE They are iterated in reversed order because they're inserted to def _sort(_layer_name: str) -> int:
# the beginning of 'layer_names_order' -> last added will be first. # Prioritize "" layer name
for name in reversed(["", "rgba"]): # Prioritize layers with RGB channels
if name in layer_names_order: if _layer_name == "rgba":
layer_names_order.remove(name) return 0
layer_names_order.insert(0, name)
if _layer_name == "":
return 1
channels = channels_by_layer_name[_layer_name]
if all(channel in channels for channel in "RGB"):
return 2
return 10
layer_names_order.sort(key=_sort)
output = [] output = []
for layer_name in layer_names_order: for layer_name in layer_names_order:
rgba_layer_info = rgba_by_layer_name[layer_name] channel_info = channels_by_layer_name[layer_name]
red = rgba_layer_info.get("R")
green = rgba_layer_info.get("G") alpha = channel_info.get("A")
blue = rgba_layer_info.get("B")
if not red or not green or not blue: # RGB channels
if all(channel in channel_info for channel in "RGB"):
rgb = "R", "G", "B"
# RGB channels using fully written out channel names
elif all(
channel in channel_info
for channel in ("RED", "GREEN", "BLUE")
):
rgb = "RED", "GREEN", "BLUE"
alpha = channel_info.get("ALPHA")
# XYZ channels (position pass)
elif all(channel in channel_info for channel in "XYZ"):
rgb = "X", "Y", "Z"
# Colored mattes (as defined in OpenEXR Channel Name standards)
elif all(channel in channel_info for channel in ("AR", "AG", "AB")):
rgb = "AR", "AG", "AB"
# Luminance channel (as defined in OpenEXR Channel Name standards)
elif "Y" in channel_info:
rgb = "Y", "Y", "Y"
# Has only Z channel (Z-depth layer)
elif "Z" in channel_info:
rgb = "Z", "Z", "Z"
# Has only A channel (Alpha layer)
elif "A" in channel_info:
rgb = "A", "A", "A"
alpha = None
else:
# No reviewable channels found
continue continue
red = channel_info[rgb[0]]
green = channel_info[rgb[1]]
blue = channel_info[rgb[2]]
output.append({ output.append({
"name": layer_name, "name": layer_name,
"review_channels": { "review_channels": {
"R": red, "R": red,
"G": green, "G": green,
"B": blue, "B": blue,
"A": rgba_layer_info.get("A"), "A": alpha,
} }
}) })
return output return output
@ -1467,8 +1529,9 @@ def get_oiio_input_and_channel_args(oiio_input_info, alpha_default=None):
review_channels = get_convert_rgb_channels(channel_names) review_channels = get_convert_rgb_channels(channel_names)
if review_channels is None: if review_channels is None:
raise ValueError( raise MissingRGBAChannelsError(
"Couldn't find channels that can be used for conversion." "Couldn't find channels that can be used for conversion "
f"among channels: {channel_names}."
) )
red, green, blue, alpha = review_channels red, green, blue, alpha = review_channels

View file

@ -137,6 +137,7 @@ class AttributeValues:
if value is None: if value is None:
continue continue
converted_value = attr_def.convert_value(value) converted_value = attr_def.convert_value(value)
# QUESTION Could we just use converted value all the time?
if converted_value == value: if converted_value == value:
self._data[attr_def.key] = value self._data[attr_def.key] = value
@ -245,11 +246,11 @@ class AttributeValues:
def _update(self, value): def _update(self, value):
changes = {} changes = {}
for key, value in dict(value).items(): for key, key_value in dict(value).items():
if key in self._data and self._data.get(key) == value: if key in self._data and self._data.get(key) == key_value:
continue continue
self._data[key] = value self._data[key] = key_value
changes[key] = value changes[key] = key_value
return changes return changes
def _pop(self, key, default): def _pop(self, key, default):

View file

@ -11,6 +11,7 @@ from ayon_core.lib import (
is_oiio_supported, is_oiio_supported,
) )
from ayon_core.lib.transcoding import ( from ayon_core.lib.transcoding import (
MissingRGBAChannelsError,
oiio_color_convert, oiio_color_convert,
) )
@ -111,7 +112,17 @@ class ExtractOIIOTranscode(publish.Extractor):
self.log.warning("Config file doesn't exist, skipping") self.log.warning("Config file doesn't exist, skipping")
continue continue
# Get representation files to convert
if isinstance(repre["files"], list):
repre_files_to_convert = copy.deepcopy(repre["files"])
else:
repre_files_to_convert = [repre["files"]]
# Process each output definition
for output_def in profile_output_defs: for output_def in profile_output_defs:
# Local copy to avoid accidental mutable changes
files_to_convert = list(repre_files_to_convert)
output_name = output_def["name"] output_name = output_def["name"]
new_repre = copy.deepcopy(repre) new_repre = copy.deepcopy(repre)
@ -122,11 +133,6 @@ class ExtractOIIOTranscode(publish.Extractor):
) )
new_repre["stagingDir"] = new_staging_dir new_repre["stagingDir"] = new_staging_dir
if isinstance(new_repre["files"], list):
files_to_convert = copy.deepcopy(new_repre["files"])
else:
files_to_convert = [new_repre["files"]]
output_extension = output_def["extension"] output_extension = output_def["extension"]
output_extension = output_extension.replace('.', '') output_extension = output_extension.replace('.', '')
self._rename_in_representation(new_repre, self._rename_in_representation(new_repre,
@ -168,30 +174,49 @@ class ExtractOIIOTranscode(publish.Extractor):
additional_command_args = (output_def["oiiotool_args"] additional_command_args = (output_def["oiiotool_args"]
["additional_command_args"]) ["additional_command_args"])
files_to_convert = self._translate_to_sequence( sequence_files = self._translate_to_sequence(files_to_convert)
files_to_convert) self.log.debug("Files to convert: {}".format(sequence_files))
self.log.debug("Files to convert: {}".format(files_to_convert)) missing_rgba_review_channels = False
for file_name in files_to_convert: for file_name in sequence_files:
if isinstance(file_name, clique.Collection):
# Convert to filepath that can be directly converted
# by oiio like `frame.1001-1025%04d.exr`
file_name: str = file_name.format(
"{head}{range}{padding}{tail}"
)
self.log.debug("Transcoding file: `{}`".format(file_name)) self.log.debug("Transcoding file: `{}`".format(file_name))
input_path = os.path.join(original_staging_dir, input_path = os.path.join(original_staging_dir,
file_name) file_name)
output_path = self._get_output_file_path(input_path, output_path = self._get_output_file_path(input_path,
new_staging_dir, new_staging_dir,
output_extension) output_extension)
try:
oiio_color_convert(
input_path=input_path,
output_path=output_path,
config_path=config_path,
source_colorspace=source_colorspace,
target_colorspace=target_colorspace,
target_display=target_display,
target_view=target_view,
source_display=source_display,
source_view=source_view,
additional_command_args=additional_command_args,
logger=self.log
)
except MissingRGBAChannelsError as exc:
missing_rgba_review_channels = True
self.log.error(exc)
self.log.error(
"Skipping OIIO Transcode. Unknown RGBA channels"
f" for colorspace conversion in file: {input_path}"
)
break
oiio_color_convert( if missing_rgba_review_channels:
input_path=input_path, # Stop processing this representation
output_path=output_path, break
config_path=config_path,
source_colorspace=source_colorspace,
target_colorspace=target_colorspace,
target_display=target_display,
target_view=target_view,
source_display=source_display,
source_view=source_view,
additional_command_args=additional_command_args,
logger=self.log
)
# cleanup temporary transcoded files # cleanup temporary transcoded files
for file_name in new_repre["files"]: for file_name in new_repre["files"]:
@ -217,11 +242,11 @@ class ExtractOIIOTranscode(publish.Extractor):
added_review = True added_review = True
# If there is only 1 file outputted then convert list to # If there is only 1 file outputted then convert list to
# string, cause that'll indicate that its not a sequence. # string, because that'll indicate that it is not a sequence.
if len(new_repre["files"]) == 1: if len(new_repre["files"]) == 1:
new_repre["files"] = new_repre["files"][0] new_repre["files"] = new_repre["files"][0]
# If the source representation has "review" tag, but its not # If the source representation has "review" tag, but it's not
# part of the output definition tags, then both the # part of the output definition tags, then both the
# representations will be transcoded in ExtractReview and # representations will be transcoded in ExtractReview and
# their outputs will clash in integration. # their outputs will clash in integration.
@ -271,42 +296,34 @@ class ExtractOIIOTranscode(publish.Extractor):
new_repre["files"] = renamed_files new_repre["files"] = renamed_files
def _translate_to_sequence(self, files_to_convert): def _translate_to_sequence(self, files_to_convert):
"""Returns original list or list with filename formatted in single """Returns original list or a clique.Collection of a sequence.
sequence format.
Uses clique to find frame sequence, in this case it merges all frames Uses clique to find frame sequence Collection.
into sequence format (FRAMESTART-FRAMEEND#) and returns it. If sequence not found, it returns original list.
If sequence not found, it returns original list
Args: Args:
files_to_convert (list): list of file names files_to_convert (list): list of file names
Returns: Returns:
(list) of [file.1001-1010#.exr] or [fileA.exr, fileB.exr] list[str | clique.Collection]: List of filepaths or a list
of Collections (usually one, unless there are holes)
""" """
pattern = [clique.PATTERNS["frames"]] pattern = [clique.PATTERNS["frames"]]
collections, _ = clique.assemble( collections, _ = clique.assemble(
files_to_convert, patterns=pattern, files_to_convert, patterns=pattern,
assume_padded_when_ambiguous=True) assume_padded_when_ambiguous=True)
if collections: if collections:
if len(collections) > 1: if len(collections) > 1:
raise ValueError( raise ValueError(
"Too many collections {}".format(collections)) "Too many collections {}".format(collections))
collection = collections[0] collection = collections[0]
frames = list(collection.indexes) # TODO: Technically oiiotool supports holes in the sequence as well
if collection.holes().indexes: # using the dedicated --frames argument to specify the frames.
return files_to_convert # We may want to use that too so conversions of sequences with
# holes will perform faster as well.
# Get the padding from the collection # Separate the collection so that we have no holes/gaps per
# This is the number of digits used in the frame numbers # collection.
padding = collection.padding return collection.separate()
frame_str = "{}-{}%0{}d".format(frames[0], frames[-1], padding)
file_name = "{}{}{}".format(collection.head, frame_str,
collection.tail)
files_to_convert = [file_name]
return files_to_convert return files_to_convert

View file

@ -361,14 +361,14 @@ class ExtractReview(pyblish.api.InstancePlugin):
if not filtered_output_defs: if not filtered_output_defs:
self.log.debug(( self.log.debug((
"Repre: {} - All output definitions were filtered" "Repre: {} - All output definitions were filtered"
" out by single frame filter. Skipping" " out by single frame filter. Skipped."
).format(repre["name"])) ).format(repre["name"]))
continue continue
# Skip if file is not set # Skip if file is not set
if first_input_path is None: if first_input_path is None:
self.log.warning(( self.log.warning((
"Representation \"{}\" have empty files. Skipped." "Representation \"{}\" has empty files. Skipped."
).format(repre["name"])) ).format(repre["name"]))
continue continue

View file

@ -17,6 +17,7 @@ from ayon_core.lib import (
run_subprocess, run_subprocess,
) )
from ayon_core.lib.transcoding import ( from ayon_core.lib.transcoding import (
MissingRGBAChannelsError,
oiio_color_convert, oiio_color_convert,
get_oiio_input_and_channel_args, get_oiio_input_and_channel_args,
get_oiio_info_for_input, get_oiio_info_for_input,
@ -477,7 +478,16 @@ class ExtractThumbnail(pyblish.api.InstancePlugin):
return False 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)
input_arg, channels_arg = get_oiio_input_and_channel_args(input_info) try:
input_arg, channels_arg = get_oiio_input_and_channel_args(
input_info
)
except MissingRGBAChannelsError:
self.log.debug(
"Unable to find relevant reviewable channel for thumbnail "
"creation"
)
return False
oiio_cmd = get_oiio_tool_args( oiio_cmd = get_oiio_tool_args(
"oiiotool", "oiiotool",
input_arg, src_path, input_arg, src_path,

View file

@ -1,5 +1,6 @@
import logging import logging
import re import re
import copy
from typing import ( from typing import (
Union, Union,
List, List,
@ -1098,7 +1099,7 @@ class CreateModel:
creator_attributes[key] = attr_def.default creator_attributes[key] = attr_def.default
elif attr_def.is_value_valid(value): elif attr_def.is_value_valid(value):
creator_attributes[key] = value creator_attributes[key] = copy.deepcopy(value)
def _set_instances_publish_attr_values( def _set_instances_publish_attr_values(
self, instance_ids, plugin_name, key, value self, instance_ids, plugin_name, key, value

View file

@ -0,0 +1,158 @@
import unittest
from ayon_core.lib.transcoding import (
get_review_info_by_layer_name
)
class GetReviewInfoByLayerName(unittest.TestCase):
"""Test responses from `get_review_info_by_layer_name`"""
def test_rgba_channels(self):
# RGB is supported
info = get_review_info_by_layer_name(["R", "G", "B"])
self.assertEqual(info, [{
"name": "",
"review_channels": {
"R": "R",
"G": "G",
"B": "B",
"A": None,
}
}])
# rgb is supported
info = get_review_info_by_layer_name(["r", "g", "b"])
self.assertEqual(info, [{
"name": "",
"review_channels": {
"R": "r",
"G": "g",
"B": "b",
"A": None,
}
}])
# diffuse.[RGB] is supported
info = get_review_info_by_layer_name(
["diffuse.R", "diffuse.G", "diffuse.B"]
)
self.assertEqual(info, [{
"name": "diffuse",
"review_channels": {
"R": "diffuse.R",
"G": "diffuse.G",
"B": "diffuse.B",
"A": None,
}
}])
info = get_review_info_by_layer_name(["R", "G", "B", "A"])
self.assertEqual(info, [{
"name": "",
"review_channels": {
"R": "R",
"G": "G",
"B": "B",
"A": "A",
}
}])
def test_z_channel(self):
info = get_review_info_by_layer_name(["Z"])
self.assertEqual(info, [{
"name": "",
"review_channels": {
"R": "Z",
"G": "Z",
"B": "Z",
"A": None,
}
}])
info = get_review_info_by_layer_name(["Z", "A"])
self.assertEqual(info, [{
"name": "",
"review_channels": {
"R": "Z",
"G": "Z",
"B": "Z",
"A": "A",
}
}])
def test_ar_ag_ab_channels(self):
info = get_review_info_by_layer_name(["AR", "AG", "AB"])
self.assertEqual(info, [{
"name": "",
"review_channels": {
"R": "AR",
"G": "AG",
"B": "AB",
"A": None,
}
}])
info = get_review_info_by_layer_name(["AR", "AG", "AB", "A"])
self.assertEqual(info, [{
"name": "",
"review_channels": {
"R": "AR",
"G": "AG",
"B": "AB",
"A": "A",
}
}])
def test_unknown_channels(self):
info = get_review_info_by_layer_name(["hello", "world"])
self.assertEqual(info, [])
def test_rgba_priority(self):
"""Ensure main layer, and RGB channels are prioritized
If both Z and RGB channels are present for a layer name, then RGB
should be prioritized and the Z channel should be ignored.
Also, the alpha channel from another "layer name" is not used. Note
how the diffuse response does not take A channel from the main layer.
"""
info = get_review_info_by_layer_name([
"Z",
"diffuse.R", "diffuse.G", "diffuse.B",
"R", "G", "B", "A",
"specular.R", "specular.G", "specular.B", "specular.A",
])
self.assertEqual(info, [
{
"name": "",
"review_channels": {
"R": "R",
"G": "G",
"B": "B",
"A": "A",
},
},
{
"name": "diffuse",
"review_channels": {
"R": "diffuse.R",
"G": "diffuse.G",
"B": "diffuse.B",
"A": None,
},
},
{
"name": "specular",
"review_channels": {
"R": "specular.R",
"G": "specular.G",
"B": "specular.B",
"A": "specular.A",
},
},
])