diff --git a/client/ayon_houdini/api/lib.py b/client/ayon_houdini/api/lib.py index 6a5f12c875..c938fd6481 100644 --- a/client/ayon_houdini/api/lib.py +++ b/client/ayon_houdini/api/lib.py @@ -109,6 +109,107 @@ def get_output_parameter(node): raise TypeError("Node type '%s' not supported" % node_type) +def get_lops_rop_context_options( + ropnode: hou.RopNode) -> "dict[str, str | float]": + """Return the Context Options that a LOP ROP node uses.""" + rop_context_options: "dict[str, str | float]" = {} + + # Always set @ropname and @roppath + # See: https://www.sidefx.com/docs/houdini/hom/hou/isAutoContextOption.html + rop_context_options["ropname"] = ropnode.name() + rop_context_options["roppath"] = ropnode.path() + + # Set @ropcook, @ropstart, @ropend and @ropinc if setropcook is enabled + setropcook_parm = ropnode.parm("setropcook") + if setropcook_parm: + setropcook = setropcook_parm.eval() + if setropcook: + # TODO: Support "Render Frame Range from Stage" correctly + # TODO: Support passing in the start, end, and increment values + # for the cases where this may need to consider overridden + # frame ranges for `RopNode.render()` calls. + trange = ropnode.evalParm("trange") + if trange == 0: + # Current frame + start: float = hou.frame() + end: float = start + inc: float = 1.0 + elif trange in {1, 2}: + # Frame range + start: float = ropnode.evalParm("f1") + end: float = ropnode.evalParm("f2") + inc: float = ropnode.evalParm("f3") + else: + raise ValueError("Unsupported trange value: %s" % trange) + rop_context_options["ropcook"] = 1.0 + rop_context_options["ropstart"] = start + rop_context_options["ropend"] = end + rop_context_options["ropinc"] = inc + + # Get explicit context options set on the ROP node. + num = ropnode.evalParm("optioncount") + for i in range(1, num + 1): + # Ignore disabled options + if not ropnode.evalParm(f"optionenable{i}"): + continue + + name: str = ropnode.evalParm(f"optionname{i}") + option_type: str = ropnode.evalParm(f"optiontype{i}") + if option_type == "string": + value: str = ropnode.evalParm(f"optionstrvalue{i}") + elif option_type == "float": + value: float = ropnode.evalParm(f"optionfloatvalue{i}") + else: + raise ValueError(f"Unsupported option type: {option_type}") + rop_context_options[name] = value + + return rop_context_options + + +@contextmanager +def context_options(context_options: "dict[str, str | float]"): + """Context manager to set Solaris Context Options. + + The original context options are restored after the context exits. + + Arguments: + context_options (dict[str, str | float]): + The Solaris Context Options to set. + + Yields: + dict[str, str | float]: The original context options that were changed. + + """ + # Get the original context options and their values + original_context_options: "dict[str, str | float]" = {} + for name in hou.contextOptionNames(): + original_context_options[name] = hou.contextOption(name) + + try: + # Override the context options + for name, value in context_options.items(): + hou.setContextOption(name, value) + yield original_context_options + finally: + # Restore original context options that we changed + for name in context_options: + if name in original_context_options: + hou.setContextOption(name, original_context_options[name]) + else: + # Clear context option + hou.setContextOption(name, None) + + +@contextmanager +def update_mode_context(mode): + original = hou.updateModeSetting() + try: + hou.setUpdateMode(mode) + yield + finally: + hou.setUpdateMode(original) + + def set_scene_fps(fps): hou.setFps(fps) diff --git a/client/ayon_houdini/plugins/publish/collect_output_node.py b/client/ayon_houdini/plugins/publish/collect_output_node.py index ff51669376..c7af43ec6a 100644 --- a/client/ayon_houdini/plugins/publish/collect_output_node.py +++ b/client/ayon_houdini/plugins/publish/collect_output_node.py @@ -6,7 +6,7 @@ class CollectOutputSOPPath(plugin.HoudiniInstancePlugin): """Collect the out node's SOP/COP Path value.""" - order = pyblish.api.CollectorOrder + order = pyblish.api.CollectorOrder - 0.45 families = [ "pointcache", "camera", @@ -16,7 +16,9 @@ class CollectOutputSOPPath(plugin.HoudiniInstancePlugin): "usdrender", "redshiftproxy", "staticMesh", - "model" + "model", + "usdrender", + "usdrop" ] label = "Collect Output Node Path" diff --git a/client/ayon_houdini/plugins/publish/collect_render_products.py b/client/ayon_houdini/plugins/publish/collect_render_products.py index 9dea2364f8..7223f1ffe9 100644 --- a/client/ayon_houdini/plugins/publish/collect_render_products.py +++ b/client/ayon_houdini/plugins/publish/collect_render_products.py @@ -46,7 +46,7 @@ def process(self, instance): filenames = [] files_by_product = {} - stage = node.stage() + stage = instance.data["stage"] for prim_path in self.get_render_products(rop_node, stage): prim = stage.GetPrimAtPath(prim_path) if not prim or not prim.IsA(pxr.UsdRender.Product): diff --git a/client/ayon_houdini/plugins/publish/collect_usd_look_assets.py b/client/ayon_houdini/plugins/publish/collect_usd_look_assets.py index 0874cef0b6..210b02d3da 100644 --- a/client/ayon_houdini/plugins/publish/collect_usd_look_assets.py +++ b/client/ayon_houdini/plugins/publish/collect_usd_look_assets.py @@ -6,7 +6,6 @@ import dataclasses import pyblish.api -import hou from pxr import Sdf from ayon_houdini.api import plugin @@ -71,23 +70,13 @@ class CollectUsdLookAssets(plugin.HoudiniInstancePlugin): def process(self, instance): - rop: hou.RopNode = hou.node(instance.data.get("instance_node")) - if not rop: + # Get Sdf.Layers from "Collect ROP Sdf Layers and USD Stage" plug-in + layers = instance.data.get("layers") + if layers is None: + self.log.warning(f"No USD layers found on instance: {instance}") return - lop_node: hou.LopNode = instance.data.get("output_node") - if not lop_node: - return - - above_break_layers = set(lop_node.layersAboveLayerBreak()) - - stage = lop_node.stage() - layers = [ - layer for layer - in stage.GetLayerStack(includeSessionLayers=False) - if layer.identifier not in above_break_layers - ] - + layers: List[Sdf.Layer] instance_resources = self.get_layer_assets(layers) # Define a relative asset remapping for the USD Extractor so that diff --git a/client/ayon_houdini/plugins/publish/collect_usd_rop_layer_and_stage.py b/client/ayon_houdini/plugins/publish/collect_usd_rop_layer_and_stage.py new file mode 100644 index 0000000000..097c67d492 --- /dev/null +++ b/client/ayon_houdini/plugins/publish/collect_usd_rop_layer_and_stage.py @@ -0,0 +1,141 @@ +import json +import contextlib +from typing import Dict + +import hou +from pxr import Sdf, Usd +import pyblish.api + +from ayon_houdini.api import plugin +from ayon_houdini.api.lib import ( + get_lops_rop_context_options, + context_options, + update_mode_context +) + + +def copy_stage_layers(stage) -> Dict[Sdf.Layer, Sdf.Layer]: + """Copy a stage's anonymous layers to new in-memory layers. + + The layers will be remapped to use the copied layers instead of the + original layers if e.g. the root layer uses one of the other layers. + + Returns: + Dict[Sdf.Layer, Sdf.Layer]: Mapping from original layers to + copied layers. + """ + # Create a mapping from original layers to their copies + layer_mapping: Dict[Sdf.Layer, Sdf.Layer] = {} + + # Copy each layer + for layer in stage.GetLayerStack(includeSessionLayers=False): + if not layer.anonymous: + # We disregard non-anonymous layers for replacing and assume + # they are static enough for our use case. + continue + + copied_layer = Sdf.Layer.CreateAnonymous() + copied_layer.TransferContent(layer) + layer_mapping[layer] = copied_layer + + # Remap all used layers in the root layer + copied_root_layer = layer_mapping[stage.GetRootLayer()] + for old_layer, new_layer in layer_mapping.items(): + copied_root_layer.UpdateCompositionAssetDependency( + old_layer.identifier, + new_layer.identifier + ) + + return layer_mapping + + +class CollectUsdRenderLayerAndStage(plugin.HoudiniInstancePlugin): + """Collect USD stage and layers below layer break for USD ROPs. + + This collects an in-memory copy of the Usd.Stage that can be used in other + collectors and validations. It also collects the Sdf.Layer objects up to + the layer break (ignoring any above). + + It only creates an in-memory copy of anonymous layers and assumes that any + intended to live on disk are already static written to disk files or at + least the loaded Sdf.Layer to not be updated during publishing. + + It collects the stage and layers from the LOP node connected to the USD ROP + with the context options set on the ROP node. This ensures the graph is + evaluated similar to how the ROP node would process it on export. + + """ + + label = "Collect ROP Sdf Layers and USD Stage" + # Run after Collect Output Node + order = pyblish.api.CollectorOrder + hosts = ["houdini"] + families = ["usdrender", "usdrop"] + + def process(self, instance): + + lop_node = instance.data.get("output_node") + if not lop_node: + return + + lop_node: hou.LopNode + rop: hou.RopNode = hou.node(instance.data["instance_node"]) + options = get_lops_rop_context_options(rop) + + # Log the context options + self.log.debug( + "Collecting USD stage with context options:\n" + f"{json.dumps(options, indent=4)}") + + with contextlib.ExitStack() as stack: + # Force cooking the lop node does not seem to work, so we + # must set the cook mode to "Update" for this to work + stack.enter_context(update_mode_context(hou.updateMode.AutoUpdate)) + + # Set the context options of the ROP node. + stack.enter_context(context_options(options)) + + # Force cook. There have been some cases where the LOP node + # just would not return the USD stage without force cooking it. + lop_node.cook(force=True) + + # Get stage and layers from the LOP node. + stage = lop_node.stage(use_last_cook_context_options=False, + apply_viewport_overrides=False, + apply_post_layers=False) + if stage is None: + self.log.error( + "Unable to get USD stage from LOP node: " + f"{lop_node.path()}. It may have failed to cook due to " + "errors in the node graph.") + return + + above_break_layers = set(lop_node.layersAboveLayerBreak( + use_last_cook_context_options=False)) + layers = [ + layer for layer + in stage.GetLayerStack(includeSessionLayers=False) + if layer.identifier not in above_break_layers + ] + + # The returned stage and layer in memory is shared across cooks + # so it is the exact same stage and layer object each time if + # multiple ROPs point to the same LOP node (or its layer's graph). + # As such, we must explicitly copy the stage and layers to ensure + # the next 'cook' does not affect the stage and layers of the + # previous instance or by any other process down the line. + # Get a copy of the stage and layers so that any in houdini edit + # or another recook from another instance of the same LOP layers + # does not influence this collected stage and layers. + copied_layer_mapping = copy_stage_layers(stage) + copied_stage = Usd.Stage.Open( + copied_layer_mapping[stage.GetRootLayer()]) + copied_layers = [ + # Remap layers only that were remapped (anonymous layers + # only). If the layer was not remapped, then use the + # original + copied_layer_mapping.get(layer, layer) for layer in layers + ] + + instance.data["layers"] = copied_layers + instance.data["stage"] = copied_stage \ No newline at end of file diff --git a/client/ayon_houdini/plugins/publish/validate_usd_look_assignments.py b/client/ayon_houdini/plugins/publish/validate_usd_look_assignments.py index e5037454dd..49d3a5dbaa 100644 --- a/client/ayon_houdini/plugins/publish/validate_usd_look_assignments.py +++ b/client/ayon_houdini/plugins/publish/validate_usd_look_assignments.py @@ -1,6 +1,5 @@ # -*- coding: utf-8 -*- import inspect -import hou from pxr import Usd, UsdShade, UsdGeom import pyblish.api @@ -54,16 +53,18 @@ def process(self, instance): if not self.is_active(instance.data): return - lop_node: hou.LopNode = instance.data.get("output_node") - if not lop_node: + # Get Usd.Stage from "Collect ROP Sdf Layers and USD Stage" plug-in + stage = instance.data.get("stage") + if not stage: + self.log.debug("No USD stage found.") return + stage: Usd.Stage # We iterate the composed stage for code simplicity; however this # means that it does not validate across e.g. multiple model variants # but only checks against the current composed stage. Likely this is # also what you actually want to validate, because your look might not # apply to *all* model variants. - stage = lop_node.stage() invalid = [] for prim in stage.Traverse(): if not prim.IsA(UsdGeom.Gprim): diff --git a/client/ayon_houdini/plugins/publish/validate_usd_look_contents.py b/client/ayon_houdini/plugins/publish/validate_usd_look_contents.py index 43357cdb35..1b4cb29b83 100644 --- a/client/ayon_houdini/plugins/publish/validate_usd_look_contents.py +++ b/client/ayon_houdini/plugins/publish/validate_usd_look_contents.py @@ -3,7 +3,6 @@ from typing import List, Union from functools import partial -import hou from pxr import Sdf import pyblish.api @@ -44,20 +43,11 @@ class ValidateUsdLookContents(plugin.HoudiniInstancePlugin): def process(self, instance): - lop_node: hou.LopNode = instance.data.get("output_node") - if not lop_node: - return - - # Get layers below layer break - above_break_layers = set(layer for layer in lop_node.layersAboveLayerBreak()) - stage = lop_node.stage() - layers = [ - layer for layer - in stage.GetLayerStack(includeSessionLayers=False) - if layer.identifier not in above_break_layers - ] + # Get Sdf.Layers from "Collect ROP Sdf Layers and USD Stage" plug-in + layers = instance.data.get("layers") if not layers: return + layers: List[Sdf.Layer] # The Sdf.PrimSpec type name will not have knowledge about inherited # types for the type, name. So we pre-collect all invalid types diff --git a/client/ayon_houdini/plugins/publish/validate_usd_look_material_defs.py b/client/ayon_houdini/plugins/publish/validate_usd_look_material_defs.py index 273bf46b18..60dc635f25 100644 --- a/client/ayon_houdini/plugins/publish/validate_usd_look_material_defs.py +++ b/client/ayon_houdini/plugins/publish/validate_usd_look_material_defs.py @@ -1,7 +1,8 @@ # -*- coding: utf-8 -*- import inspect -import hou -from pxr import Sdf, UsdShade +from typing import List + +from pxr import Sdf, Usd, UsdShade import pyblish.api from ayon_core.pipeline.publish import ( @@ -34,21 +35,12 @@ def process(self, instance): if not self.is_active(instance.data): return - lop_node: hou.LopNode = instance.data.get("output_node") - if not lop_node: - return - - # Get layers below layer break - above_break_layers = set( - layer for layer in lop_node.layersAboveLayerBreak()) - stage = lop_node.stage() - layers = [ - layer for layer - in stage.GetLayerStack(includeSessionLayers=False) - if layer.identifier not in above_break_layers - ] + # Get Sdf.Layers from "Collect ROP Sdf Layers and USD Stage" plug-in + layers = instance.data.get("layers") if not layers: return + stage: Usd.Stage = instance.data["stage"] + layers: List[Sdf.Layer] # The Sdf.PrimSpec type name will not have knowledge about inherited # types for the type, name. So we pre-collect all invalid types diff --git a/client/ayon_houdini/plugins/publish/validate_usd_render_arnold.py b/client/ayon_houdini/plugins/publish/validate_usd_render_arnold.py index 67d1aa605a..1a589b1a6d 100644 --- a/client/ayon_houdini/plugins/publish/validate_usd_render_arnold.py +++ b/client/ayon_houdini/plugins/publish/validate_usd_render_arnold.py @@ -163,7 +163,7 @@ def process(self, instance): # Validate Arnold Product Type is enabled on the Arnold Render Settings # This is confirmed by the `includeAovs` attribute on the RenderProduct - stage: pxr.Usd.Stage = node.stage() + stage: pxr.Usd.Stage = instance.data["stage"] invalid = False for prim_path in instance.data.get("usdRenderProducts", []): prim = stage.GetPrimAtPath(prim_path) @@ -223,8 +223,7 @@ def process(self, instance): # be validated by another plug-in. return - stage = lop_node.stage() - + stage = instance.data["stage"] render_settings = get_usd_render_rop_rendersettings(rop_node, stage, logger=self.log) if not render_settings: diff --git a/client/ayon_houdini/plugins/publish/validate_usd_rop_default_prim.py b/client/ayon_houdini/plugins/publish/validate_usd_rop_default_prim.py index ee4746f73f..284dd31c71 100644 --- a/client/ayon_houdini/plugins/publish/validate_usd_rop_default_prim.py +++ b/client/ayon_houdini/plugins/publish/validate_usd_rop_default_prim.py @@ -1,5 +1,7 @@ # -*- coding: utf-8 -*- import inspect +from typing import List + import hou from pxr import Sdf import pyblish.api @@ -30,20 +32,12 @@ def process(self, instance): ) return - lop_node: hou.LopNode = instance.data.get("output_node") - if not lop_node: - return - - above_break_layers = set(layer for layer in lop_node.layersAboveLayerBreak()) - stage = lop_node.stage() - layers = [ - layer for layer - in stage.GetLayerStack(includeSessionLayers=False) - if layer.identifier not in above_break_layers - ] + # Get Sdf.Layers from "Collect ROP Sdf Layers and USD Stage" plug-in + layers = instance.data.get("layers") if not layers: self.log.error("No USD layers found. This is likely a bug.") return + layers: List[Sdf.Layer] # TODO: This only would detect any local opinions on that prim and thus # would fail to detect if a sublayer added on the stage root layer diff --git a/client/ayon_houdini/plugins/publish/validate_vdb_output_node.py b/client/ayon_houdini/plugins/publish/validate_vdb_output_node.py index c4ed9d2fb8..6bdf6cea0a 100644 --- a/client/ayon_houdini/plugins/publish/validate_vdb_output_node.py +++ b/client/ayon_houdini/plugins/publish/validate_vdb_output_node.py @@ -1,11 +1,11 @@ # -*- coding: utf-8 -*- -import contextlib import hou import pyblish.api from ayon_core.pipeline import PublishXmlValidationError from ayon_houdini.api import plugin +from ayon_houdini.api.lib import update_mode_context from ayon_houdini.api.action import SelectInvalidAction @@ -42,16 +42,6 @@ def _result(a, b): yield _result(start, end) -@contextlib.contextmanager -def update_mode_context(mode): - original = hou.updateModeSetting() - try: - hou.setUpdateMode(mode) - yield - finally: - hou.setUpdateMode(original) - - def get_geometry_at_frame(sop_node, frame, force=True): """Return geometry at frame but force a cooked value.""" if not hasattr(sop_node, "geometry"):