diff --git a/src/deadline/houdini_submitter/otls/deadline_cloud.hda/Driver_1deadline__cloud/DialogScript b/src/deadline/houdini_submitter/otls/deadline_cloud.hda/Driver_1deadline__cloud/DialogScript index bc4dad0..1f1c631 100644 --- a/src/deadline/houdini_submitter/otls/deadline_cloud.hda/Driver_1deadline__cloud/DialogScript +++ b/src/deadline/houdini_submitter/otls/deadline_cloud.hda/Driver_1deadline__cloud/DialogScript @@ -712,6 +712,14 @@ parmtag { "script_callback_language" "python" } help "Automatically save the scene (.hip) file to $HIP when submitting a job." } + parm { + name "arnold_auto_configure" + label "Auto-configure Arnold ROPs for export" + type toggle + default { "1" } + parmtag { "script_callback_language" "python" } + help "Automatically detect Arnold ROPs in the input network and configure them for safe .ass export before submission. Enables ar_ass_export_enable, clears ar_picture (prevents hython hang on 'ip'), and sets logging parameters." + } } group { @@ -850,6 +858,16 @@ joinnext default { "" } } + parm { + name "export_ass" + label "Export .ass" + type button + joinnext + default { "0" } + parmtag { "script_callback" "hou.phm().callback(kwargs)" } + parmtag { "script_callback_language" "python" } + help "Export Arnold .ass files locally from all Arnold ROPs in the input network. Configures ROPs for safe export, renders them, and reports the exported files." + } parm { name "save_bundle" label "Save Bundle" diff --git a/src/deadline/houdini_submitter/otls/deadline_cloud.hda/Driver_1deadline__cloud/PythonModule b/src/deadline/houdini_submitter/otls/deadline_cloud.hda/Driver_1deadline__cloud/PythonModule index b9fd427..b5135cf 100644 --- a/src/deadline/houdini_submitter/otls/deadline_cloud.hda/Driver_1deadline__cloud/PythonModule +++ b/src/deadline/houdini_submitter/otls/deadline_cloud.hda/Driver_1deadline__cloud/PythonModule @@ -1 +1 @@ -from deadline_cloud_for_houdini.submitter import callback, update_queue_parameters_callback \ No newline at end of file +from deadline_cloud_for_houdini.submitter import callback, update_queue_parameters_callback, export_ass_callback \ No newline at end of file diff --git a/src/deadline/houdini_submitter/python/deadline_cloud_for_houdini/_assets.py b/src/deadline/houdini_submitter/python/deadline_cloud_for_houdini/_assets.py index 9f714db..531b444 100644 --- a/src/deadline/houdini_submitter/python/deadline_cloud_for_houdini/_assets.py +++ b/src/deadline/houdini_submitter/python/deadline_cloud_for_houdini/_assets.py @@ -326,9 +326,16 @@ def _usd_render_outputs(node: hou.Node) -> set[str]: return output_directories +def _arnold_outputs(node: hou.Node) -> set[str]: + """Get Arnold output directories, prioritizing .ass export path over ar_picture.""" + from .arnold_utils import get_arnold_ass_output_directories + + return get_arnold_ass_output_directories(node) + + _NODE_DIR_MAP = { "Driver/alembic": "filename", # Alembic - "Driver/arnold": "ar_picture", # Arnold + "Driver/arnold": _arnold_outputs, # Arnold (.ass export aware) "Driver/baketexture::3.0": "vm_uvoutputpicture1", # Bake Texture "Driver/channel": "chopoutput", # Channel "Driver/comp": "copoutput", # Composite diff --git a/src/deadline/houdini_submitter/python/deadline_cloud_for_houdini/arnold_utils.py b/src/deadline/houdini_submitter/python/deadline_cloud_for_houdini/arnold_utils.py new file mode 100644 index 0000000..85198cc --- /dev/null +++ b/src/deadline/houdini_submitter/python/deadline_cloud_for_houdini/arnold_utils.py @@ -0,0 +1,257 @@ +# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + +""" +Arnold ROP utility functions for the Deadline Cloud Houdini submitter. + +Provides detection, configuration, and output directory resolution for Arnold +ROP nodes. Handles known Arnold pitfalls: +- ar_picture set to 'ip' causes hython to hang (interactive render mode) +- ar_ass_export_enable may be disabled, preventing .ass file generation +- Arnold menu parameters expect strings, not integers (type conversion needed) +""" + +from __future__ import annotations + +import logging +import os +import re +import tempfile +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + import hou + +from .hip_settings import ArnoldExportSettings + +logger = logging.getLogger(__name__) + + +def is_arnold_rop(node: "hou.Node") -> bool: + """Check if a node is an Arnold ROP (Driver/arnold).""" + try: + return node.type().name() == "arnold" + except Exception: + return False + + +def find_arnold_rops_in_network(rop_node: "hou.Node") -> list["hou.Node"]: + """Walk inputAncestors() and return all Arnold ROP nodes.""" + arnold_rops = [] + for ancestor in rop_node.inputAncestors(): + if is_arnold_rop(ancestor): + arnold_rops.append(ancestor) + return arnold_rops + + +def _set_parm_safe(parm: "hou.Parm", value) -> None: + """Set a parameter value with automatic type conversion for menu parameters. + + Arnold menu parameters (like ar_log_verbosity, ar_ass_export_enable) are + string-typed in Houdini but often receive integer values. This function + handles the TypeError fallback by converting int → str. + """ + if isinstance(value, bool): + parm.set(1 if value else 0) + return + + try: + parm.set(value) + except TypeError: + if isinstance(value, int): + parm.set(str(value)) + else: + raise + + +def configure_arnold_rop_for_export(rop: "hou.Node", settings: ArnoldExportSettings) -> None: + """Apply safe export configuration to an Arnold ROP before submission. + + Addresses known Arnold pitfalls: + - Sets ar_ass_export_enable = 1 so .ass files are actually written + - Clears ar_picture to prevent hython hanging on 'ip' (interactive render) + - Sets logging and license-failure parameters + - Optionally overrides ar_ass_file output path + + All parameter sets use try/except with string fallback for menu params. + Missing optional parameters are silently skipped. + """ + params_to_set: dict[str, tuple] = {} + + if settings.enable_ass_export: + params_to_set["ar_ass_export_enable"] = (1, False) + + if settings.disable_image_render: + params_to_set["ar_picture"] = ("", False) + + params_to_set["ar_log_verbosity"] = (settings.log_verbosity, False) + params_to_set["ar_log_console_enable"] = (1, False) + params_to_set["ar_abort_on_license_fail"] = (1, False) + + if settings.ass_output_path: + params_to_set["ar_ass_file"] = (settings.ass_output_path, True) + + for param_name, (param_value, is_required) in params_to_set.items(): + parm = rop.parm(param_name) + if parm is None: + if is_required: + raise RuntimeError( + f"Required parameter '{param_name}' not found on Arnold ROP '{rop.path()}'" + ) + continue + + try: + _set_parm_safe(parm, param_value) + except Exception as e: + logger.warning("Failed to set '%s' on '%s': %s", param_name, rop.path(), e) + + +def _snapshot_parms(rop: "hou.Node", parm_names: list[str]) -> dict[str, str]: + """Capture the unexpanded string values of parameters for later restore.""" + snapshot = {} + for name in parm_names: + parm = rop.parm(name) + if parm is not None: + try: + snapshot[name] = parm.unexpandedString() + except Exception: + snapshot[name] = parm.evalAsString() + return snapshot + + +def _restore_parms(rop: "hou.Node", snapshot: dict[str, str]) -> None: + """Restore parameter values from a snapshot.""" + for name, value in snapshot.items(): + parm = rop.parm(name) + if parm is not None: + try: + parm.set(value) + except Exception: + logger.warning("Failed to restore '%s' on '%s'", name, rop.path()) + + +# Parameters that configure_arnold_rop_for_export may modify +_ARNOLD_EXPORT_PARM_NAMES = [ + "ar_ass_export_enable", + "ar_picture", + "ar_log_verbosity", + "ar_log_console_enable", + "ar_abort_on_license_fail", + "ar_ass_file", +] + + +def export_arnold_ass_locally( + rop: "hou.Node", + settings: ArnoldExportSettings, + frame_range: tuple[int, int, int] | None = None, + restore_parms: bool = True, +) -> list[str]: + """Configure an Arnold ROP and render it locally to produce .ass files. + + Args: + rop: The Arnold ROP node to export from. + settings: Export configuration settings. + frame_range: Optional (start, end, step) tuple. If None, renders current frame. + restore_parms: If True, restore original ROP parameter values after export. + + Returns: + List of .ass file paths that were written. + """ + import hou as _hou + + # Snapshot original values so we can restore after export + original_parms = _snapshot_parms(rop, _ARNOLD_EXPORT_PARM_NAMES) if restore_parms else {} + + try: + # Configure the ROP for .ass export + configure_arnold_rop_for_export(rop, settings) + + # Ensure ar_ass_file has a value + ass_parm = rop.parm("ar_ass_file") + if ass_parm is None: + raise RuntimeError(f"Arnold ROP '{rop.path()}' has no ar_ass_file parameter") + + ass_path = ass_parm.eval() + if not ass_path: + # Set a default output path + hip = _hou.getenv("HIP", tempfile.gettempdir()) + default_path = f"{hip}/ass/{rop.name()}.$F4.ass" + ass_parm.set(default_path) + ass_path = ass_parm.eval() + logger.info("Set default .ass output path: %s", default_path) + + # Create output directory if needed + out_dir = os.path.dirname(ass_path) + if out_dir: + os.makedirs(out_dir, exist_ok=True) + + # Render (this triggers the .ass export) + # hou.RopNode.render() accepts frame_range as a 2- or 3-element tuple: + # (start, end) or (start, end, step) + if frame_range: + rop.render(frame_range=frame_range, ignore_inputs=True) + else: + rop.render(ignore_inputs=True) + + # Collect exported files + exported_files = [] + import glob as _glob + + # Replace frame tokens with glob pattern to find exported files + unexpanded = ass_parm.unexpandedString() + glob_pattern = re.sub(r"\$F\d*|\${F\d*}|\$FF|\${FF}", "*", unexpanded) + # Evaluate remaining Houdini variables in the glob pattern + # Use try/finally to guarantee parm is restored even on error + orig = ass_parm.unexpandedString() + try: + ass_parm.set(glob_pattern) + evaluated_glob = ass_parm.eval() + except Exception: + evaluated_glob = glob_pattern + finally: + ass_parm.set(orig) + + for f in sorted(_glob.iglob(evaluated_glob)): + exported_files.append(f) + + if not exported_files: + # Single frame case — check the evaluated path directly + if os.path.isfile(ass_path): + exported_files.append(ass_path) + + return exported_files + finally: + # Restore original ROP parameters so the scene isn't permanently modified + if restore_parms and original_parms: + _restore_parms(rop, original_parms) + + +def get_arnold_ass_output_directories(node: "hou.Node") -> set[str]: + """Get output directories from an Arnold ROP. + + Prioritizes ar_ass_file (the .ass export path) over ar_picture. + Handles Houdini time variables ($F, $F4, etc.) via glob pattern replacement + so that the directory portion is still valid. + """ + from ._assets import _houdini_time_vars_to_glob + + output_dirs: set[str] = set() + + # Check ar_ass_file first (preferred for .ass export) + for parm_name in ("ar_ass_file", "ar_picture"): + parm = node.parm(parm_name) + if parm is None: + continue + path = parm.eval() + if not path or path == "ip": + continue + # Strip time variables before taking dirname + clean_path = _houdini_time_vars_to_glob(path) + dirname = os.path.dirname(clean_path) + if dirname: + output_dirs.add(dirname) + # If we got a valid dir from ar_ass_file, prefer that over ar_picture + if output_dirs and parm_name == "ar_ass_file": + break + + return output_dirs diff --git a/src/deadline/houdini_submitter/python/deadline_cloud_for_houdini/hip_settings.py b/src/deadline/houdini_submitter/python/deadline_cloud_for_houdini/hip_settings.py index cbb40ea..4445b6d 100644 --- a/src/deadline/houdini_submitter/python/deadline_cloud_for_houdini/hip_settings.py +++ b/src/deadline/houdini_submitter/python/deadline_cloud_for_houdini/hip_settings.py @@ -6,6 +6,16 @@ from .constants import FrameRange, RenderStrategy +@dataclass +class ArnoldExportSettings: + """Settings controlling how Arnold ROPs are configured before submission.""" + + enable_ass_export: bool = True + disable_image_render: bool = True + log_verbosity: int = 2 + ass_output_path: str = "" + + @dataclass class HoudiniSubmitterUISettings: """Settings that the submitter UI will use.""" @@ -29,3 +39,7 @@ class HoudiniSubmitterUISettings: include_adaptor_wheels: bool = field(default=False, metadata={"sticky": True}) adaptor_wheels_dir: Optional[str] = field(default=None, metadata={"sticky": True}) + + # Arnold export settings + arnold_auto_configure: bool = field(default=True, metadata={"sticky": True}) + arnold_settings: ArnoldExportSettings = field(default_factory=ArnoldExportSettings) diff --git a/src/deadline/houdini_submitter/python/deadline_cloud_for_houdini/houdini_submitter_widget.py b/src/deadline/houdini_submitter/python/deadline_cloud_for_houdini/houdini_submitter_widget.py index 252ddbc..1842f3f 100644 --- a/src/deadline/houdini_submitter/python/deadline_cloud_for_houdini/houdini_submitter_widget.py +++ b/src/deadline/houdini_submitter/python/deadline_cloud_for_houdini/houdini_submitter_widget.py @@ -4,7 +4,7 @@ import hou from qtpy.QtCore import Qt # type: ignore -from qtpy.QtWidgets import QCheckBox, QWidget, QGridLayout +from qtpy.QtWidgets import QCheckBox, QWidget, QGridLayout, QLabel from .hip_settings import HoudiniSubmitterUISettings @@ -56,6 +56,38 @@ def _build_ui(self) -> None: self.auto_save_hip_check = QCheckBox("Automatically save scene (.hip) file", self) layout.addWidget(self.auto_save_hip_check, qt_pos_index, 0) + # Arnold Export Settings + qt_pos_index += 1 + arnold_label = QLabel("Arnold Export Settings") + arnold_label.setStyleSheet("font-weight: bold; margin-top: 8px;") + layout.addWidget(arnold_label, qt_pos_index, 0) + + qt_pos_index += 1 + self.arnold_auto_configure_check = QCheckBox("Auto-configure Arnold ROPs for export", self) + self.arnold_auto_configure_check.setToolTip( + "Automatically configure Arnold ROP parameters before submission " + "to ensure proper .ass file export and prevent known issues." + ) + layout.addWidget(self.arnold_auto_configure_check, qt_pos_index, 0) + + qt_pos_index += 1 + self.arnold_ass_export_check = QCheckBox("Enable .ass file export", self) + self.arnold_ass_export_check.setToolTip( + "Set ar_ass_export_enable = 1 on Arnold ROPs so .ass files are written." + ) + layout.addWidget(self.arnold_ass_export_check, qt_pos_index, 0) + + qt_pos_index += 1 + self.arnold_disable_image_check = QCheckBox("Disable image render (prevent hang)", self) + self.arnold_disable_image_check.setToolTip( + "Clear ar_picture to prevent hython from hanging when set to 'ip' " + "(interactive render mode)." + ) + layout.addWidget(self.arnold_disable_image_check, qt_pos_index, 0) + + # Wire Arnold auto-configure to enable/disable sub-options + self.arnold_auto_configure_check.stateChanged.connect(self._arnold_auto_configure_changed) + def _load(self, settings: HoudiniSubmitterUISettings) -> None: """ Populates the UI elements with the contents of `settings`. @@ -68,9 +100,22 @@ def _load(self, settings: HoudiniSubmitterUISettings) -> None: self.include_adaptor_wheels_check.setChecked(settings.include_adaptor_wheels) self.adaptor_wheels_directory_picker.setEnabled(settings.include_adaptor_wheels) + # Arnold settings + self.arnold_auto_configure_check.setChecked(settings.arnold_auto_configure) + self.arnold_ass_export_check.setChecked(settings.arnold_settings.enable_ass_export) + self.arnold_disable_image_check.setChecked(settings.arnold_settings.disable_image_render) + self._arnold_auto_configure_changed( + Qt.Checked if settings.arnold_auto_configure else Qt.Unchecked + ) + def _include_adaptor_wheels_changed(self, state): self.adaptor_wheels_directory_picker.setEnabled(Qt.CheckState(state) == Qt.Checked) + def _arnold_auto_configure_changed(self, state): + enabled = Qt.CheckState(state) == Qt.Checked + self.arnold_ass_export_check.setEnabled(enabled) + self.arnold_disable_image_check.setEnabled(enabled) + def update_settings(self, settings: HoudiniSubmitterUISettings) -> None: """ Updates the Houdini submitter settings according to values set in the job settings panel. @@ -86,3 +131,8 @@ def update_settings(self, settings: HoudiniSubmitterUISettings) -> None: settings.auto_unlock_rops = self.auto_unlock_rops_check.isChecked() settings.auto_parse_hip = self.auto_parse_hip_check.isChecked() settings.auto_save_hip = self.auto_save_hip_check.isChecked() + + # Arnold settings + settings.arnold_auto_configure = self.arnold_auto_configure_check.isChecked() + settings.arnold_settings.enable_ass_export = self.arnold_ass_export_check.isChecked() + settings.arnold_settings.disable_image_render = self.arnold_disable_image_check.isChecked() diff --git a/src/deadline/houdini_submitter/python/deadline_cloud_for_houdini/submitter.py b/src/deadline/houdini_submitter/python/deadline_cloud_for_houdini/submitter.py index b126141..bcfefb7 100644 --- a/src/deadline/houdini_submitter/python/deadline_cloud_for_houdini/submitter.py +++ b/src/deadline/houdini_submitter/python/deadline_cloud_for_houdini/submitter.py @@ -35,7 +35,6 @@ import hou - _NONE_SELECTED_TEXT = "" _REFRESHING_TEXT = "" @@ -397,6 +396,13 @@ def _get_step_template(node: Dict, ignore_input_nodes: bool): "wedgenum": node["wedgenum"], "wedge_node": node["wedge_node"], } + + # Propagate Arnold license server to farm workers if set (Arnold ROPs only) + rop_node = hou.node(node["rop"]) + if rop_node and rop_node.type().name() == "arnold": + arnold_license = os.environ.get("ADSKFLEX_LICENSE_FILE", "") + if arnold_license: + init_data["arnold_license_server"] = arnold_license init_data_attachment = { "name": "initData", "filename": "init-data.yaml", @@ -489,12 +495,133 @@ def _create_job_bundle( deadline_yaml_dump(asset_references.to_dict(), f, indent=1) +def _auto_configure_arnold_rops(node: hou.Node) -> list: + """Detect Arnold ROPs in the input network and optionally configure them. + + Checks the ``arnold_auto_configure`` parm on *node* (defaults to True when + the parm is absent). + + Returns: + List of Arnold ROPs found (empty list if none detected). + When auto-configure is enabled the ROPs are modified in-place and the + scene is saved before returning. + """ + from .arnold_utils import find_arnold_rops_in_network, configure_arnold_rop_for_export + from .arnold_utils import ArnoldExportSettings as _ArnoldExportSettings + + arnold_rops = find_arnold_rops_in_network(node) + if not arnold_rops: + return [] + + auto_configure_parm = node.parm("arnold_auto_configure") + should_configure = auto_configure_parm is None or auto_configure_parm.eval() + + if not should_configure: + return arnold_rops + + arnold_settings = _ArnoldExportSettings( + enable_ass_export=True, + disable_image_render=True, + log_verbosity=2, + ) + configured_rops = [] + for rop in arnold_rops: + configure_arnold_rop_for_export(rop, arnold_settings) + configured_rops.append(rop) + hou.hipFile.save() + + return configured_rops + + def callback(kwargs): """ROP parameter callback wrapper""" function_name = f"{kwargs['parm'].name()}_callback" globals()[function_name](kwargs) +_ARNOLD_EXPORT_TITLE = "Arnold .ass Export" + + +def _get_parm_int(node: hou.Node, name: str, default: int = 0) -> int: + """Read an integer parameter, returning *default* when the parm is absent.""" + parm = node.parm(name) + return int(parm.eval()) if parm else default + + +def _get_frame_range_from_node(node: hou.Node): + """Return a (start, end, step) tuple or None for current-frame-only.""" + trange = _get_parm_int(node, "trange", 0) + if trange == 0: + return None + return ( + _get_parm_int(node, "f1", 1), + _get_parm_int(node, "f2", 1), + _get_parm_int(node, "f3", 1), + ) + + +def _report_ass_export_results(all_exported: list[str], errors: list[str]) -> None: + """Show a Houdini UI message summarising the .ass export outcome.""" + if errors: + hou.ui.displayMessage( + f"Exported {len(all_exported)} .ass file(s) with {len(errors)} error(s).", + title=_ARNOLD_EXPORT_TITLE, + severity=hou.severityType.Warning, + details="\n".join(errors + ["", "Exported files:"] + all_exported), + ) + elif all_exported: + hou.ui.displayMessage( + f"Exported {len(all_exported)} .ass file(s) successfully.", + title=_ARNOLD_EXPORT_TITLE, + details="\n".join(all_exported), + ) + else: + hou.ui.displayMessage( + "Export completed but no .ass files were found on disk.\n" + "Check that ar_ass_file is set on your Arnold ROP(s).", + title=_ARNOLD_EXPORT_TITLE, + severity=hou.severityType.Warning, + ) + + +def export_ass_callback(kwargs): + """Export .ass files locally from all Arnold ROPs wired into the Deadline Cloud node.""" + node = kwargs["node"] + from .arnold_utils import ( + find_arnold_rops_in_network, + export_arnold_ass_locally, + ArnoldExportSettings as _ArnoldExportSettings, + ) + + arnold_rops = find_arnold_rops_in_network(node) + if not arnold_rops: + hou.ui.displayMessage( + "No Arnold ROPs found in the input network.", + title=_ARNOLD_EXPORT_TITLE, + severity=hou.severityType.Warning, + ) + return + + settings = _ArnoldExportSettings( + enable_ass_export=True, + disable_image_render=True, + log_verbosity=2, + ) + frame_range = _get_frame_range_from_node(node) + + all_exported = [] + errors = [] + for rop in arnold_rops: + try: + exported = export_arnold_ass_locally(rop, settings, frame_range) + all_exported.extend(exported) + except Exception as exc: + errors.append(f"{rop.path()}: {exc}") + + hou.hipFile.save() + _report_ass_export_results(all_exported, errors) + + def parse_files_callback(kwargs): node = kwargs["node"] _parse_files(node) @@ -502,6 +629,10 @@ def parse_files_callback(kwargs): def save_bundle_callback(kwargs): node = kwargs["node"] + + # Arnold ROP pre-configuration (same as submit_callback) + _auto_configure_arnold_rops(node) + name = node.parm("name").evalAsString() asset_references = _get_evaluated_asset_references(node) try: @@ -588,6 +719,10 @@ def submit_callback(kwargs): # check hip is listed in input_filenames hip_file = _get_hip_file() + + # Arnold ROP pre-configuration: detect and configure Arnold ROPs for safe export + _auto_configure_arnold_rops(node) + hip_input = hip_file in asset_references.input_filenames if not hip_input: auto_parse = node.parm("auto_parse_hip").eval() diff --git a/test/unit/deadline_submitter_for_houdini/test_arnold_callbacks.py b/test/unit/deadline_submitter_for_houdini/test_arnold_callbacks.py new file mode 100644 index 0000000..b1ffbb4 --- /dev/null +++ b/test/unit/deadline_submitter_for_houdini/test_arnold_callbacks.py @@ -0,0 +1,151 @@ +# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + +"""Unit tests for Arnold-related callbacks and helpers in submitter.py.""" + +from unittest import mock +from unittest.mock import MagicMock + +from .mock_hou import hou_module as hou + +from deadline.houdini_submitter.python.deadline_cloud_for_houdini.submitter import ( + export_ass_callback, + _auto_configure_arnold_rops, + _get_frame_range_from_node, + _get_parm_int, + _report_ass_export_results, +) + +_ARNOLD_UTILS = "deadline.houdini_submitter.python.deadline_cloud_for_houdini.arnold_utils" + + +def _make_arnold_rop(name="arnold1"): + node = MagicMock() + node.type().name.return_value = "arnold" + node.path.return_value = f"/out/{name}" + node.name.return_value = name + return node + + +def _make_deadline_node(trange=0, f1=1, f2=10, f3=1, auto_configure=True): + node = MagicMock() + parm_map = { + "trange": MagicMock(**{"eval.return_value": trange}), + "f1": MagicMock(**{"eval.return_value": f1}), + "f2": MagicMock(**{"eval.return_value": f2}), + "f3": MagicMock(**{"eval.return_value": f3}), + "arnold_auto_configure": MagicMock(**{"eval.return_value": auto_configure}), + } + node.parm = MagicMock(side_effect=lambda n: parm_map.get(n)) + return node + + +class TestAutoConfigureArnoldRops: + @mock.patch(f"{_ARNOLD_UTILS}.configure_arnold_rop_for_export") + @mock.patch(f"{_ARNOLD_UTILS}.find_arnold_rops_in_network") + def test_no_arnold_rops(self, mock_find, mock_configure): + mock_find.return_value = [] + result = _auto_configure_arnold_rops(_make_deadline_node()) + assert result == [] + mock_configure.assert_not_called() + + @mock.patch(f"{_ARNOLD_UTILS}.configure_arnold_rop_for_export") + @mock.patch(f"{_ARNOLD_UTILS}.find_arnold_rops_in_network") + def test_configures_when_enabled(self, mock_find, mock_configure): + mock_find.return_value = [_make_arnold_rop("a1"), _make_arnold_rop("a2")] + result = _auto_configure_arnold_rops(_make_deadline_node(auto_configure=True)) + assert len(result) == 2 + assert mock_configure.call_count == 2 + hou.hipFile.save.assert_called_once() + + @mock.patch(f"{_ARNOLD_UTILS}.configure_arnold_rop_for_export") + @mock.patch(f"{_ARNOLD_UTILS}.find_arnold_rops_in_network") + def test_skips_when_disabled(self, mock_find, mock_configure): + mock_find.return_value = [_make_arnold_rop()] + result = _auto_configure_arnold_rops(_make_deadline_node(auto_configure=False)) + assert len(result) == 1 + mock_configure.assert_not_called() + + @mock.patch(f"{_ARNOLD_UTILS}.configure_arnold_rop_for_export") + @mock.patch(f"{_ARNOLD_UTILS}.find_arnold_rops_in_network") + def test_defaults_when_parm_missing(self, mock_find, mock_configure): + mock_find.return_value = [_make_arnold_rop()] + node = MagicMock() + node.parm = MagicMock(return_value=None) + result = _auto_configure_arnold_rops(node) + assert len(result) == 1 + mock_configure.assert_called_once() + + +class TestExportAssCallback: + @mock.patch(f"{_ARNOLD_UTILS}.find_arnold_rops_in_network") + def test_no_rops_shows_warning(self, mock_find): + mock_find.return_value = [] + export_ass_callback({"node": _make_deadline_node()}) + hou.ui.displayMessage.assert_called_once() + assert "No Arnold ROPs found" in hou.ui.displayMessage.call_args[0][0] + + @mock.patch(f"{_ARNOLD_UTILS}.export_arnold_ass_locally") + @mock.patch(f"{_ARNOLD_UTILS}.find_arnold_rops_in_network") + def test_success(self, mock_find, mock_export): + mock_find.return_value = [_make_arnold_rop()] + mock_export.return_value = ["/renders/scene.0001.ass"] + export_ass_callback({"node": _make_deadline_node(trange=0)}) + mock_export.assert_called_once() + assert "1 .ass file(s) successfully" in hou.ui.displayMessage.call_args[0][0] + + @mock.patch(f"{_ARNOLD_UTILS}.export_arnold_ass_locally") + @mock.patch(f"{_ARNOLD_UTILS}.find_arnold_rops_in_network") + def test_frame_range_when_trange_nonzero(self, mock_find, mock_export): + mock_find.return_value = [_make_arnold_rop()] + mock_export.return_value = ["/renders/scene.0001.ass"] + export_ass_callback({"node": _make_deadline_node(trange=1, f1=5, f2=20, f3=2)}) + assert mock_export.call_args[0][2] == (5, 20, 2) + + @mock.patch(f"{_ARNOLD_UTILS}.export_arnold_ass_locally") + @mock.patch(f"{_ARNOLD_UTILS}.find_arnold_rops_in_network") + def test_partial_failure(self, mock_find, mock_export): + mock_find.return_value = [_make_arnold_rop("a1"), _make_arnold_rop("a2")] + mock_export.side_effect = [["/renders/scene.0001.ass"], RuntimeError("License fail")] + export_ass_callback({"node": _make_deadline_node(trange=0)}) + assert "1 error(s)" in hou.ui.displayMessage.call_args[0][0] + + @mock.patch(f"{_ARNOLD_UTILS}.export_arnold_ass_locally") + @mock.patch(f"{_ARNOLD_UTILS}.find_arnold_rops_in_network") + def test_no_files_warning(self, mock_find, mock_export): + mock_find.return_value = [_make_arnold_rop()] + mock_export.return_value = [] + export_ass_callback({"node": _make_deadline_node(trange=0)}) + assert "no .ass files were found" in hou.ui.displayMessage.call_args[0][0] + + +class TestGetParmInt: + def test_returns_value(self): + node = _make_deadline_node(trange=1) + assert _get_parm_int(node, "trange", 0) == 1 + + def test_returns_default_when_missing(self): + node = _make_deadline_node() + assert _get_parm_int(node, "nonexistent", 42) == 42 + + +class TestGetFrameRangeFromNode: + def test_current_frame(self): + assert _get_frame_range_from_node(_make_deadline_node(trange=0)) is None + + def test_frame_range(self): + node = _make_deadline_node(trange=1, f1=5, f2=20, f3=2) + assert _get_frame_range_from_node(node) == (5, 20, 2) + + +class TestReportAssExportResults: + def test_success(self): + _report_ass_export_results(["/renders/scene.0001.ass"], []) + assert "1 .ass file(s) successfully" in hou.ui.displayMessage.call_args[0][0] + + def test_errors(self): + _report_ass_export_results([], ["error1"]) + assert "1 error(s)" in hou.ui.displayMessage.call_args[0][0] + + def test_no_files(self): + _report_ass_export_results([], []) + assert "no .ass files were found" in hou.ui.displayMessage.call_args[0][0] diff --git a/test/unit/deadline_submitter_for_houdini/test_arnold_utils.py b/test/unit/deadline_submitter_for_houdini/test_arnold_utils.py new file mode 100644 index 0000000..f6ad4b3 --- /dev/null +++ b/test/unit/deadline_submitter_for_houdini/test_arnold_utils.py @@ -0,0 +1,248 @@ +# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + +"""Unit tests for arnold_utils module.""" + +from unittest.mock import MagicMock +import pytest +from deadline.houdini_submitter.python.deadline_cloud_for_houdini.arnold_utils import ( + ArnoldExportSettings, + is_arnold_rop, + find_arnold_rops_in_network, + configure_arnold_rop_for_export, + get_arnold_ass_output_directories, + _set_parm_safe, + _snapshot_parms, + _restore_parms, +) + + +def _make_node(type_name="arnold", parms=None): + node = MagicMock() + node.type().name.return_value = type_name + node.path.return_value = f"/out/{type_name}1" + parm_dict = parms or {} + node.parm = MagicMock(side_effect=lambda n: parm_dict.get(n)) + return node + + +def _make_parm(value="", raises_type_error=False): + parm = MagicMock() + parm.eval.return_value = value + if raises_type_error: + parm.set = MagicMock(side_effect=[TypeError("wrong type"), None]) + else: + parm.set = MagicMock() + return parm + + +# --- is_arnold_rop --- + + +class TestIsArnoldRop: + def test_arnold_node(self): + assert is_arnold_rop(_make_node("arnold")) is True + + def test_non_arnold_node(self): + assert is_arnold_rop(_make_node("ifd")) is False + + def test_exception_returns_false(self): + node = MagicMock() + node.type.side_effect = Exception("broken") + assert is_arnold_rop(node) is False + + +# --- find_arnold_rops_in_network --- + + +class TestFindArnoldRopsInNetwork: + def test_finds_arnold_rops(self): + a1 = _make_node("arnold") + a2 = _make_node("arnold") + root = MagicMock() + root.inputAncestors.return_value = [a1, _make_node("ifd"), a2] + result = find_arnold_rops_in_network(root) + assert len(result) == 2 + + def test_empty_network(self): + root = MagicMock() + root.inputAncestors.return_value = [] + assert find_arnold_rops_in_network(root) == [] + + +# --- _set_parm_safe --- + + +class TestSetParmSafe: + def test_set_int(self): + parm = _make_parm() + _set_parm_safe(parm, 2) + parm.set.assert_called_once_with(2) + + def test_set_string(self): + parm = _make_parm() + _set_parm_safe(parm, "/renders/out.ass") + parm.set.assert_called_once_with("/renders/out.ass") + + def test_set_bool_true(self): + parm = _make_parm() + _set_parm_safe(parm, True) + parm.set.assert_called_once_with(1) + + def test_set_bool_false(self): + parm = _make_parm() + _set_parm_safe(parm, False) + parm.set.assert_called_once_with(0) + + def test_type_error_fallback_int_to_string(self): + parm = _make_parm(raises_type_error=True) + _set_parm_safe(parm, 2) + assert parm.set.call_count == 2 + parm.set.assert_called_with("2") + + def test_type_error_non_int_reraises(self): + parm = MagicMock() + parm.set = MagicMock(side_effect=TypeError("wrong type")) + with pytest.raises(TypeError): + _set_parm_safe(parm, [1, 2, 3]) + + +# --- configure_arnold_rop_for_export --- + + +class TestConfigureArnoldRopForExport: + def test_default_settings(self): + parms = { + "ar_ass_export_enable": _make_parm(), + "ar_picture": _make_parm("ip"), + "ar_log_verbosity": _make_parm(), + "ar_log_console_enable": _make_parm(), + "ar_abort_on_license_fail": _make_parm(), + } + node = _make_node("arnold", parms) + configure_arnold_rop_for_export(node, ArnoldExportSettings()) + for p in parms.values(): + p.set.assert_called() + + def test_custom_output_path(self): + parms = { + "ar_ass_export_enable": _make_parm(), + "ar_picture": _make_parm(), + "ar_log_verbosity": _make_parm(), + "ar_log_console_enable": _make_parm(), + "ar_abort_on_license_fail": _make_parm(), + "ar_ass_file": _make_parm(), + } + node = _make_node("arnold", parms) + configure_arnold_rop_for_export( + node, ArnoldExportSettings(ass_output_path="/renders/scene.$F4.ass") + ) + parms["ar_ass_file"].set.assert_called_with("/renders/scene.$F4.ass") + + def test_missing_required_param_raises(self): + parms = { + "ar_ass_export_enable": _make_parm(), + "ar_picture": _make_parm(), + "ar_log_verbosity": _make_parm(), + "ar_log_console_enable": _make_parm(), + "ar_abort_on_license_fail": _make_parm(), + } + node = _make_node("arnold", parms) + with pytest.raises(RuntimeError, match="Required parameter"): + configure_arnold_rop_for_export( + node, ArnoldExportSettings(ass_output_path="/renders/out.ass") + ) + + def test_missing_optional_params_skipped(self): + parms = {"ar_picture": _make_parm("ip")} + node = _make_node("arnold", parms) + configure_arnold_rop_for_export(node, ArnoldExportSettings()) + parms["ar_picture"].set.assert_called() + + def test_disable_ass_export(self): + parms = { + "ar_picture": _make_parm("ip"), + "ar_log_verbosity": _make_parm(), + "ar_log_console_enable": _make_parm(), + "ar_abort_on_license_fail": _make_parm(), + } + node = _make_node("arnold", parms) + configure_arnold_rop_for_export(node, ArnoldExportSettings(enable_ass_export=False)) + assert node.parm("ar_ass_export_enable") is None + + def test_keep_image_render(self): + parms = { + "ar_ass_export_enable": _make_parm(), + "ar_log_verbosity": _make_parm(), + "ar_log_console_enable": _make_parm(), + "ar_abort_on_license_fail": _make_parm(), + } + node = _make_node("arnold", parms) + configure_arnold_rop_for_export(node, ArnoldExportSettings(disable_image_render=False)) + assert node.parm("ar_picture") is None + + +# --- _snapshot_parms / _restore_parms --- + + +class TestSnapshotRestore: + def test_snapshot_captures_values(self): + parm = MagicMock() + parm.unexpandedString.return_value = "$HIP/render.ass" + node = MagicMock() + node.parm = MagicMock(side_effect=lambda n: parm if n == "ar_ass_file" else None) + result = _snapshot_parms(node, ["ar_ass_file", "missing_parm"]) + assert result == {"ar_ass_file": "$HIP/render.ass"} + + def test_snapshot_falls_back_to_eval_as_string(self): + parm = MagicMock() + parm.unexpandedString.side_effect = Exception("no unexpanded") + parm.evalAsString.return_value = "/var/scenes/render.ass" + node = MagicMock() + node.parm = MagicMock(return_value=parm) + result = _snapshot_parms(node, ["ar_ass_file"]) + assert result == {"ar_ass_file": "/var/scenes/render.ass"} + + def test_restore_sets_values(self): + parm = MagicMock() + node = MagicMock() + node.parm = MagicMock(return_value=parm) + _restore_parms(node, {"ar_ass_file": "$HIP/render.ass"}) + parm.set.assert_called_once_with("$HIP/render.ass") + + def test_restore_handles_missing_parm(self): + node = MagicMock() + node.parm = MagicMock(return_value=None) + _restore_parms(node, {"missing": "value"}) # should not raise + + +# --- get_arnold_ass_output_directories --- + + +class TestGetArnoldAssOutputDirectories: + def test_ass_file_path(self): + parms = { + "ar_ass_file": _make_parm("/renders/scene.0001.ass"), + "ar_picture": _make_parm("/renders/image.0001.exr"), + } + node = _make_node("arnold", parms) + assert get_arnold_ass_output_directories(node) == {"/renders"} + + def test_falls_back_to_ar_picture(self): + parms = { + "ar_ass_file": _make_parm(""), + "ar_picture": _make_parm("/output/beauty.0001.exr"), + } + node = _make_node("arnold", parms) + assert get_arnold_ass_output_directories(node) == {"/output"} + + def test_skips_ip_picture(self): + parms = { + "ar_ass_file": _make_parm(""), + "ar_picture": _make_parm("ip"), + } + node = _make_node("arnold", parms) + assert get_arnold_ass_output_directories(node) == set() + + def test_no_parms(self): + node = _make_node("arnold", {}) + assert get_arnold_ass_output_directories(node) == set()