From fce6c89a7507d98210fcd85eab4631f61e3ada2c Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Fri, 14 Aug 2026 19:45:30 +0200 Subject: [PATCH 1/2] USD Contributions: Allow to publish contributions with Entity URI as version=latest or version=latestDone. Do not republish the instance if the output file matches the previous result (as in, previous file also had version=latest and no new contribution were added) --- client/ayon_core/pipeline/entity_uri.py | 9 +- .../extract_usd_layer_contributions.py | 153 ++++++++++++------ server/settings/conversion.py | 29 +++- server/settings/publish_plugins.py | 35 +++- 4 files changed, 163 insertions(+), 63 deletions(-) diff --git a/client/ayon_core/pipeline/entity_uri.py b/client/ayon_core/pipeline/entity_uri.py index 1362389ee9f..5efdeafb19c 100644 --- a/client/ayon_core/pipeline/entity_uri.py +++ b/client/ayon_core/pipeline/entity_uri.py @@ -74,10 +74,13 @@ def construct_ayon_entity_uri( """ if isinstance(version, int) and version < 0: version = "hero" - if not (isinstance(version, int) or version in {"latest", "hero"}): + if not ( + isinstance(version, int) + or version in {"latest", "latestDone", "hero"} + ): raise ValueError( - "Version must either be integer, 'latest' or 'hero'. " - "Got: {}".format(version) + "Version must either be integer, 'latest', 'latestDone' or " + f"'hero'. Got: {version}" ) return ( "ayon://{project}/{folder_path}?product={product}&version={version}" 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 6f370b4b38d..13c2cf48b60 100644 --- a/client/ayon_core/plugins/publish/extract_usd_layer_contributions.py +++ b/client/ayon_core/plugins/publish/extract_usd_layer_contributions.py @@ -4,7 +4,7 @@ import platform from collections import defaultdict from operator import attrgetter -from typing import Any, Dict, List +from typing import Any, Dict, List, Literal import pyblish.api try: @@ -50,6 +50,13 @@ # all the time at the same time BUILD_INTO_LAST_VERSIONS = True +USDContributionURI = Literal[ + "filepath", + "ayon_entity_uri", + "ayon_entity_uri_latest", + "ayon_entity_uri_latest_approved", +] + @dataclasses.dataclass class _BaseContribution: @@ -142,59 +149,71 @@ def get_representation_path_in_publish_context( def get_instance_uri_path( - instance, - resolve=True -): + instance: pyblish.api.Instance, + uri_mode: USDContributionURI = "filepath" +) -> str: """Return path for instance's usd representation""" context = instance.context - folder_path = instance.data["folderPath"] - product_name = instance.data["productName"] - project_name = context.data["projectName"] - version_name = instance.data["version"] - - # Get the layer's published path - path = construct_ayon_entity_uri( - project_name=project_name, - folder_path=folder_path, - product=product_name, - version=version_name, - representation_name="usd" - ) + project_name: str = context.data["projectName"] + folder_path: str = instance.data["folderPath"] + product_name: str = instance.data["productName"] + version: int = instance.data["version"] + representation_name: str = "usd" + + # Handle AYON entity URI modes + if uri_mode != "filepath": + uri_version: int | str = version + if uri_mode == "ayon_entity_uri_latest": + uri_version = "latest" + elif uri_mode == "ayon_entity_uri_latest_approved": + uri_version = "latestDone" + return construct_ayon_entity_uri( + project_name=project_name, + folder_path=folder_path, + product=product_name, + version=uri_version, + representation_name=representation_name + ) - # Resolve contribution path - # TODO: Remove this when Asset Resolver is used - if resolve: - query = parse_ayon_entity_uri(path) - names = { - "project_name": query["project"], - "folder_path": query["folderPath"], - "product_name": query["product"], - "version_name": query["version"], - "representation_name": query["representation"], - } + # Resolve the layer's published path. + names = { + "project_name": project_name, + "folder_path": folder_path, + "product_name": product_name, + "version_name": version, + "representation_name": representation_name, + } - # We want to resolve the paths live from the publishing context - path = get_representation_path_in_publish_context(context, **names) - if path: - return path + # We want to resolve the paths live from the publishing context. + path = get_representation_path_in_publish_context(context, **names) + if path: + return 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( - anatomy=context.data["anatomy"], - **names - ) - if not path: - raise RuntimeError(f"Unable to resolve publish path for: {names}") + # 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( + 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) - if platform.system().lower() == "windows": - path = path.replace("\\", "/") + # Ensure `None` for now is also a string. + path = str(path) + if platform.system().lower() == "windows": + path = path.replace("\\", "/") return path +def _layer_contents(layer: Sdf.Layer | None) -> str | None: + """Return a stable serialized representation of an SDF layer.""" + if layer is None: + return None + layer: Sdf.Layer + return layer.ExportToString() + + def get_last_publish(instance, representation="usd"): """Wrapper to quickly get last representation publish path""" return get_representation_path_by_names( @@ -722,7 +741,7 @@ class ExtractUSDLayerContribution(publish.Extractor): settings_category = "core" - use_ayon_entity_uri = False + use_ayon_entity_uri: USDContributionURI = "filepath" enforce_default_prim = False def process(self, instance): @@ -734,6 +753,7 @@ def process(self, instance): path = get_last_publish(instance) if path and BUILD_INTO_LAST_VERSIONS: sdf_layer = Sdf.Layer.OpenAsAnonymous(path) + original_contents = _layer_contents(sdf_layer) # If enabled in settings, ignore any default prim specified on # older publish versions and always publish with the AYON @@ -748,11 +768,14 @@ def process(self, instance): default_prim = get_standard_default_prim_name(folder_path) sdf_layer = Sdf.Layer.CreateAnonymous() set_layer_defaults(sdf_layer, default_prim=default_prim) + original_contents = None contributions = instance.data.get("usd_contributions", []) for contribution in sorted(contributions, key=attrgetter("order")): - path = get_instance_uri_path(contribution.instance, - resolve=not self.use_ayon_entity_uri) + path = get_instance_uri_path( + contribution.instance, + uri_mode=self.use_ayon_entity_uri + ) if isinstance(contribution, VariantContribution): # Add contribution as a reference inside a variant self.log.debug(f"Adding variant: {contribution}") @@ -812,6 +835,18 @@ def process(self, instance): else: raise TypeError(f"Unsupported contribution: {contribution}") + # Only publish if there are changes compared to last version, + # otherwise do not generate a new file. + if ( + original_contents is not None + and original_contents == _layer_contents(sdf_layer) + ): + self.log.info( + "USD contribution layer is unchanged; skipping publish." + ) + instance.data["publish"] = False + return + # Save the file staging_dir = self.staging_dir(instance) filename = f"{instance.name}.usd" @@ -836,6 +871,8 @@ def remove_previous_reference_contribution(self, ref: "Sdf.Reference" uri = ref.customData.get("ayon_uri") + if not uri or not parse_ayon_entity_uri(uri): + uri = ref.customData.get("ayon_entity_uri") if uri and self.instance_match_ayon_uri(instance, uri): self.log.debug("Removing existing reference: %s", ref) remove_indices.add(index) @@ -896,7 +933,7 @@ class ExtractUSDAssetContribution(publish.Extractor): settings_category = "core" - use_ayon_entity_uri = False + use_ayon_entity_uri: USDContributionURI = "filepath" def process(self, instance): @@ -909,14 +946,18 @@ def process(self, instance): # Use existing asset and add to it, or initialize a new asset layer path = get_last_publish(instance) payload_layer = None + original_asset_contents = None + original_payload_contents = None if path and BUILD_INTO_LAST_VERSIONS: # If there's a payload file, put it in the payload instead folder = os.path.dirname(path) payload_path = os.path.join(folder, "payload.usd") if os.path.exists(payload_path): payload_layer = Sdf.Layer.OpenAsAnonymous(payload_path) + original_payload_contents = _layer_contents(payload_layer) asset_layer = Sdf.Layer.OpenAsAnonymous(path) + original_asset_contents = _layer_contents(asset_layer) else: # If no existing publish of this product exists then we initialize # the layer as either a default asset or shot structure. @@ -974,8 +1015,10 @@ def sort_by_order(instance): layer_id = layer_instance.data["usd_layer_id"] order = layer_instance.data["usd_layer_order"] - path = get_instance_uri_path(instance=layer_instance, - resolve=not self.use_ayon_entity_uri) + path = get_instance_uri_path( + instance=layer_instance, + uri_mode=self.use_ayon_entity_uri + ) add_ordered_sublayer(target_layer, contribution_path=path, layer_id=layer_id, @@ -984,6 +1027,16 @@ def sort_by_order(instance): # us to later detect whether another path # has the same layer id, so we can replace it. add_sdf_arguments_metadata=True) + if ( + original_asset_contents is not None + and original_asset_contents == _layer_contents(asset_layer) + and original_payload_contents == _layer_contents(payload_layer) + ): + self.log.info( + "USD asset contribution is unchanged; skipping publish." + ) + instance.data["publish"] = False + return # Save the file staging_dir = self.staging_dir(instance) diff --git a/server/settings/conversion.py b/server/settings/conversion.py index 6e44cceafa7..ccfaf50e885 100644 --- a/server/settings/conversion.py +++ b/server/settings/conversion.py @@ -287,11 +287,36 @@ def _convert_oiio_transcode_0_4_5(publish_overrides): } -def _convert_publish_plugins(overrides): +def _convert_usd_contribution_uri_modes_1_9_11( + publish_overrides, + version: VersionInfo +): + """Convert legacy USD contribution URI booleans to URI modes.""" + if (version.major, version.minor, version.patch) >= (1, 9, 11): + return + for plugin_name in ( + "ExtractUSDAssetContribution", + "ExtractUSDLayerContribution", + ): + plugin_settings = publish_overrides.get(plugin_name) + if not plugin_settings: + continue + + value = plugin_settings.get("use_ayon_entity_uri") + if not isinstance(value, bool): + continue + + plugin_settings["use_ayon_entity_uri"] = ( + "ayon_entity_uri" if value else "filepath" + ) + + +def _convert_publish_plugins(overrides, version: VersionInfo): if "publish" not in overrides: return _convert_validate_version_0_3_3(overrides["publish"]) _convert_oiio_transcode_0_4_5(overrides["publish"]) + _convert_usd_contribution_uri_modes_1_9_11(overrides["publish"], version) def _convert_extract_thumbnail(overrides, version: VersionInfo): @@ -412,7 +437,7 @@ def convert_settings_overrides( _convert_imageio_configs_0_4_5(overrides) _convert_product_name_templates_1_6_5(overrides) _convert_product_name_templates_1_7_0(overrides) - _convert_publish_plugins(overrides) + _convert_publish_plugins(overrides, version) _convert_extract_thumbnail(overrides, version) _convert_product_base_types_1_8_0(overrides) _convert_unify_profile_keys_1_8_0(overrides) diff --git a/server/settings/publish_plugins.py b/server/settings/publish_plugins.py index dba9a0bd54f..b822230aa5d 100644 --- a/server/settings/publish_plugins.py +++ b/server/settings/publish_plugins.py @@ -476,14 +476,33 @@ def validate_unique_resolution_options(cls, value): return value +def usd_contribution_path_types(): + return [ + {"value": "filepath", "label": "Filepath"}, + { + "value": "ayon_entity_uri", + "label": "AYON Entity URI (explicit version)" + }, + { + "value": "ayon_entity_uri_latest", + "label": "AYON Entity URI as latest version" + }, + { + "value": "ayon_entity_uri_latest_approved", + "label": "AYON Entity URI as latest approved" + }, + ] + + class AyonEntityURIModel(BaseSettingsModel): - use_ayon_entity_uri: bool = SettingsField( - title="Use AYON Entity URI", + use_ayon_entity_uri: str = SettingsField( + "filepath", + title="Contribution path", description=( - "When enabled the USD paths written using the contribution " - "workflow will use ayon entity URIs instead of resolved published " - "paths. You can only load these if you use the AYON USD Resolver." - ) + "Choose how paths written by the USD contribution workflow are " + "authored. Entity URI options require the AYON USD Resolver." + ), + enum_resolver=usd_contribution_path_types, ) @@ -2102,10 +2121,10 @@ class PublishPuginsModel(BaseSettingsModel): ] }, "ExtractUSDAssetContribution": { - "use_ayon_entity_uri": False, + "use_ayon_entity_uri": "filepath", }, "ExtractUSDLayerContribution": { - "use_ayon_entity_uri": False, + "use_ayon_entity_uri": "filepath", "enforce_default_prim": False, }, "PreIntegrateThumbnails": { From 071d437edcceeb4e2d5f3c2d078079b9755093ed Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Wed, 19 Aug 2026 12:37:04 +0200 Subject: [PATCH 2/2] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .../plugins/publish/extract_usd_layer_contributions.py | 6 +++++- 1 file changed, 5 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 13c2cf48b60..dda2fa9e746 100644 --- a/client/ayon_core/plugins/publish/extract_usd_layer_contributions.py +++ b/client/ayon_core/plugins/publish/extract_usd_layer_contributions.py @@ -161,7 +161,11 @@ def get_instance_uri_path( representation_name: str = "usd" # Handle AYON entity URI modes - if uri_mode != "filepath": + if uri_mode in { + "ayon_entity_uri", + "ayon_entity_uri_latest", + "ayon_entity_uri_latest_approved", + }: uri_version: int | str = version if uri_mode == "ayon_entity_uri_latest": uri_version = "latest"