Skip to content

Commit

Permalink
Merge pull request #145 from BigRoy/enhancement/solaris_rop_context_o…
Browse files Browse the repository at this point in the history
…ptions

Support Houdini context options from the ROP node for the USD collectors + validations
  • Loading branch information
BigRoy authored Oct 28, 2024
2 parents b1a3364 + abfff1b commit beff26f
Show file tree
Hide file tree
Showing 11 changed files with 275 additions and 76 deletions.
101 changes: 101 additions & 0 deletions client/ayon_houdini/api/lib.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
6 changes: 4 additions & 2 deletions client/ayon_houdini/plugins/publish/collect_output_node.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -16,7 +16,9 @@ class CollectOutputSOPPath(plugin.HoudiniInstancePlugin):
"usdrender",
"redshiftproxy",
"staticMesh",
"model"
"model",
"usdrender",
"usdrop"
]

label = "Collect Output Node Path"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
21 changes: 5 additions & 16 deletions client/ayon_houdini/plugins/publish/collect_usd_look_assets.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@
import dataclasses

import pyblish.api
import hou
from pxr import Sdf

from ayon_houdini.api import plugin
Expand Down Expand Up @@ -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
Expand Down
141 changes: 141 additions & 0 deletions client/ayon_houdini/plugins/publish/collect_usd_rop_layer_and_stage.py
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
# -*- coding: utf-8 -*-
import inspect
import hou
from pxr import Usd, UsdShade, UsdGeom

import pyblish.api
Expand Down Expand Up @@ -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):
Expand Down
16 changes: 3 additions & 13 deletions client/ayon_houdini/plugins/publish/validate_usd_look_contents.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@
from typing import List, Union
from functools import partial

import hou
from pxr import Sdf
import pyblish.api

Expand Down Expand Up @@ -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
Expand Down
Loading

0 comments on commit beff26f

Please sign in to comment.