From 862049d995087d163ac02cb2c2538dcc53dffe38 Mon Sep 17 00:00:00 2001 From: timsergeeff <38128238+timsergeeff@users.noreply.github.com> Date: Fri, 10 Oct 2025 13:00:09 +0300 Subject: [PATCH 01/35] Refactor color conversion logic in transcoding.py --- client/ayon_core/lib/transcoding.py | 22 ++++++++++++++++++---- 1 file changed, 18 insertions(+), 4 deletions(-) diff --git a/client/ayon_core/lib/transcoding.py b/client/ayon_core/lib/transcoding.py index 127bd3bac4..12a64c7e06 100644 --- a/client/ayon_core/lib/transcoding.py +++ b/client/ayon_core/lib/transcoding.py @@ -1170,6 +1170,14 @@ def oiio_color_convert( # Handle the different conversion cases # Source view and display are known if source_view and source_display: + color_convert_args = None + ocio_display_args = None + oiio_cmd.extend([ + "--ociodisplay:inverse=1:subimages=0", + source_display, + source_view + ]) + if target_colorspace: # This is a two-step conversion process since there's no direct # display/view to colorspace command @@ -1179,22 +1187,28 @@ def oiio_color_convert( elif source_display != target_display or source_view != target_view: # Complete display/view pair conversion # - go through a reference space - color_convert_args = (target_display, target_view) + ocio_display_args = (target_display, target_view) else: color_convert_args = None + ocio_display_args = None logger.debug( "Source and target display/view pairs are identical." " No color conversion needed." ) + if color_convert_args: + # Use colorconvert for colorspace target oiio_cmd.extend([ - "--ociodisplay:inverse=1:subimages=0", - source_display, - source_view, "--colorconvert:subimages=0", *color_convert_args ]) + elif ocio_display_args: + # Use ociodisplay for display/view target + oiio_cmd.extend([ + "--ociodisplay:subimages=0", + *ocio_display_args + ]) elif target_colorspace: # Standard color space to color space conversion From 0db3f67eb3bdb04b84b8818d52971f251af1cc8e Mon Sep 17 00:00:00 2001 From: timsergeeff <38128238+timsergeeff@users.noreply.github.com> Date: Fri, 10 Oct 2025 15:06:51 +0300 Subject: [PATCH 02/35] Remove unnecessary blank lines in transcoding.py --- client/ayon_core/lib/transcoding.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/client/ayon_core/lib/transcoding.py b/client/ayon_core/lib/transcoding.py index 12a64c7e06..9216c88ed2 100644 --- a/client/ayon_core/lib/transcoding.py +++ b/client/ayon_core/lib/transcoding.py @@ -1196,7 +1196,6 @@ def oiio_color_convert( " No color conversion needed." ) - if color_convert_args: # Use colorconvert for colorspace target oiio_cmd.extend([ @@ -1232,7 +1231,6 @@ def oiio_color_convert( logger.debug("Conversion command: {}".format(" ".join(oiio_cmd))) run_subprocess(oiio_cmd, logger=logger) - def split_cmd_args(in_args): """Makes sure all entered arguments are separated in individual items. From 7ef330c3f4fb0e59af9d2a58a2470a2230bd23c2 Mon Sep 17 00:00:00 2001 From: timsergeeff <38128238+timsergeeff@users.noreply.github.com> Date: Fri, 10 Oct 2025 21:55:12 +0300 Subject: [PATCH 03/35] Update client/ayon_core/lib/transcoding.py Co-authored-by: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> --- client/ayon_core/lib/transcoding.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/client/ayon_core/lib/transcoding.py b/client/ayon_core/lib/transcoding.py index 9216c88ed2..fcf7fdece2 100644 --- a/client/ayon_core/lib/transcoding.py +++ b/client/ayon_core/lib/transcoding.py @@ -1173,10 +1173,10 @@ def oiio_color_convert( color_convert_args = None ocio_display_args = None oiio_cmd.extend([ - "--ociodisplay:inverse=1:subimages=0", - source_display, - source_view - ]) + "--ociodisplay:inverse=1:subimages=0", + source_display, + source_view, + ]) if target_colorspace: # This is a two-step conversion process since there's no direct From 2541f8909e6625e710e376e4dc4c10a21a7db082 Mon Sep 17 00:00:00 2001 From: timsergeeff <38128238+timsergeeff@users.noreply.github.com> Date: Fri, 10 Oct 2025 21:55:18 +0300 Subject: [PATCH 04/35] Update client/ayon_core/lib/transcoding.py Co-authored-by: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> --- client/ayon_core/lib/transcoding.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/client/ayon_core/lib/transcoding.py b/client/ayon_core/lib/transcoding.py index fcf7fdece2..70a8f26cf2 100644 --- a/client/ayon_core/lib/transcoding.py +++ b/client/ayon_core/lib/transcoding.py @@ -1189,8 +1189,6 @@ def oiio_color_convert( # - go through a reference space ocio_display_args = (target_display, target_view) else: - color_convert_args = None - ocio_display_args = None logger.debug( "Source and target display/view pairs are identical." " No color conversion needed." From aabd9f7f505ddd2972bf2fa9afd5d28318b36299 Mon Sep 17 00:00:00 2001 From: timsergeeff <38128238+timsergeeff@users.noreply.github.com> Date: Fri, 10 Oct 2025 21:55:23 +0300 Subject: [PATCH 05/35] Update client/ayon_core/lib/transcoding.py Co-authored-by: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> --- client/ayon_core/lib/transcoding.py | 1 + 1 file changed, 1 insertion(+) diff --git a/client/ayon_core/lib/transcoding.py b/client/ayon_core/lib/transcoding.py index 70a8f26cf2..37fcb59ab3 100644 --- a/client/ayon_core/lib/transcoding.py +++ b/client/ayon_core/lib/transcoding.py @@ -1229,6 +1229,7 @@ def oiio_color_convert( logger.debug("Conversion command: {}".format(" ".join(oiio_cmd))) run_subprocess(oiio_cmd, logger=logger) + def split_cmd_args(in_args): """Makes sure all entered arguments are separated in individual items. From 2fe89c4b4619d01b53f0bcdbc5ef2c20b5d05c5c Mon Sep 17 00:00:00 2001 From: Kayla Man Date: Mon, 3 Nov 2025 22:09:25 +0800 Subject: [PATCH 06/35] add substance painter as host and adjust some instance data so that it can be used to review for image product --- client/ayon_core/plugins/publish/extract_review.py | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/client/ayon_core/plugins/publish/extract_review.py b/client/ayon_core/plugins/publish/extract_review.py index 580aa27eef..665d031d5a 100644 --- a/client/ayon_core/plugins/publish/extract_review.py +++ b/client/ayon_core/plugins/publish/extract_review.py @@ -163,7 +163,8 @@ class ExtractReview(pyblish.api.InstancePlugin): "flame", "unreal", "batchdelivery", - "photoshop" + "photoshop", + "substancepainter", ] settings_category = "core" @@ -571,7 +572,7 @@ class ExtractReview(pyblish.api.InstancePlugin): # review output files "timecode": frame_to_timecode( frame=temp_data.frame_start_handle, - fps=float(instance.data["fps"]) + fps=float(instance.data.get("fps", 25.0)) ) }) @@ -664,8 +665,8 @@ class ExtractReview(pyblish.api.InstancePlugin): with values may be added. """ - frame_start = instance.data["frameStart"] - frame_end = instance.data["frameEnd"] + frame_start = instance.data.get("frameStart", 1) + frame_end = instance.data.get("frameEnd", 1) # Try to get handles from instance handle_start = instance.data.get("handleStart") @@ -725,7 +726,7 @@ class ExtractReview(pyblish.api.InstancePlugin): ext = os.path.splitext(repre["files"])[1].replace(".", "") return TempData( - fps=float(instance.data["fps"]), + fps=float(instance.data.get("fps", 25.0)), frame_start=frame_start, frame_end=frame_end, handle_start=handle_start, From cfed4afaaf7e22741b463fe77fb2eb3affaf7156 Mon Sep 17 00:00:00 2001 From: Kayla Man Date: Tue, 4 Nov 2025 15:30:33 +0800 Subject: [PATCH 07/35] use the frame range from context data if it cannot find one --- client/ayon_core/plugins/publish/extract_review.py | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/client/ayon_core/plugins/publish/extract_review.py b/client/ayon_core/plugins/publish/extract_review.py index 665d031d5a..e519a4a97d 100644 --- a/client/ayon_core/plugins/publish/extract_review.py +++ b/client/ayon_core/plugins/publish/extract_review.py @@ -572,7 +572,9 @@ class ExtractReview(pyblish.api.InstancePlugin): # review output files "timecode": frame_to_timecode( frame=temp_data.frame_start_handle, - fps=float(instance.data.get("fps", 25.0)) + fps=float(instance.data.get( + "fps", instance.context.data["fps"] + )) ) }) @@ -665,8 +667,12 @@ class ExtractReview(pyblish.api.InstancePlugin): with values may be added. """ - frame_start = instance.data.get("frameStart", 1) - frame_end = instance.data.get("frameEnd", 1) + frame_start = instance.data.get( + "frameStart", instance.context.data["frameStart"] + ) + frame_end = instance.data.get( + "frameEnd", instance.context.data["frameEnd"] + ) # Try to get handles from instance handle_start = instance.data.get("handleStart") @@ -726,7 +732,7 @@ class ExtractReview(pyblish.api.InstancePlugin): ext = os.path.splitext(repre["files"])[1].replace(".", "") return TempData( - fps=float(instance.data.get("fps", 25.0)), + fps=float(instance.data.get("fps", instance.context.data["fps"])), frame_start=frame_start, frame_end=frame_end, handle_start=handle_start, From 5ab274aa503cecc07a7289175b5c61f755c0aede Mon Sep 17 00:00:00 2001 From: Kayla Man Date: Tue, 4 Nov 2025 17:47:49 +0800 Subject: [PATCH 08/35] restore the instance data and adjust them into textureset collector in substance instead --- client/ayon_core/plugins/publish/extract_review.py | 14 ++++---------- 1 file changed, 4 insertions(+), 10 deletions(-) diff --git a/client/ayon_core/plugins/publish/extract_review.py b/client/ayon_core/plugins/publish/extract_review.py index e519a4a97d..3f2a0dcd3e 100644 --- a/client/ayon_core/plugins/publish/extract_review.py +++ b/client/ayon_core/plugins/publish/extract_review.py @@ -572,9 +572,7 @@ class ExtractReview(pyblish.api.InstancePlugin): # review output files "timecode": frame_to_timecode( frame=temp_data.frame_start_handle, - fps=float(instance.data.get( - "fps", instance.context.data["fps"] - )) + fps=float(instance.data["fps"]) ) }) @@ -667,12 +665,8 @@ class ExtractReview(pyblish.api.InstancePlugin): with values may be added. """ - frame_start = instance.data.get( - "frameStart", instance.context.data["frameStart"] - ) - frame_end = instance.data.get( - "frameEnd", instance.context.data["frameEnd"] - ) + frame_start = instance.data["frameStart"] + frame_end = instance.data["frameEnd"] # Try to get handles from instance handle_start = instance.data.get("handleStart") @@ -732,7 +726,7 @@ class ExtractReview(pyblish.api.InstancePlugin): ext = os.path.splitext(repre["files"])[1].replace(".", "") return TempData( - fps=float(instance.data.get("fps", instance.context.data["fps"])), + fps=float(instance.data["fps"]), frame_start=frame_start, frame_end=frame_end, handle_start=handle_start, From c0fd2aa8c57b5ef17cfe38245f74f0b889243e51 Mon Sep 17 00:00:00 2001 From: Kayla Man Date: Tue, 4 Nov 2025 18:10:52 +0800 Subject: [PATCH 09/35] add additional default settings into ExtractReview for substance painter --- server/settings/publish_plugins.py | 99 ++++++++++++++++++++++++++++++ 1 file changed, 99 insertions(+) diff --git a/server/settings/publish_plugins.py b/server/settings/publish_plugins.py index ee422a0acf..311b4672cf 100644 --- a/server/settings/publish_plugins.py +++ b/server/settings/publish_plugins.py @@ -1448,6 +1448,105 @@ DEFAULT_PUBLISH_VALUES = { "fill_missing_frames": "closest_existing" } ] + }, + { + "product_types": [], + "hosts": ["substancepainter"], + "task_types": [], + "outputs": [ + { + "name": "png", + "ext": "png", + "tags": [ + "ftrackreview", + "kitsureview", + "webreview" + ], + "burnins": [], + "ffmpeg_args": { + "video_filters": [], + "audio_filters": [], + "input": [], + "output": [] + }, + "filter": { + "families": [ + "render", + "review", + "ftrack" + ], + "product_names": [], + "custom_tags": [], + "single_frame_filter": "single_frame" + }, + "overscan_crop": "", + # "overscan_color": [0, 0, 0], + "overscan_color": [0, 0, 0, 0.0], + "width": 1920, + "height": 1080, + "scale_pixel_aspect": True, + "bg_color": [0, 0, 0, 0.0], + "letter_box": { + "enabled": False, + "ratio": 0.0, + "fill_color": [0, 0, 0, 1.0], + "line_thickness": 0, + "line_color": [255, 0, 0, 1.0] + }, + "fill_missing_frames": "only_rendered" + }, + { + "name": "h264", + "ext": "mp4", + "tags": [ + "burnin", + "ftrackreview", + "kitsureview", + "webreview" + ], + "burnins": [], + "ffmpeg_args": { + "video_filters": [], + "audio_filters": [], + "input": [ + "-apply_trc gamma22" + ], + "output": [ + "-pix_fmt yuv420p", + "-crf 18", + "-c:a aac", + "-b:a 192k", + "-g 1", + "-movflags faststart" + ] + }, + "filter": { + "families": [ + "render", + "review", + "ftrack" + ], + "product_names": [], + "custom_tags": [], + "single_frame_filter": "multi_frame" + }, + "overscan_crop": "", + # "overscan_color": [0, 0, 0], + "overscan_color": [0, 0, 0, 0.0], + "width": 0, + "height": 0, + "scale_pixel_aspect": True, + "bg_color": [0, 0, 0, 0.0], + "letter_box": { + "enabled": False, + "ratio": 0.0, + "fill_color": [0, 0, 0, 1.0], + "line_thickness": 0, + "line_color": [255, 0, 0, 1.0] + }, + "fill_missing_frames": "only_rendered" + } + ] } ] }, From 67d5422c94584a100a1d3818137c4e16e06465ab Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Sat, 8 Nov 2025 22:45:42 +0100 Subject: [PATCH 10/35] If folder name starts with a digit we now prefix it with `_` to avoid invalid USD data to be authored. --- client/ayon_core/pipeline/usdlib.py | 17 +++++++++++++++++ .../publish/extract_usd_layer_contributions.py | 5 +++-- 2 files changed, 20 insertions(+), 2 deletions(-) diff --git a/client/ayon_core/pipeline/usdlib.py b/client/ayon_core/pipeline/usdlib.py index 2ff98c5e45..095f6fdc57 100644 --- a/client/ayon_core/pipeline/usdlib.py +++ b/client/ayon_core/pipeline/usdlib.py @@ -684,3 +684,20 @@ def get_sdf_format_args(path): """Return SDF_FORMAT_ARGS parsed to `dict`""" _raw_path, data = Sdf.Layer.SplitIdentifier(path) return data + + +def get_standard_default_prim_name(folder_path: str) -> str: + """Return the AYON-specified default prim name for a folder path. + + This is used e.g. for the default prim in AYON USD Contribution workflows. + """ + folder_name: str = folder_path.rsplit("/", 1)[-1] + + # Prim names are not allowed to start with a digit in USD. Authoring them + # would mean generating essentially garbage data and may result in + # unexpected behavior in certain USD or DCC versions, like failure to + # refresh in usdview or crashes in Houdini 21. + if folder_name and folder_name[0].isdigit(): + folder_name = f"_{folder_name}" + + return folder_name diff --git a/client/ayon_core/plugins/publish/extract_usd_layer_contributions.py b/client/ayon_core/plugins/publish/extract_usd_layer_contributions.py index 9db8c49a02..c73d1aa447 100644 --- a/client/ayon_core/plugins/publish/extract_usd_layer_contributions.py +++ b/client/ayon_core/plugins/publish/extract_usd_layer_contributions.py @@ -25,7 +25,8 @@ try: variant_nested_prim_path, setup_asset_layer, add_ordered_sublayer, - set_layer_defaults + set_layer_defaults, + get_standard_default_prim_name ) except ImportError: pass @@ -652,7 +653,7 @@ class ExtractUSDLayerContribution(publish.Extractor): sdf_layer = Sdf.Layer.OpenAsAnonymous(path) default_prim = sdf_layer.defaultPrim else: - default_prim = folder_path.rsplit("/", 1)[-1] # use folder name + default_prim = get_standard_default_prim_name(folder_path) sdf_layer = Sdf.Layer.CreateAnonymous() set_layer_defaults(sdf_layer, default_prim=default_prim) From 113d01ce9952762a7dafca4960f80d899bdbc395 Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Sat, 8 Nov 2025 22:54:24 +0100 Subject: [PATCH 11/35] Add setting to always enforce the default prim value --- .../extract_usd_layer_contributions.py | 10 +++++++++ server/settings/publish_plugins.py | 22 ++++++++++++++++--- 2 files changed, 29 insertions(+), 3 deletions(-) diff --git a/client/ayon_core/plugins/publish/extract_usd_layer_contributions.py b/client/ayon_core/plugins/publish/extract_usd_layer_contributions.py index c73d1aa447..ff1c4fec8a 100644 --- a/client/ayon_core/plugins/publish/extract_usd_layer_contributions.py +++ b/client/ayon_core/plugins/publish/extract_usd_layer_contributions.py @@ -641,6 +641,7 @@ class ExtractUSDLayerContribution(publish.Extractor): settings_category = "core" use_ayon_entity_uri = False + enforce_default_prim = False def process(self, instance): @@ -651,6 +652,15 @@ class ExtractUSDLayerContribution(publish.Extractor): path = get_last_publish(instance) if path and BUILD_INTO_LAST_VERSIONS: sdf_layer = Sdf.Layer.OpenAsAnonymous(path) + + # If enabled in settings, ignore any default prim specified on + # older publish versions and always publish with the AYON + # standard default prim + if self.enforce_default_prim: + sdf_layer.defaultPrim = get_standard_default_prim_name( + folder_path + ) + default_prim = sdf_layer.defaultPrim else: default_prim = get_standard_default_prim_name(folder_path) diff --git a/server/settings/publish_plugins.py b/server/settings/publish_plugins.py index ee422a0acf..60bd933344 100644 --- a/server/settings/publish_plugins.py +++ b/server/settings/publish_plugins.py @@ -251,6 +251,19 @@ class AyonEntityURIModel(BaseSettingsModel): ) +class ExtractUSDLayerContributionModel(AyonEntityURIModel): + enforce_default_prim: bool = SettingsField( + title="Always set default prim to folder name.", + description=( + "When enabled ignore any default prim specified on older " + "published versions of a layer and always override it to the " + "AYON standard default prim. When disabled, preserve default prim " + "on the layer and then only the initial version would be setting " + "the AYON standard default prim." + ) + ) + + class PluginStateByHostModelProfile(BaseSettingsModel): _layout = "expanded" # Filtering @@ -1134,9 +1147,11 @@ class PublishPuginsModel(BaseSettingsModel): default_factory=AyonEntityURIModel, title="Extract USD Asset Contribution", ) - ExtractUSDLayerContribution: AyonEntityURIModel = SettingsField( - default_factory=AyonEntityURIModel, - title="Extract USD Layer Contribution", + ExtractUSDLayerContribution: ExtractUSDLayerContributionModel = ( + SettingsField( + default_factory=ExtractUSDLayerContributionModel, + title="Extract USD Layer Contribution", + ) ) PreIntegrateThumbnails: PreIntegrateThumbnailsModel = SettingsField( default_factory=PreIntegrateThumbnailsModel, @@ -1526,6 +1541,7 @@ DEFAULT_PUBLISH_VALUES = { }, "ExtractUSDLayerContribution": { "use_ayon_entity_uri": False, + "enforce_default_prim": False, }, "PreIntegrateThumbnails": { "enabled": True, From e7896c66f3b513ae1f024259943ad4cf3f60fb09 Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Sun, 9 Nov 2025 11:33:33 +0100 Subject: [PATCH 12/35] Update server/settings/publish_plugins.py Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- server/settings/publish_plugins.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/settings/publish_plugins.py b/server/settings/publish_plugins.py index 60bd933344..e939a6518b 100644 --- a/server/settings/publish_plugins.py +++ b/server/settings/publish_plugins.py @@ -256,7 +256,7 @@ class ExtractUSDLayerContributionModel(AyonEntityURIModel): title="Always set default prim to folder name.", description=( "When enabled ignore any default prim specified on older " - "published versions of a layer and always override it to the " + "published versions of a layer and always override it to the " "AYON standard default prim. When disabled, preserve default prim " "on the layer and then only the initial version would be setting " "the AYON standard default prim." From aea231d64e6a07258ee106ec7043967630e4e21c Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Sun, 16 Nov 2025 22:20:57 +0100 Subject: [PATCH 13/35] Also prefix folder name with `_` for initializing the asset layer --- .../plugins/publish/extract_usd_layer_contributions.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/client/ayon_core/plugins/publish/extract_usd_layer_contributions.py b/client/ayon_core/plugins/publish/extract_usd_layer_contributions.py index ff1c4fec8a..4d8a8005f2 100644 --- a/client/ayon_core/plugins/publish/extract_usd_layer_contributions.py +++ b/client/ayon_core/plugins/publish/extract_usd_layer_contributions.py @@ -821,7 +821,7 @@ class ExtractUSDAssetContribution(publish.Extractor): folder_path = instance.data["folderPath"] product_name = instance.data["productName"] self.log.debug(f"Building asset: {folder_path} > {product_name}") - folder_name = folder_path.rsplit("/", 1)[-1] + asset_name = get_standard_default_prim_name(folder_path) # Contribute layers to asset # Use existing asset and add to it, or initialize a new asset layer @@ -840,7 +840,7 @@ class ExtractUSDAssetContribution(publish.Extractor): # the layer as either a default asset or shot structure. init_type = instance.data["contribution_target_product_init"] asset_layer, payload_layer = self.init_layer( - asset_name=folder_name, init_type=init_type + asset_name=asset_name, init_type=init_type ) # Author timeCodesPerSecond and framesPerSecond if the asset layer From 335f9cf21b7ccba2ca6674081c8b9eb6e11b9f3f Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Mon, 17 Nov 2025 14:39:27 +0100 Subject: [PATCH 14/35] Implement generic ExtractOIIOPostProcess plug-in. This can be used to take any image representation through `oiiotool` to process with settings-defined arguments, to e.g. resize an image, convert all layers to scanline, etc. --- .../publish/extract_oiio_postprocess.py | 322 ++++++++++++++++++ 1 file changed, 322 insertions(+) create mode 100644 client/ayon_core/plugins/publish/extract_oiio_postprocess.py diff --git a/client/ayon_core/plugins/publish/extract_oiio_postprocess.py b/client/ayon_core/plugins/publish/extract_oiio_postprocess.py new file mode 100644 index 0000000000..6163eb98d2 --- /dev/null +++ b/client/ayon_core/plugins/publish/extract_oiio_postprocess.py @@ -0,0 +1,322 @@ +import os +import copy +import clique +import pyblish.api + +from ayon_core.pipeline import ( + publish, + get_temp_dir +) +from ayon_core.lib import ( + is_oiio_supported, + get_oiio_tool_args, + run_subprocess +) +from ayon_core.lib.profiles_filtering import filter_profiles + + +class ExtractOIIOPostProcess(publish.Extractor): + """Process representations through `oiiotool` with profile defined + settings so that e.g. color space conversions can be applied or images + could be converted to scanline, resized, etc. regardless of colorspace + data. + """ + + label = "OIIO Post Process" + order = pyblish.api.ExtractorOrder + 0.020 + + settings_category = "core" + + optional = True + + # Supported extensions + supported_exts = {"exr", "jpg", "jpeg", "png", "dpx"} + + # Configurable by Settings + profiles = None + options = None + + def process(self, instance): + if not self.profiles: + self.log.debug("No profiles present for OIIO Post Process") + return + + if "representations" not in instance.data: + self.log.debug("No representations, skipping.") + return + + if not is_oiio_supported(): + self.log.warning("OIIO not supported, no transcoding possible.") + return + + profile, representations = self._get_profile( + instance + ) + if not profile: + return + + profile_output_defs = profile["outputs"] + new_representations = [] + for idx, repre in enumerate(list(instance.data["representations"])): + self.log.debug("repre ({}): `{}`".format(idx + 1, repre["name"])) + if not self._repre_is_valid(repre, profile): + 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"]] + + added_representations = False + added_review = False + + # Process each output definition + 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"] + new_repre = copy.deepcopy(repre) + + original_staging_dir = new_repre["stagingDir"] + new_staging_dir = get_temp_dir( + project_name=instance.context.data["projectName"], + use_local_temp=True, + ) + new_repre["stagingDir"] = new_staging_dir + + output_extension = output_def["extension"] + output_extension = output_extension.replace('.', '') + self._rename_in_representation(new_repre, + files_to_convert, + output_name, + output_extension) + + sequence_files = self._translate_to_sequence(files_to_convert) + self.log.debug("Files to convert: {}".format(sequence_files)) + 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)) + input_path = os.path.join(original_staging_dir, + file_name) + output_path = self._get_output_file_path(input_path, + new_staging_dir, + output_extension) + + # TODO: Support formatting with dynamic keys from the + # representation, like e.g. colorspace config, display, + # view, etc. + input_arguments: list[str] = output_def.get( + "input_arguments", [] + ) + output_arguments: list[str] = output_def.get( + "output_arguments", [] + ) + + # Prepare subprocess arguments + oiio_cmd = get_oiio_tool_args( + "oiiotool", + *input_arguments, + input_path, + *output_arguments, + "-o", + output_path + ) + + self.log.debug( + "Conversion command: {}".format(" ".join(oiio_cmd))) + run_subprocess(oiio_cmd, logger=self.log) + + # cleanup temporary transcoded files + for file_name in new_repre["files"]: + transcoded_file_path = os.path.join(new_staging_dir, + file_name) + instance.context.data["cleanupFullPaths"].append( + transcoded_file_path) + + custom_tags = output_def.get("custom_tags") + if custom_tags: + if new_repre.get("custom_tags") is None: + new_repre["custom_tags"] = [] + new_repre["custom_tags"].extend(custom_tags) + + # Add additional tags from output definition to representation + if new_repre.get("tags") is None: + new_repre["tags"] = [] + for tag in output_def["tags"]: + if tag not in new_repre["tags"]: + new_repre["tags"].append(tag) + + if tag == "review": + added_review = True + + # If there is only 1 file outputted then convert list to + # string, because that'll indicate that it is not a sequence. + if len(new_repre["files"]) == 1: + new_repre["files"] = new_repre["files"][0] + + # If the source representation has "review" tag, but it's not + # part of the output definition tags, then both the + # representations will be transcoded in ExtractReview and + # their outputs will clash in integration. + if "review" in repre.get("tags", []): + added_review = True + + new_representations.append(new_repre) + added_representations = True + + if added_representations: + self._mark_original_repre_for_deletion( + repre, profile, added_review + ) + + tags = repre.get("tags") or [] + if "delete" in tags and "thumbnail" not in tags: + instance.data["representations"].remove(repre) + + instance.data["representations"].extend(new_representations) + + def _rename_in_representation(self, new_repre, files_to_convert, + output_name, output_extension): + """Replace old extension with new one everywhere in representation. + + Args: + new_repre (dict) + files_to_convert (list): of filenames from repre["files"], + standardized to always list + output_name (str): key of output definition from Settings, + if "" token used, keep original repre name + output_extension (str): extension from output definition + """ + if output_name != "passthrough": + new_repre["name"] = output_name + if not output_extension: + return + + new_repre["ext"] = output_extension + new_repre["outputName"] = output_name + + renamed_files = [] + for file_name in files_to_convert: + file_name, _ = os.path.splitext(file_name) + file_name = '{}.{}'.format(file_name, + output_extension) + renamed_files.append(file_name) + new_repre["files"] = renamed_files + + def _translate_to_sequence(self, files_to_convert): + """Returns original list or a clique.Collection of a sequence. + + Uses clique to find frame sequence Collection. + If sequence not found, it returns original list. + + Args: + files_to_convert (list): list of file names + Returns: + list[str | clique.Collection]: List of filepaths or a list + of Collections (usually one, unless there are holes) + """ + pattern = [clique.PATTERNS["frames"]] + collections, _ = clique.assemble( + files_to_convert, patterns=pattern, + assume_padded_when_ambiguous=True) + if collections: + if len(collections) > 1: + raise ValueError( + "Too many collections {}".format(collections)) + + collection = collections[0] + # TODO: Technically oiiotool supports holes in the sequence as well + # using the dedicated --frames argument to specify the frames. + # We may want to use that too so conversions of sequences with + # holes will perform faster as well. + # Separate the collection so that we have no holes/gaps per + # collection. + return collection.separate() + + return files_to_convert + + def _get_output_file_path(self, input_path, output_dir, + output_extension): + """Create output file name path.""" + file_name = os.path.basename(input_path) + file_name, input_extension = os.path.splitext(file_name) + if not output_extension: + output_extension = input_extension.replace(".", "") + new_file_name = '{}.{}'.format(file_name, + output_extension) + return os.path.join(output_dir, new_file_name) + + def _get_profile(self, instance): + """Returns profile if it should process this instance.""" + 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 = { + "hosts": 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" + " Host: \"{}\" | Product types: \"{}\" | Product names: \"{}\"" + " | Task name \"{}\" | Task type \"{}\"" + ).format( + host_name, product_type, product_name, task_name, task_type + )) + + return profile + + def _repre_is_valid(self, repre) -> bool: + """Validation if representation should be processed. + + Args: + repre (dict): Representation which should be checked. + + Returns: + bool: False if can't be processed else True. + """ + if repre.get("ext") not in self.supported_exts: + self.log.debug(( + "Representation '{}' has unsupported extension: '{}'. Skipped." + ).format(repre["name"], repre.get("ext"))) + return False + + if not repre.get("files"): + self.log.debug(( + "Representation '{}' has empty files. Skipped." + ).format(repre["name"])) + return False + + return True + + def _mark_original_repre_for_deletion(self, repre, profile, added_review): + """If new transcoded representation created, delete old.""" + if not repre.get("tags"): + repre["tags"] = [] + + delete_original = profile["delete_original"] + + if delete_original: + if "delete" not in repre["tags"]: + repre["tags"].append("delete") + + if added_review and "review" in repre["tags"]: + repre["tags"].remove("review") From a6ecea872efda602b2fe0b157924d893d6611192 Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Mon, 17 Nov 2025 14:47:25 +0100 Subject: [PATCH 15/35] Add missing changes --- .../publish/extract_oiio_postprocess.py | 4 +- server/settings/publish_plugins.py | 111 ++++++++++++++++++ 2 files changed, 113 insertions(+), 2 deletions(-) diff --git a/client/ayon_core/plugins/publish/extract_oiio_postprocess.py b/client/ayon_core/plugins/publish/extract_oiio_postprocess.py index 6163eb98d2..2e93c68283 100644 --- a/client/ayon_core/plugins/publish/extract_oiio_postprocess.py +++ b/client/ayon_core/plugins/publish/extract_oiio_postprocess.py @@ -49,7 +49,7 @@ class ExtractOIIOPostProcess(publish.Extractor): self.log.warning("OIIO not supported, no transcoding possible.") return - profile, representations = self._get_profile( + profile = self._get_profile( instance ) if not profile: @@ -59,7 +59,7 @@ class ExtractOIIOPostProcess(publish.Extractor): new_representations = [] for idx, repre in enumerate(list(instance.data["representations"])): self.log.debug("repre ({}): `{}`".format(idx + 1, repre["name"])) - if not self._repre_is_valid(repre, profile): + if not self._repre_is_valid(repre): continue # Get representation files to convert diff --git a/server/settings/publish_plugins.py b/server/settings/publish_plugins.py index ee422a0acf..173526e13f 100644 --- a/server/settings/publish_plugins.py +++ b/server/settings/publish_plugins.py @@ -565,12 +565,115 @@ class ExtractOIIOTranscodeProfileModel(BaseSettingsModel): class ExtractOIIOTranscodeModel(BaseSettingsModel): + """Color conversion transcoding using OIIO for images mostly aimed at + transcoding for reviewables (it'll process and output only RGBA channels). + """ enabled: bool = SettingsField(True) profiles: list[ExtractOIIOTranscodeProfileModel] = SettingsField( default_factory=list, title="Profiles" ) +class ExtractOIIOPostProcessOutputModel(BaseSettingsModel): + _layout = "expanded" + name: str = SettingsField( + "", + title="Name", + description="Output name (no space)", + regex=r"[a-zA-Z0-9_]([a-zA-Z0-9_\.\-]*[a-zA-Z0-9_])?$", + ) + extension: str = SettingsField( + "", + title="Extension", + description=( + "Target extension. If left empty, original" + " extension is used." + ), + ) + input_arguments: list[str] = SettingsField( + default_factory=list, + title="Input arguments", + description="Arguments passed prior to the input file argument.", + ) + output_arguments: list[str] = SettingsField( + default_factory=list, + title="Output arguments", + description="Arguments passed prior to the -o argument.", + ) + tags: list[str] = SettingsField( + default_factory=list, + title="Tags", + description=( + "Additional tags that will be added to the created representation." + "\nAdd *review* tag to create review from the transcoded" + " representation instead of the original." + ) + ) + custom_tags: list[str] = SettingsField( + default_factory=list, + title="Custom Tags", + description=( + "Additional custom tags that will be added" + " to the created representation." + ) + ) + + +class ExtractOIIOPostProcessProfileModel(BaseSettingsModel): + product_types: list[str] = SettingsField( + default_factory=list, + title="Product types" + ) + hosts: 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" + ) + delete_original: bool = SettingsField( + True, + title="Delete Original Representation", + description=( + "Choose to preserve or remove the original representation.\n" + "Keep in mind that if the transcoded representation includes" + " a `review` tag, it will take precedence over" + " the original for creating reviews." + ), + ) + outputs: list[ExtractOIIOPostProcessOutputModel] = SettingsField( + default_factory=list, + title="Output Definitions", + ) + + @validator("outputs") + def validate_unique_outputs(cls, value): + ensure_unique_names(value) + return value + + +class ExtractOIIOPostProcessModel(BaseSettingsModel): + """Process representation images with `oiiotool` on publish. + + This could be used to convert images to different formats, convert to + scanline images or flatten deep images. + """ + enabled: bool = SettingsField(True) + profiles: list[ExtractOIIOPostProcessProfileModel] = SettingsField( + default_factory=list, title="Profiles" + ) + + # --- [START] Extract Review --- class ExtractReviewFFmpegModel(BaseSettingsModel): video_filters: list[str] = SettingsField( @@ -1122,6 +1225,10 @@ class PublishPuginsModel(BaseSettingsModel): default_factory=ExtractOIIOTranscodeModel, title="Extract OIIO Transcode" ) + ExtractOIIOPostProcess: ExtractOIIOPostProcessModel = SettingsField( + default_factory=ExtractOIIOPostProcessModel, + title="Extract OIIO Post Process" + ) ExtractReview: ExtractReviewModel = SettingsField( default_factory=ExtractReviewModel, title="Extract Review" @@ -1347,6 +1454,10 @@ DEFAULT_PUBLISH_VALUES = { "enabled": True, "profiles": [] }, + "ExtractOIIOPostProcess": { + "enabled": True, + "profiles": [] + }, "ExtractReview": { "enabled": True, "profiles": [ From 73dfff9191e4ba5d159d2d495c71950d60389236 Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Sat, 22 Nov 2025 13:28:08 +0100 Subject: [PATCH 16/35] Fix call to `get_representation_path_by_names` --- .../plugins/publish/extract_usd_layer_contributions.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/client/ayon_core/plugins/publish/extract_usd_layer_contributions.py b/client/ayon_core/plugins/publish/extract_usd_layer_contributions.py index 4d8a8005f2..ec6b2b9792 100644 --- a/client/ayon_core/plugins/publish/extract_usd_layer_contributions.py +++ b/client/ayon_core/plugins/publish/extract_usd_layer_contributions.py @@ -177,7 +177,10 @@ def get_instance_uri_path( # If for whatever reason we were unable to retrieve from the context # then get the path from an existing database entry - path = get_representation_path_by_names(**query) + path = get_representation_path_by_names( + anatomy=context.data["anatomy"], + **names + ) # Ensure `None` for now is also a string path = str(path) From 70bf746c7acac36d8cf3670843b862cf08d82537 Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Sat, 22 Nov 2025 13:29:49 +0100 Subject: [PATCH 17/35] Fail clearly if the path can't be resolved instead of setting path to "None" --- .../plugins/publish/extract_usd_layer_contributions.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/client/ayon_core/plugins/publish/extract_usd_layer_contributions.py b/client/ayon_core/plugins/publish/extract_usd_layer_contributions.py index ec6b2b9792..d5c5aca0f2 100644 --- a/client/ayon_core/plugins/publish/extract_usd_layer_contributions.py +++ b/client/ayon_core/plugins/publish/extract_usd_layer_contributions.py @@ -181,6 +181,8 @@ def get_instance_uri_path( anatomy=context.data["anatomy"], **names ) + if not path: + raise RuntimeError(f"Unable to resolve publish path for: {names}") # Ensure `None` for now is also a string path = str(path) From 58432ff4dd3aad7b879e72a6dcf158dce381d874 Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Tue, 25 Nov 2025 23:55:12 +0100 Subject: [PATCH 18/35] Re-show 'initialize as' attribute for USD publish so it's clear what is going on with the initial layer. --- .../plugins/publish/extract_usd_layer_contributions.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/client/ayon_core/plugins/publish/extract_usd_layer_contributions.py b/client/ayon_core/plugins/publish/extract_usd_layer_contributions.py index 9db8c49a02..4dec4d8b9b 100644 --- a/client/ayon_core/plugins/publish/extract_usd_layer_contributions.py +++ b/client/ayon_core/plugins/publish/extract_usd_layer_contributions.py @@ -494,7 +494,7 @@ class CollectUSDLayerContributions(pyblish.api.InstancePlugin, "asset" if profile.get("contribution_target_product") == "usdAsset" else "shot") - init_as_visible = False + init_as_visible = True # Attributes logic publish_attributes = instance["publish_attributes"].get( @@ -828,6 +828,7 @@ class ExtractUSDAssetContribution(publish.Extractor): # If no existing publish of this product exists then we initialize # the layer as either a default asset or shot structure. init_type = instance.data["contribution_target_product_init"] + self.log.debug("Initializing layer as type: %s", init_type) asset_layer, payload_layer = self.init_layer( asset_name=folder_name, init_type=init_type ) From 2aa7e46c9c94e5224c74c043971749f9b3c8f671 Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Tue, 25 Nov 2025 23:58:09 +0100 Subject: [PATCH 19/35] Cosmetic type hints --- .../plugins/publish/extract_usd_layer_contributions.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/ayon_core/plugins/publish/extract_usd_layer_contributions.py b/client/ayon_core/plugins/publish/extract_usd_layer_contributions.py index 4dec4d8b9b..d73a417f16 100644 --- a/client/ayon_core/plugins/publish/extract_usd_layer_contributions.py +++ b/client/ayon_core/plugins/publish/extract_usd_layer_contributions.py @@ -910,7 +910,7 @@ class ExtractUSDAssetContribution(publish.Extractor): payload_layer.Export(payload_path, args={"format": "usda"}) self.add_relative_file(instance, payload_path) - def init_layer(self, asset_name, init_type): + def init_layer(self, asset_name: str, init_type: str): """Initialize layer if no previous version exists""" if init_type == "asset": From 344f91c983256d5a29641c5e809761ab39b60f64 Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Wed, 26 Nov 2025 00:38:37 +0100 Subject: [PATCH 20/35] Make settings profiles more granular for OIIO post process --- .../publish/extract_oiio_postprocess.py | 46 +++++++++++++------ server/settings/publish_plugins.py | 10 ++++ 2 files changed, 41 insertions(+), 15 deletions(-) diff --git a/client/ayon_core/plugins/publish/extract_oiio_postprocess.py b/client/ayon_core/plugins/publish/extract_oiio_postprocess.py index 2e93c68283..610f464989 100644 --- a/client/ayon_core/plugins/publish/extract_oiio_postprocess.py +++ b/client/ayon_core/plugins/publish/extract_oiio_postprocess.py @@ -1,3 +1,5 @@ +from __future__ import annotations +from typing import Any, Optional import os import copy import clique @@ -49,19 +51,21 @@ class ExtractOIIOPostProcess(publish.Extractor): self.log.warning("OIIO not supported, no transcoding possible.") return - profile = self._get_profile( - instance - ) - if not profile: - return - - profile_output_defs = profile["outputs"] new_representations = [] for idx, repre in enumerate(list(instance.data["representations"])): self.log.debug("repre ({}): `{}`".format(idx + 1, repre["name"])) if not self._repre_is_valid(repre): continue + # We check profile per representation name and extension because + # it's included in the profile check. As such, an instance may have + # a different profile applied per representation. + profile = self._get_profile( + instance + ) + if not profile: + continue + # Get representation files to convert if isinstance(repre["files"], list): repre_files_to_convert = copy.deepcopy(repre["files"]) @@ -72,7 +76,7 @@ class ExtractOIIOPostProcess(publish.Extractor): added_review = False # Process each output definition - for output_def in profile_output_defs: + for output_def in profile["outputs"]: # Local copy to avoid accidental mutable changes files_to_convert = list(repre_files_to_convert) @@ -255,7 +259,7 @@ class ExtractOIIOPostProcess(publish.Extractor): output_extension) return os.path.join(output_dir, new_file_name) - def _get_profile(self, instance): + def _get_profile(self, instance: pyblish.api.Instance, repre: dict) -> Optional[dict[str, Any]]: """Returns profile if it should process this instance.""" host_name = instance.context.data["hostName"] product_type = instance.data["productType"] @@ -263,24 +267,30 @@ class ExtractOIIOPostProcess(publish.Extractor): task_data = instance.data["anatomyData"].get("task", {}) task_name = task_data.get("name") task_type = task_data.get("type") + repre_name: str = repre["name"] + repre_ext: str = repre["ext"] filtering_criteria = { "hosts": host_name, "product_types": product_type, "product_names": product_name, "task_names": task_name, "task_types": task_type, + "representation_names": repre_name, + "representation_exts": repre_ext, } profile = filter_profiles(self.profiles, filtering_criteria, logger=self.log) if not profile: - self.log.debug(( + self.log.debug( "Skipped instance. None of profiles in presets are for" - " Host: \"{}\" | Product types: \"{}\" | Product names: \"{}\"" - " | Task name \"{}\" | Task type \"{}\"" - ).format( - host_name, product_type, product_name, task_name, task_type - )) + f" Host: \"{host_name}\" |" + f" Product types: \"{product_type}\" |" + f" Product names: \"{product_name}\" |" + f" Task name \"{task_name}\" |" + f" Task type \"{task_type}\" |" + f" Representation: \"{repre_name}\" (.{repre_ext})" + ) return profile @@ -305,6 +315,12 @@ class ExtractOIIOPostProcess(publish.Extractor): ).format(repre["name"])) return False + if "delete" in repre.get("tags", []): + self.log.debug(( + "Representation '{}' has 'delete' tag. Skipped." + ).format(repre["name"])) + return False + return True def _mark_original_repre_for_deletion(self, repre, profile, added_review): diff --git a/server/settings/publish_plugins.py b/server/settings/publish_plugins.py index 173526e13f..2c133ddbbf 100644 --- a/server/settings/publish_plugins.py +++ b/server/settings/publish_plugins.py @@ -621,6 +621,7 @@ class ExtractOIIOPostProcessOutputModel(BaseSettingsModel): class ExtractOIIOPostProcessProfileModel(BaseSettingsModel): product_types: list[str] = SettingsField( + section="Profile", default_factory=list, title="Product types" ) @@ -641,6 +642,14 @@ class ExtractOIIOPostProcessProfileModel(BaseSettingsModel): default_factory=list, title="Product names" ) + representation_names: list[str] = SettingsField( + default_factory=list, + title="Representation names", + ) + representation_exts: list[str] = SettingsField( + default_factory=list, + title="Representation extensions", + ) delete_original: bool = SettingsField( True, title="Delete Original Representation", @@ -650,6 +659,7 @@ class ExtractOIIOPostProcessProfileModel(BaseSettingsModel): " a `review` tag, it will take precedence over" " the original for creating reviews." ), + section="Conversion Outputs", ) outputs: list[ExtractOIIOPostProcessOutputModel] = SettingsField( default_factory=list, From 4f332766f0ccc9071d6f189abce8ed9fe67e9e6a Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Wed, 26 Nov 2025 10:22:17 +0100 Subject: [PATCH 21/35] Use `IMAGE_EXTENSIONS` --- client/ayon_core/plugins/publish/extract_oiio_postprocess.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/client/ayon_core/plugins/publish/extract_oiio_postprocess.py b/client/ayon_core/plugins/publish/extract_oiio_postprocess.py index 610f464989..33384e0185 100644 --- a/client/ayon_core/plugins/publish/extract_oiio_postprocess.py +++ b/client/ayon_core/plugins/publish/extract_oiio_postprocess.py @@ -14,6 +14,7 @@ from ayon_core.lib import ( get_oiio_tool_args, run_subprocess ) +from ayon_core.lib.transcoding import IMAGE_EXTENSIONS from ayon_core.lib.profiles_filtering import filter_profiles @@ -32,7 +33,7 @@ class ExtractOIIOPostProcess(publish.Extractor): optional = True # Supported extensions - supported_exts = {"exr", "jpg", "jpeg", "png", "dpx"} + supported_exts = {ext.lstrip(".") for ext in IMAGE_EXTENSIONS} # Configurable by Settings profiles = None From ba6a9bdca4a8c301c8bf8122f4a0bd9afa620a46 Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Thu, 27 Nov 2025 00:41:34 +0100 Subject: [PATCH 22/35] Allow OCIO color management profiles that have no matching profile or do match a profile with "disabled" status to be considered as NOT color managed. So that you can specify a particular part of the project to NOT be OCIO color managed. --- client/ayon_core/pipeline/colorspace.py | 56 ++++++++++++++++--------- server/settings/main.py | 1 + 2 files changed, 37 insertions(+), 20 deletions(-) diff --git a/client/ayon_core/pipeline/colorspace.py b/client/ayon_core/pipeline/colorspace.py index 41241e17ca..db7d287cf1 100644 --- a/client/ayon_core/pipeline/colorspace.py +++ b/client/ayon_core/pipeline/colorspace.py @@ -7,6 +7,7 @@ import platform import tempfile import warnings from copy import deepcopy +from dataclasses import dataclass import ayon_api @@ -25,6 +26,17 @@ from ayon_core.pipeline.load import get_representation_path_with_anatomy log = Logger.get_logger(__name__) +@dataclass +class ConfigData: + """OCIO Config to use in a certain context. + + When enabled and no path/template are set, it will be considered invalid + and will error on OCIO path not found. Enabled must be False to explicitly + allow OCIO to be disabled.""" + path: str = "" + template: str = "" + enabled: bool = True + class CachedData: remapping = {} @@ -710,7 +722,7 @@ def _get_config_path_from_profile_data( template_data (dict[str, Any]): Template data. Returns: - dict[str, str]: Config data with path and template. + ConfigData: Config data with path and template. """ template = profile[profile_type] result = StringTemplate.format_strict_template( @@ -719,12 +731,12 @@ def _get_config_path_from_profile_data( normalized_path = str(result.normalized()) if not os.path.exists(normalized_path): log.warning(f"Path was not found '{normalized_path}'.") - return None + return ConfigData() # Return invalid config data - return { - "path": normalized_path, - "template": template - } + return ConfigData( + path=normalized_path, + template=template + ) def _get_global_config_data( @@ -735,7 +747,7 @@ def _get_global_config_data( imageio_global, folder_id, log, -): +) -> ConfigData: """Get global config data. Global config from core settings is using profiles that are based on @@ -759,8 +771,7 @@ def _get_global_config_data( log (logging.Logger): Logger object. Returns: - Union[dict[str, str], None]: Config data with path and template - or None. + ConfigData: Config data with path and template. """ task_name = task_type = None @@ -779,12 +790,14 @@ def _get_global_config_data( ) if profile is None: log.info(f"No config profile matched filters {str(filter_values)}") - return None + return ConfigData(enabled=False) profile_type = profile["type"] - if profile_type in ("builtin_path", "custom_path"): + if profile_type in {"builtin_path", "custom_path"}: return _get_config_path_from_profile_data( profile, profile_type, template_data) + elif profile_type == "disabled": + return ConfigData(enabled=False) # TODO decide if this is the right name for representation repre_name = "ocioconfig" @@ -798,7 +811,7 @@ def _get_global_config_data( "Colorspace OCIO config path cannot be set. " "Profile is set to published product but `Product name` is empty." ) - return None + return ConfigData() folder_info = template_data.get("folder") if not folder_info: @@ -819,7 +832,7 @@ def _get_global_config_data( ) if not folder_entity: log.warning(f"Folder entity '{folder_path}' was not found..") - return None + return ConfigData() folder_id = folder_entity["id"] product_entities_by_name = { @@ -855,7 +868,7 @@ def _get_global_config_data( log.info( f"Product '{product_name}' does not have available any versions." ) - return None + return ConfigData() # Find 'ocioconfig' representation entity repre_entity = ayon_api.get_representation_by_name( @@ -868,15 +881,15 @@ def _get_global_config_data( f"Representation '{repre_name}'" f" not found on product '{product_name}'." ) - return None + return ConfigData() path = get_representation_path_with_anatomy(repre_entity, anatomy) template = repre_entity["attrib"]["template"] - return { - "path": path, - "template": template, - } + return ConfigData( + path=path, + template=template + ) def get_imageio_config_preset( @@ -1015,7 +1028,10 @@ def get_imageio_config_preset( host_ocio_config["filepath"], template_data ) - if not config_data: + if not config_data.enabled: + return {} # OCIO management disabled + + if not config_data.path: raise FileExistsError( "No OCIO config found in settings. It is" " either missing or there is typo in path inputs" diff --git a/server/settings/main.py b/server/settings/main.py index cca885303f..3bd9549116 100644 --- a/server/settings/main.py +++ b/server/settings/main.py @@ -59,6 +59,7 @@ def _ocio_config_profile_types(): {"value": "builtin_path", "label": "AYON built-in OCIO config"}, {"value": "custom_path", "label": "Path to OCIO config"}, {"value": "published_product", "label": "Published product"}, + {"value": "disabled", "label": "Disable OCIO management"}, ] From ab8a93b4a4455ba1ecf639f00d325051bb8bf8de Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Thu, 27 Nov 2025 00:57:46 +0100 Subject: [PATCH 23/35] Cosmetics --- client/ayon_core/pipeline/colorspace.py | 1 + 1 file changed, 1 insertion(+) diff --git a/client/ayon_core/pipeline/colorspace.py b/client/ayon_core/pipeline/colorspace.py index db7d287cf1..a7d205d48e 100644 --- a/client/ayon_core/pipeline/colorspace.py +++ b/client/ayon_core/pipeline/colorspace.py @@ -26,6 +26,7 @@ from ayon_core.pipeline.load import get_representation_path_with_anatomy log = Logger.get_logger(__name__) + @dataclass class ConfigData: """OCIO Config to use in a certain context. From a73d8f947d25f6243f293204a1405f1e47065fef Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Thu, 27 Nov 2025 01:01:51 +0100 Subject: [PATCH 24/35] Fix return value --- client/ayon_core/pipeline/colorspace.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/client/ayon_core/pipeline/colorspace.py b/client/ayon_core/pipeline/colorspace.py index a7d205d48e..7a4d9dda50 100644 --- a/client/ayon_core/pipeline/colorspace.py +++ b/client/ayon_core/pipeline/colorspace.py @@ -1038,7 +1038,10 @@ def get_imageio_config_preset( " either missing or there is typo in path inputs" ) - return config_data + return { + "path": config_data.path, + "template": config_data.template, + } def _get_host_config_data(templates, template_data): From 2a5210ccc535e13ae4aec24415a108505982ba2b Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Thu, 27 Nov 2025 14:10:55 +0100 Subject: [PATCH 25/35] Update client/ayon_core/plugins/publish/extract_oiio_postprocess.py Co-authored-by: Mustafa Zaky Jafar --- client/ayon_core/plugins/publish/extract_oiio_postprocess.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/client/ayon_core/plugins/publish/extract_oiio_postprocess.py b/client/ayon_core/plugins/publish/extract_oiio_postprocess.py index 33384e0185..1130a86fb6 100644 --- a/client/ayon_core/plugins/publish/extract_oiio_postprocess.py +++ b/client/ayon_core/plugins/publish/extract_oiio_postprocess.py @@ -62,7 +62,8 @@ class ExtractOIIOPostProcess(publish.Extractor): # it's included in the profile check. As such, an instance may have # a different profile applied per representation. profile = self._get_profile( - instance + instance, + repre ) if not profile: continue From 877a9fdecd61d7935e0bedf62344d2cc14c785d8 Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Thu, 27 Nov 2025 14:38:35 +0100 Subject: [PATCH 26/35] Refactor profile `hosts` -> `host_names` --- client/ayon_core/plugins/publish/extract_oiio_postprocess.py | 2 +- server/settings/publish_plugins.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/client/ayon_core/plugins/publish/extract_oiio_postprocess.py b/client/ayon_core/plugins/publish/extract_oiio_postprocess.py index 1130a86fb6..3228e418b5 100644 --- a/client/ayon_core/plugins/publish/extract_oiio_postprocess.py +++ b/client/ayon_core/plugins/publish/extract_oiio_postprocess.py @@ -272,7 +272,7 @@ class ExtractOIIOPostProcess(publish.Extractor): repre_name: str = repre["name"] repre_ext: str = repre["ext"] filtering_criteria = { - "hosts": host_name, + "host_names": host_name, "product_types": product_type, "product_names": product_name, "task_names": task_name, diff --git a/server/settings/publish_plugins.py b/server/settings/publish_plugins.py index aa86398582..9b490ab208 100644 --- a/server/settings/publish_plugins.py +++ b/server/settings/publish_plugins.py @@ -638,7 +638,7 @@ class ExtractOIIOPostProcessProfileModel(BaseSettingsModel): default_factory=list, title="Product types" ) - hosts: list[str] = SettingsField( + host_names: list[str] = SettingsField( default_factory=list, title="Host names" ) From e2727ad15e7db7c5789ff2a67ffabbbec32f917a Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Thu, 27 Nov 2025 14:41:31 +0100 Subject: [PATCH 27/35] Cosmetics + type hints --- .../plugins/publish/extract_oiio_postprocess.py | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/client/ayon_core/plugins/publish/extract_oiio_postprocess.py b/client/ayon_core/plugins/publish/extract_oiio_postprocess.py index 3228e418b5..c7ae4c3910 100644 --- a/client/ayon_core/plugins/publish/extract_oiio_postprocess.py +++ b/client/ayon_core/plugins/publish/extract_oiio_postprocess.py @@ -261,7 +261,11 @@ class ExtractOIIOPostProcess(publish.Extractor): output_extension) return os.path.join(output_dir, new_file_name) - def _get_profile(self, instance: pyblish.api.Instance, repre: dict) -> Optional[dict[str, Any]]: + def _get_profile( + self, + instance: pyblish.api.Instance, + repre: dict + ) -> Optional[dict[str, Any]]: """Returns profile if it should process this instance.""" host_name = instance.context.data["hostName"] product_type = instance.data["productType"] @@ -296,7 +300,7 @@ class ExtractOIIOPostProcess(publish.Extractor): return profile - def _repre_is_valid(self, repre) -> bool: + def _repre_is_valid(self, repre: dict) -> bool: """Validation if representation should be processed. Args: @@ -325,7 +329,12 @@ class ExtractOIIOPostProcess(publish.Extractor): return True - def _mark_original_repre_for_deletion(self, repre, profile, added_review): + def _mark_original_repre_for_deletion( + self, + repre: dict, + profile: dict, + added_review: bool + ): """If new transcoded representation created, delete old.""" if not repre.get("tags"): repre["tags"] = [] From c1210b297788cd35190edc300fe8ebccfc5bb2a0 Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Thu, 27 Nov 2025 14:42:34 +0100 Subject: [PATCH 28/35] Also skip early if no representations in the data. --- client/ayon_core/plugins/publish/extract_oiio_postprocess.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/ayon_core/plugins/publish/extract_oiio_postprocess.py b/client/ayon_core/plugins/publish/extract_oiio_postprocess.py index c7ae4c3910..7ea6ba50e3 100644 --- a/client/ayon_core/plugins/publish/extract_oiio_postprocess.py +++ b/client/ayon_core/plugins/publish/extract_oiio_postprocess.py @@ -44,7 +44,7 @@ class ExtractOIIOPostProcess(publish.Extractor): self.log.debug("No profiles present for OIIO Post Process") return - if "representations" not in instance.data: + if not instance.data.get("representations"): self.log.debug("No representations, skipping.") return From 596612cc99e6e0238d77ca4278f4e926fc41380f Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Thu, 27 Nov 2025 15:02:11 +0100 Subject: [PATCH 29/35] Move over product types in settings so order makes more sense --- server/settings/publish_plugins.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/server/settings/publish_plugins.py b/server/settings/publish_plugins.py index 9b490ab208..a5b84354bd 100644 --- a/server/settings/publish_plugins.py +++ b/server/settings/publish_plugins.py @@ -633,12 +633,8 @@ class ExtractOIIOPostProcessOutputModel(BaseSettingsModel): class ExtractOIIOPostProcessProfileModel(BaseSettingsModel): - product_types: list[str] = SettingsField( - section="Profile", - default_factory=list, - title="Product types" - ) host_names: list[str] = SettingsField( + section="Profile", default_factory=list, title="Host names" ) @@ -651,6 +647,10 @@ class ExtractOIIOPostProcessProfileModel(BaseSettingsModel): default_factory=list, title="Task names" ) + product_types: list[str] = SettingsField( + default_factory=list, + title="Product types" + ) product_names: list[str] = SettingsField( default_factory=list, title="Product names" From a7e02c19e5c7443638283a82b3f7ecab613edf55 Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Thu, 27 Nov 2025 15:30:53 +0100 Subject: [PATCH 30/35] Update client/ayon_core/plugins/publish/extract_oiio_postprocess.py Co-authored-by: Mustafa Zaky Jafar --- client/ayon_core/plugins/publish/extract_oiio_postprocess.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/client/ayon_core/plugins/publish/extract_oiio_postprocess.py b/client/ayon_core/plugins/publish/extract_oiio_postprocess.py index 7ea6ba50e3..7f3ba38963 100644 --- a/client/ayon_core/plugins/publish/extract_oiio_postprocess.py +++ b/client/ayon_core/plugins/publish/extract_oiio_postprocess.py @@ -40,6 +40,9 @@ class ExtractOIIOPostProcess(publish.Extractor): options = None def process(self, instance): + if instance.data.get("farm"): + self.log.debug("Should be processed on farm, skipping.") + return if not self.profiles: self.log.debug("No profiles present for OIIO Post Process") return From 0b942a062f3255a43a3d2a1a1f830f473396860b Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Thu, 27 Nov 2025 15:31:39 +0100 Subject: [PATCH 31/35] Cosmetics --- client/ayon_core/plugins/publish/extract_oiio_postprocess.py | 1 + 1 file changed, 1 insertion(+) diff --git a/client/ayon_core/plugins/publish/extract_oiio_postprocess.py b/client/ayon_core/plugins/publish/extract_oiio_postprocess.py index 7f3ba38963..2b432f2a0a 100644 --- a/client/ayon_core/plugins/publish/extract_oiio_postprocess.py +++ b/client/ayon_core/plugins/publish/extract_oiio_postprocess.py @@ -43,6 +43,7 @@ class ExtractOIIOPostProcess(publish.Extractor): if instance.data.get("farm"): self.log.debug("Should be processed on farm, skipping.") return + if not self.profiles: self.log.debug("No profiles present for OIIO Post Process") return From 8a0e1afcb37ef0843e1ae9bf714dfa949eb3bab0 Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Fri, 28 Nov 2025 23:16:02 +0100 Subject: [PATCH 32/35] Remove unused function: `split_cmd_args` --- client/ayon_core/lib/transcoding.py | 18 ------------------ 1 file changed, 18 deletions(-) diff --git a/client/ayon_core/lib/transcoding.py b/client/ayon_core/lib/transcoding.py index 076ee79665..726ea62542 100644 --- a/client/ayon_core/lib/transcoding.py +++ b/client/ayon_core/lib/transcoding.py @@ -1281,24 +1281,6 @@ def oiio_color_convert( run_subprocess(oiio_cmd, logger=logger) -def split_cmd_args(in_args): - """Makes sure all entered arguments are separated in individual items. - - Split each argument string with " -" to identify if string contains - one or more arguments. - Args: - in_args (list): of arguments ['-n', '-d uint10'] - Returns - (list): ['-n', '-d', 'unint10'] - """ - splitted_args = [] - for arg in in_args: - if not arg.strip(): - continue - splitted_args.extend(arg.split(" ")) - return splitted_args - - def get_rescaled_command_arguments( application, input_path, From 72249691801ac1784e1bf85a2b070c62a03ccfa5 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Sat, 29 Nov 2025 16:13:55 +0100 Subject: [PATCH 33/35] fix product name template filtering --- client/ayon_core/pipeline/create/product_name.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/client/ayon_core/pipeline/create/product_name.py b/client/ayon_core/pipeline/create/product_name.py index ecffa4a340..5596cec0ce 100644 --- a/client/ayon_core/pipeline/create/product_name.py +++ b/client/ayon_core/pipeline/create/product_name.py @@ -41,8 +41,8 @@ def get_product_name_template( profiles = tools_settings["creator"]["product_name_profiles"] filtering_criteria = { "product_types": product_type, - "hosts": host_name, - "tasks": task_name, + "host_names": host_name, + "task_names": task_name, "task_types": task_type } From 930454ad08832328e98c0b8d2473eafbb3623c75 Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Sun, 30 Nov 2025 21:38:36 +0100 Subject: [PATCH 34/35] Fix missing settings conversion --- server/settings/conversion.py | 1 + 1 file changed, 1 insertion(+) diff --git a/server/settings/conversion.py b/server/settings/conversion.py index 846b91edab..757818a9ff 100644 --- a/server/settings/conversion.py +++ b/server/settings/conversion.py @@ -164,5 +164,6 @@ def convert_settings_overrides( ) -> dict[str, Any]: _convert_imageio_configs_0_3_1(overrides) _convert_imageio_configs_0_4_5(overrides) + _convert_imageio_configs_1_6_5(overrides) _convert_publish_plugins(overrides) return overrides From b0d153ce8745d304108468d78c5ab630f28b0255 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Mon, 1 Dec 2025 10:30:22 +0100 Subject: [PATCH 35/35] remove python from pyproject toml --- client/pyproject.toml | 1 - 1 file changed, 1 deletion(-) diff --git a/client/pyproject.toml b/client/pyproject.toml index c98591b707..5ae71de18b 100644 --- a/client/pyproject.toml +++ b/client/pyproject.toml @@ -3,7 +3,6 @@ name="core" description="AYON core addon." [tool.poetry.dependencies] -python = ">=3.9.1,<3.10" markdown = "^3.4.1" clique = "1.6.*" jsonschema = "^2.6.0"