Skip to content

feat: add Arnold AOV support - #266

Open
ZainAallii wants to merge 1 commit into
aws-deadline:mainlinefrom
ZainAallii:arnold-aov-support
Open

feat: add Arnold AOV support#266
ZainAallii wants to merge 1 commit into
aws-deadline:mainlinefrom
ZainAallii:arnold-aov-support

Conversation

@ZainAallii

Copy link
Copy Markdown
Contributor

Wire Arnold AOVs through the render handler so per-frame AOV filenames are unique and AOV_Manager.outputPath is remapped to the worker's output directory. Arnold AOVs are managed by renderer.AOV_Manager (not the standard render element manager), so the handler drives the manager directly.

What was the problem/requirement? (What/Why)

Arnold AOVs did not render correctly for animations through the adaptor, for two reasons:

  1. Filename collisions. Arnold writes one file per AOV_Manager driver, and each file's basename comes from that driver's filenameSuffix (e.g. AOVs.exr). There is no per-frame token in the name, so every frame overwrote the previous frame's AOV output — a hard blocker for animation.
  2. Hard-coded output path. AOV_Manager.outputPath is baked into the scene at author time and points at the artist's local filesystem, which isn't writable on a Deadline Cloud worker.

Unlike V-Ray render elements, Arnold AOVs are managed by renderer.AOV_Manager (an ArnoldAOVsManager reference target), not by 3ds Max's standard RenderElementManager, so this required driving the AOV manager directly from the handler.

What was the solution? (How)

  • _apply_path_mapping remaps AOV_Manager.outputPath through the adaptor's map_path after scene load.
  • _configure_renderer_output sets AOV_Manager.outputPath to the job's output directory and rewrites each driver's filenameSuffix to <output_name>_<original_suffix>, giving every frame a unique per-driver filename. If a driver's suffix is empty, _AOV is used as a safety tag so the AOV file doesn't collide with the beauty pass.
  • Original suffixes are cached per driver index and restored via _restore_arnold_aov_drivers (invoked from the existing cleanup_render_elements framework hook). A try/finally guarantees the cache is cleared even if a restore raises. The cache also prevents suffix compounding across frames within a session.
  • check_renderer uses startswith("Arnold") for version robustness.
  • Every defensive except Exception logs a warning; no silent failures.
  • Beauty pass rendering is unchanged — _configure_renderer_output returns False, so the framework's existing rt.render(...) call still writes the main output. (Contrast with VrayHandler, which returns True in raw-output modes because V-Ray writes the beauty pass into its own container.)

What is the impact of this change?

  • Multi-frame Arnold jobs with AOVs now produce one file per frame per driver, instead of each frame overwriting the last.
  • Arnold AOV output lands in the job's output directory on workers, where job attachments can pick it up.
  • No change to the beauty pass path, and no change to jobs that don't use AOVs.

How was this change tested?

  • 16 new unit tests in test_arnold_aov.py covering path remapping (including "undefined" and unchanged paths), per-frame suffix mutation, the empty-suffix _AOV fallback, once-only caching / no compounding across frames, cleanup restoration, and the always-clear-cache path. Plus a versioned check_renderer test case in test_arnold_handler.py.
  • hatch run test: full suite passes.
  • OpenJD runner locally: 3 frames × a 3-driver scene produced frame-unique files with no collisions.
  • Deadline Cloud submission: rendered globe_rotation.max (2 AOV drivers) across multiple frames. Per-frame logs confirm outputPath remapping and per-driver filenameSuffix mutation with no compounding; all frames exited 0 and uploaded to S3.

Was this change documented?

Inline docstrings on the class (explaining the divergence from V-Ray's render-element path) and on each helper method. No user-facing docs required.

Did you modify schema files?

  • Yes
  • No

If yes, Did you bump the integration_data_interface_version in src/deadline/max_adaptor/MaxAdaptor/adaptor.py and update the test constants?
See the "Adaptor Schema Files" section in DEVELOPMENT.md for details.

  • Yes
  • No

Is this a breaking change?

No. The beauty pass is unchanged and non-AOV jobs are unaffected. Existing AOV jobs previously produced only the last frame's output; they now correctly produce one file per frame per driver.


By submitting this pull request, I confirm that you can use, modify, copy, and redistribute this contribution, under the terms of your choice.

@ZainAallii
ZainAallii requested a review from a team as a code owner July 9, 2026 22:23
@github-actions github-actions Bot added the waiting-on-maintainers Waiting on the maintainers to review. label Jul 9, 2026
Comment thread src/deadline/max_adaptor/MaxClient/render_handlers/arnold_handler.py Outdated
Comment thread src/deadline/max_adaptor/MaxClient/render_handlers/arnold_handler.py Outdated
Comment thread src/deadline/max_adaptor/MaxClient/render_handlers/arnold_handler.py Outdated

@leongdl leongdl left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for this contribution — it fixes two real blockers for Arnold animation jobs (per-frame AOV filename collisions and the scene-baked outputPath), and the quality bar here is high: the docstrings explaining why Arnold diverges from the V-Ray render-element path are exactly what a future maintainer needs, _iter_drivers cleanly quarantines the defensive pymxs access pattern, the cache-once-across-frames test is the right regression test, and the verification story (OpenJD runner + real Deadline Cloud submission) is stronger than most PRs.

I traced the three flows (scene load → _apply_path_mapping; per-frame start_render_configure_renderer_output; session end → cleanup_render_elements → restore) and have three changes to request plus one recommendation.

Requested changes

1. Asymmetric pymxs undefined handling can leak the literal string "undefined" into filenames and the restore cache

_apply_path_mapping explicitly normalizes pymxs's "undefined" stringification, but the suffix read in _configure_renderer_output uses str(drv.filenameSuffix or ""). pymxs undefined is not guaranteed falsy across MAXtoA versions — if it's truthy, a driver with an unset suffix produces files named frame0001_undefined.exr, and worse, "undefined" gets cached and written back into the scene at cleanup. Suggest a small shared helper (e.g. _normalize_mxs_str(value) -> str that returns "" for None/"undefined") used in both places.

2. Two empty-suffix drivers still collide with each other

The _AOV safety tag correctly distinguishes empty-suffix AOV output from the beauty pass, but two drivers that both have empty suffixes compose to the same <output_name>_AOV.<ext> and overwrite each other — the exact failure mode this PR exists to fix, just one level down. Suggest including the driver index in the fallback: f"{output_name}_AOV{i}".

3. Misleading log: the _apply_path_mapping remap is always clobbered before any render

Every render path goes through start_render_configure_renderer_output, which unconditionally sets aov_mgr.outputPath = output_dir (and start_render raises earlier if output_dir is unset). So the remapped value never survives to a render — yet the code logs "Remapped Arnold AOV outputPath: X -> Y", which will send anyone debugging output paths from worker logs chasing a value that was overwritten. Either drop the outputPath remap from _apply_path_mapping, or keep it as an explicit documented fallback and reword the log so it can't be mistaken for the effective render-time path (e.g. "...(will be overridden by job output directory at render time)").

Recommended (non-blocking)

  • The PR description mentions tests for the "undefined" and unchanged-path remap cases, but I don't see them in the diff — adding them would also pin the fix for item 1.
  • Test helper _make_handler_with_aov_manager starts the rt patcher before returning it; if anything raises between start() and the caller's try (e.g. handler construction), rt stays patched for the rest of the test session. A pytest fixture or @contextmanager yielding (handler, aov_mgr, drivers) fixes that and deletes all eight try/finally patcher.stop() blocks.

Edge cases I checked and cleared, so you don't need to re-investigate: non-Arnold renderer active at map time (caught, warns, no-ops), multi-frame suffix compounding (prevented by cache-once, tested), restore raising mid-loop (finally clears the cache), and output_dir creation ordering (base creates it after the hook, before rt.render).

Happy to approve once items 1–3 are addressed — they're all small, same-file changes.

🤖 AI-assisted review 🤖

@leongdl

leongdl commented Jul 14, 2026

Copy link
Copy Markdown
Contributor

Summary. (me trying to understand the problem)

PR #266 fixes two real blockers for Arnold animation jobs: per-frame AOV filename collisions (every frame overwrote the last, since Arnold names AOV files purely from
each driver's filenameSuffix) and the scene-baked AOV_Manager.outputPath pointing at the artist's local disk. Because Arnold AOVs live in renderer.AOV_Manager rather than 3ds Max's RenderElementManager, the
handler drives the manager directly instead of reusing the V-Ray render-element path — that architectural choice is correct and well documented.

Call-stack traces

Flow 1 — scene load / path mapping:

  1. MaxClient action "scene_file"
  2. DefaultMaxHandler.set_scene_file rt.loadMaxFile(...)
  3. if self.map_path is not None: (base guards None already)
  4. ArnoldHandler._apply_path_mapping re-checks map_path (redundant, harmless)
  5. _get_aov_manager → rt.renderers.current.AOV_Manager
  6. read outputPath → map_path() → write back

Flow 2 — per frame:

  1. action "start_render" {frame}
  2. DefaultMaxHandler.start_render resolves tokens, frame padding, basename
  3. ArnoldHandler.configure_renderer_output sets AOV outputPath = output_dir;
    per driver: cache original suffix ONCE,
    set suffix = f"{output_name}
    {orig}"
  4. returns False → base makes output_dir, calls rt.render(camera, outputFile=...)

Flow 3 — session end:

  1. action "cleanup_render_elements"
  2. ArnoldHandler.cleanup_render_elements
  3. _restore_arnold_aov_drivers restore suffixes, finally: cache.clear()
  4. super().cleanup_render_elements standard render-element cleanup

@ZainAallii
ZainAallii force-pushed the arnold-aov-support branch from 355a170 to f0523a5 Compare July 17, 2026 20:55
Comment thread src/deadline/max_adaptor/MaxClient/render_handlers/arnold_handler.py Outdated
@ZainAallii
ZainAallii force-pushed the arnold-aov-support branch from f0523a5 to f9d7eac Compare July 23, 2026 18:56
@ZainAallii
ZainAallii force-pushed the arnold-aov-support branch 2 times, most recently from 181968c to 64ded63 Compare July 29, 2026 16:44
@ZainAallii
ZainAallii force-pushed the arnold-aov-support branch 2 times, most recently from f7e9dbe to 1cacf1a Compare July 29, 2026 18:08
Wire Arnold AOVs through a dedicated render handler so per-frame AOV filenames are unique and AOV_Manager.outputPath is remapped to the worker's output directory. Arnold AOVs are managed by renderer.AOV_Manager (not the standard render element manager), so the handler drives the manager directly.

- Per frame, force AOV_Manager.outputPath to the job output directory and inject the resolved output_name into each driver's filenameSuffix so frames no longer overwrite each other.
- Capture original suffixes once (positionally) and restore them at session end; empty-suffix drivers fall back to _AOV<index> to avoid collisions.
- Normalize pymxs None/'undefined' via _normalize_mxs_str so the sentinel never leaks into filenames or the restore cache.
- check_renderer matches on startswith('Arnold'); _configure_renderer_output returns False so the framework still writes the beauty pass.
- Add unit tests for suffix composition, per-frame non-compounding, empty-suffix fallback, undefined normalization, path-mapping no-ops, and suffix restore.

Signed-off-by: Zain Ali <55154081+ZainAallii@users.noreply.github.com>
@ZainAallii
ZainAallii force-pushed the arnold-aov-support branch from 1cacf1a to 059e23c Compare July 29, 2026 19:45
self.log_to_console(
f"Warning: failed to restore Arnold AOV_Manager.outputPath: {exc}"
)
for i, drv in enumerate(aov_mgr.drivers):

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

aov_mgr.drivers is the one pymxs access in this method not wrapped in an except. If it's throws here, the finally clears the cache but the exception escapes before super().cleanup_render_elements(data) runs — so the standard render-element restore silently gets skipped

``_restore_arnold_aov_drivers`` so its name reflects what it actually
does.
"""
self._restore_arnold_aov_drivers()

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this cleanup hook only gets enqueued when enabled_modify_render_elements is true (see on_cleanup in adaptor.py). So for a typical Arnold AOV job, this restore never actually runs, is this what we expected?

@@ -1,10 +1,11 @@
"""

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The description and the call-stack comment above both mention an _apply_path_mapping override that isn't actually in the diff anymore?

None of the test touch path remapping (the "undefined" one is about suffix normalization). Mind adding some?

@leongdl

leongdl commented Jul 30, 2026

Copy link
Copy Markdown
Contributor

Re: the cleanup-hook gating question (discussion) — I traced this through adaptor.py:on_cleanup and confirmed the behavior: the cleanup_render_elements action is only enqueued when enabled_modify_render_elements == "true", so for a typical Arnold AOV job the _restore_arnold_aov_drivers path never runs.

That said, I consider this non-blocking. The gated cleanup means the restore machinery is dead, not wrong: on a worker the scene copy is discarded at session end anyway, so the missing restore has zero production impact today. Its only cost is misleading docstrings that promise a restore guarantee the common path doesn't deliver.

Suggested easy follow-up (either option, both adaptor-only):

  1. Unconditionally enqueue cleanup_render_elements in on_cleanup — the base class already no-ops when nothing was configured, so this is safe and makes the restore real; or
  2. Narrow the docstrings on _restore_arnold_aov_drivers / _configure_renderer_output to state the restore only fires when render-element modification is enabled.

Fine to land this PR without it and track as a follow-up issue.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

waiting-on-maintainers Waiting on the maintainers to review.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants