Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
44 commits
Select commit Hold shift + click to select a range
d91b922
clean up and add support for custom frame range in local rendering
moonyuet May 8, 2026
476d32f
make sure we can get the correct frame range in max
moonyuet May 8, 2026
c1ab810
Potential fix for pull request finding
moonyuet May 8, 2026
9fbe351
Potential fix for pull request finding
moonyuet May 8, 2026
8813e5d
Potential fix for pull request finding
moonyuet May 8, 2026
dd97e28
upload the code suggestion by copilot
moonyuet May 8, 2026
ff51562
Merge remote-tracking branch 'origin/develop' into enhancement/YN-072…
moonyuet May 8, 2026
2e113bb
wip: resolve conlfict
moonyuet May 26, 2026
ebe08da
wip: rename custom_frames to customFrames
moonyuet May 26, 2026
4127c20
Merge branch 'develop' into enhancement/YN-0726--Custom-Frame-Range-S…
moonyuet Jun 26, 2026
2249e81
remove unused logic.
moonyuet Jul 2, 2026
e6e48d6
Clean up and implement the existing logic from core
moonyuet Jul 2, 2026
492004b
add hasExplicitFrames and reuseLastVersion data
moonyuet Jul 2, 2026
0f38a37
revert vfb changes
moonyuet Jul 2, 2026
3f58cdb
Merge branch 'develop' into enhancement/YN-0726--Custom-Frame-Range-S…
moonyuet Jul 3, 2026
6267bba
Merge branch 'develop' into enhancement/YN-0726--Custom-Frame-Range-S…
moonyuet Jul 7, 2026
48a13e0
Potential fix for pull request finding
moonyuet Jul 7, 2026
b00226a
revert the change by cilpot suggestion & implement the changes to mak…
moonyuet Jul 7, 2026
944d506
add docstrings.
moonyuet Jul 7, 2026
ba0d366
add the colorspace data into the OIIO transcode.
moonyuet Jul 7, 2026
a5bb473
Merge remote-tracking branch 'origin/develop' into enhancement/YN-072…
moonyuet Jul 8, 2026
6a32413
Merge branch 'develop' into enhancement/YN-0726--Custom-Frame-Range-S…
moonyuet Jul 8, 2026
4aa7040
Merge remote-tracking branch 'origin/develop' into enhancement/YN-072…
moonyuet Jul 8, 2026
80cfbe7
Merge branch 'enhancement/YN-0726--Custom-Frame-Range-Support-for-Loc…
moonyuet Jul 8, 2026
4024560
Update client/ayon_max/api/lib.py
moonyuet Jul 15, 2026
ea91cfd
add docstring
moonyuet Jul 16, 2026
a0e7ad0
add comment for `hasExplicitFrames` and remove unneccessary instance …
moonyuet Jul 16, 2026
48812e1
Merge branch 'develop' into enhancement/YN-0726--Custom-Frame-Range-S…
moonyuet Jul 16, 2026
4e504aa
Merge branch 'develop' into enhancement/YN-0726--Custom-Frame-Range-S…
antirotor Jul 16, 2026
8a70eba
Update client/ayon_max/plugins/publish/collect_render_local.py
moonyuet Jul 16, 2026
c31a3d1
rename frame_range to frames
moonyuet Jul 30, 2026
f5de5af
add comment to explain why ordering move later
moonyuet Jul 30, 2026
87e8ebb
clean up
moonyuet Jul 30, 2026
6ddfc05
clarify the comment on HasExplicitFrames
moonyuet Jul 30, 2026
8d8e0e9
ruff fix
moonyuet Jul 30, 2026
f1035a8
Merge branch 'develop' into enhancement/YN-0726--Custom-Frame-Range-S…
moonyuet Aug 7, 2026
5868d9b
update the repair action for repairing arnold render settings.
moonyuet Aug 10, 2026
34a3fa2
fix the local rendering failed in Arnold.
moonyuet Aug 10, 2026
38b3a68
normalize the path for stagingDir when using local rendering
moonyuet Aug 14, 2026
83e6692
Merge remote-tracking branch 'origin/develop' into enhancement/YN-072…
moonyuet Aug 14, 2026
e5a4bab
use Pathlib only for expectedFiles.
moonyuet Aug 14, 2026
ecc9991
ruff fix
moonyuet Aug 14, 2026
67e2d2b
normalized the forward slashes to fix the path issue faced in Arnold
moonyuet Aug 14, 2026
d2135c7
Merge branch 'develop' into enhancement/YN-0726--Custom-Frame-Range-S…
moonyuet Sep 4, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 25 additions & 5 deletions client/ayon_max/api/lib.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@
import contextlib
import logging
import json
from pathlib import Path
from functools import partial
import pyblish.api
import re
Expand All @@ -28,6 +27,9 @@
get_current_task_entity
)
from ayon_core.pipeline.template_data import get_template_data_with_names
from ayon_core.pipeline.farm.pyblish_functions import (
convert_frames_str_to_list,
)
from ayon_core.pipeline.create import CreateContext
from ayon_core.style import load_stylesheet

Expand Down Expand Up @@ -303,10 +305,10 @@ def get_default_render_folder(
render_data["work"] = work_dir
render_folder = project_setting["max"]["RenderSettings"]["default_render_image_folder"]
formatted_render_folder = StringTemplate(render_folder).format(render_data)
normalized_render_folder = Path(formatted_render_folder)
if not normalized_render_folder.is_absolute():
normalized_render_folder = Path(work_dir) / normalized_render_folder
return str(normalized_render_folder)
if not os.path.isabs(formatted_render_folder):
formatted_render_folder = os.path.join(work_dir, formatted_render_folder)
# Normalize to forward slashes for consistency
return formatted_render_folder.replace("\\", "/")


def get_vray_settings(renderer_name: str, renderer: Any) -> Any:
Expand Down Expand Up @@ -1153,3 +1155,21 @@ def build_general_output_filename(
ext = match.group("ext")
filename = f"{name}_{element}..{ext}"
return os.path.join(output_dir, filename)


def get_expected_frames(instance: pyblish.api.Instance) -> list[int]:
"""Get expected frames from the instance.

Args:
instance (pyblish.api.Instance): The Pyblish instance.

Returns:
list[int]: A list containing the expected frames.
"""
frames_str = instance.data.get("customFrames", "")
if frames_str:
frames_list = convert_frames_str_to_list(frames_str.strip())
Comment thread
moonyuet marked this conversation as resolved.
return frames_list
frame_start = int(rt.rendStart)
frame_end = int(rt.rendEnd)
return list(range(frame_start, frame_end + 1))
54 changes: 26 additions & 28 deletions client/ayon_max/api/lib_renderproducts.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
# https://help.autodesk.com/view/ARNOL/ENU/?guid=arnold_for_3ds_max_ax_maxscript_commands_ax_renderview_commands_html
from __future__ import annotations
import os
from pathlib import Path
from typing import Dict, Any

try:
Expand Down Expand Up @@ -38,20 +39,23 @@ def __init__(self, project_settings: Dict[str, Any] = None):
get_current_project_name()
)

def get_render_products(self) -> Dict[str, list[str]]:
def get_render_products(self, frames: list[int]) -> Dict[str, list[str]]:
"""Get render output file paths for the current scene.

Handles both beauty and AOV extraction with shared setup logic.
Always includes the beauty pass; optionally includes render elements/AOVs.

Args:
frames (list[int]): A list of frames to generate expected
output file paths for.

Returns:
Dict[str, list[str]]: A dictionary containing render output file paths.
Beauty key is "beauty"; AOV keys are named after the render element
(e.g., "Cryptomatte", "Alpha").
"""
extension = self.image_format()
start_frame = int(rt.rendStart)
end_frame = int(rt.rendEnd)

# todo: Support Custom Frames sequences 0,5-10,100-120
# we can add filtering frames list to get expected frames list
# instead of using start and end frame
Expand All @@ -60,8 +64,7 @@ def get_render_products(self) -> Dict[str, list[str]]:
render_dict: Dict[str, list[str]] = {}

# Always add beauty pass
beauty_files = self.get_expected_beauty(start_frame, end_frame, extension)
render_dict["beauty"] = beauty_files
render_dict["beauty"] = self.get_expected_beauty(frames, extension)

# Optionally add AOVs
renderer = get_current_renderer()
Expand All @@ -71,16 +74,15 @@ def get_render_products(self) -> Dict[str, list[str]]:
for aov_name, aov_filepath in render_elements:
aov_expected_files = self.get_expected_files(
aov_filepath,
start_frame,
end_frame,
frames,
aov_name,
renderer_name
)
render_dict[aov_name] = aov_expected_files
return render_dict

def get_multiple_render_products(
self, outputs: list[str], cameras: list[str]
self, outputs: list[str], cameras: list[str], frames: list[int]
) -> Dict[str, list[str]]:
Comment thread
moonyuet marked this conversation as resolved.
"""Get render output file paths for multiple cameras.

Expand All @@ -91,6 +93,8 @@ def get_multiple_render_products(
Args:
outputs (list[str]): A list of output file paths.
cameras (list[str]): A list of camera names.
frames (list[int]): A list of frames to generate expected
output file paths for.

Returns:
Dict[str, list[str]]: A dictionary containing render output file
Expand All @@ -105,11 +109,9 @@ def get_multiple_render_products(
filename, ext = os.path.splitext(output)
filename = filename.replace(".", "")
ext = ext.replace(".", "")
start_frame = int(rt.rendStart)
end_frame = int(rt.rendEnd)

# Always add beauty pass
beauty_files = self.get_expected_beauty(start_frame, end_frame, ext)
beauty_files = self.get_expected_beauty(frames, ext)
render_output_frames[f"{camera}_beauty"] = beauty_files

# Add AOVs
Expand All @@ -118,8 +120,7 @@ def get_multiple_render_products(
for aov_name, aov_filepath in render_elements:
aov_expected_files = self.get_expected_files(
aov_filepath,
start_frame,
end_frame,
frames,
aov_name,
renderer_name
)
Expand All @@ -128,13 +129,13 @@ def get_multiple_render_products(
return render_output_frames

def get_expected_beauty(
self, start_frame: int, end_frame: int, extension: str
self, frames: list[int], extension: str
) -> list[str]:
"""Get expected beauty render output file paths for each frame.

Args:
start_frame (int): The starting frame number.
end_frame (int): The ending frame number.
frames (list[int]): The frame range to generate expected
output file paths for.
extension (str): The file extension for the output files.

Returns:
Expand All @@ -151,8 +152,7 @@ def get_expected_beauty(

return self.get_expected_files(
output_path,
start_frame,
end_frame,
frames,
"",
renderer_name
)
Expand Down Expand Up @@ -264,17 +264,15 @@ def get_arnold_render_output(self, arnold_renderer: Any, extension: str) -> str:
def get_expected_files(
self,
filepath: str,
start_frame: int,
end_frame: int,
frames: list[int],
aov_name: str,
renderer_name: str,
) -> list[str]:
"""Get expected files

Args:
filepath (str): filepath of the render output.
start_frame (int): start frame of the render sequence.
end_frame (int): end frame of the render sequence.
frames (list[int]): range of frames of the render sequence.
aov_name (str): name of the AOV.
renderer_name (str): name of the renderer.

Expand All @@ -284,19 +282,19 @@ def get_expected_files(
expected_aovs: list[str] = []
if not filepath:
return expected_aovs
directory = os.path.dirname(filepath)
filename = os.path.basename(filepath)
file_path = Path(filepath)
directory = file_path.parent
filename = file_path.name
name, ext = os.path.splitext(filename)
name = name.lstrip(".")
aov_name = aov_name.strip()
for frame in range(start_frame, end_frame + 1):
for frame in frames:
aov_filename = f"{name}.{frame:04d}{ext}"
expected_aov = os.path.join(directory, aov_filename)
if aov_name and renderer_name.startswith("V_Ray_"):
aov_filename = f"{name}.{aov_name}.{frame:04d}{ext}"
aov_filename = reformat_filename(aov_filename)
expected_aov = os.path.join(directory, aov_filename)
expected_aovs.append(expected_aov)
expected_aov = Path(directory) / aov_filename
expected_aovs.append(expected_aov.as_posix())

return expected_aovs

Expand Down
11 changes: 8 additions & 3 deletions client/ayon_max/plugins/publish/collect_frame_range.py

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

not really related to this PR but I am wondering why are we filling handles here explicitly.

@moonyuet moonyuet Jul 16, 2026

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

This is filled to ensure for the validation in frame range as the frame range data in 3dsmax workflow (I mean other than render product type) are based on frameStartHandle and frameEndHandle

We can move discussion as another github issue as I also noticed that frameStartHandle and frameEndHandle are handle differently on render product. And we should use frameStart and frameEnd in the render product in terms of collect frame range as well as the validation instead.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

No, we should render the full range. The handles metadata on this instance means that the handle frames are included in this output. If you don't want to render the handles, then the instance should not have handles set. But what we render and produce, should include handles.

Original file line number Diff line number Diff line change
@@ -1,12 +1,15 @@
# -*- coding: utf-8 -*-
import pyblish.api
from pymxs import runtime as rt
from ayon_max.api.lib import get_expected_frames


class CollectFrameRange(pyblish.api.InstancePlugin):
"""Collect Frame Range."""

order = pyblish.api.CollectorOrder + 0.011
# move the collector order later to ensure
# it run after CollectCustomFrameRange in Core addon
order = pyblish.api.CollectorOrder + 0.019
Comment thread
moonyuet marked this conversation as resolved.
label = "Collect Frame Range"
hosts = ['max']
families = ["camera", "maxrender",
Expand All @@ -16,8 +19,10 @@ class CollectFrameRange(pyblish.api.InstancePlugin):

def process(self, instance):
if instance.data["productBaseType"] == "maxrender":
instance.data["frameStartHandle"] = int(rt.rendStart)
instance.data["frameEndHandle"] = int(rt.rendEnd)
frame_range = get_expected_frames(instance)
instance.data["frameStartHandle"] = min(frame_range)
instance.data["frameEndHandle"] = max(frame_range)
instance.data["expectedFrameRange"] = frame_range
Comment thread
moonyuet marked this conversation as resolved.

elif instance.data["productBaseType"] in {"tycache", "tyspline"}:
operator = instance.data["operator"]
Expand Down
5 changes: 3 additions & 2 deletions client/ayon_max/plugins/publish/collect_render.py
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,8 @@ def process(self, instance):
renderer_name = str(renderer).split(":")[0]
renderproducts = RenderProducts(context.data["project_settings"])
img_format = renderproducts.image_format()
files_by_aov: Dict[str, list[str]] = renderproducts.get_render_products()
expected_frames = instance.data.get("expectedFrameRange")
files_by_aov: Dict[str, list[str]] = renderproducts.get_render_products(expected_frames)


camera = rt.viewport.GetCamera()
Expand Down Expand Up @@ -106,7 +107,7 @@ def process(self, instance):
instance.data["cameras"] = sel_cam

files_by_aov = renderproducts.get_multiple_render_products(
outputs, sel_cam
outputs, sel_cam, expected_frames
)

if "expectedFiles" not in instance.data:
Expand Down
5 changes: 5 additions & 0 deletions client/ayon_max/plugins/publish/collect_render_local.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@

import pyblish.api

from ayon_core.pipeline.farm.pyblish_functions import (
Expand Down Expand Up @@ -78,6 +79,10 @@ def process(self, instance):
aov_instance.data.update(aov_instance_data)
aov_instance.data["families"] = [f"render.{render_target}"]

# The hasExplicitFrames flag would ensure the preset frame ranges are
# preserved and not renumbered to consecutive frame ranges on publish.
aov_instance.data["hasExplicitFrames"] = True
Comment thread
BigRoy marked this conversation as resolved.

# Pass on 'review' family
if "review" in aov_instance_data["families"]:
aov_instance.data["families"].append("review")
Expand Down
21 changes: 14 additions & 7 deletions client/ayon_max/plugins/publish/extract_render.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
from __future__ import annotations

import os
from ayon_core.pipeline import publish
from ayon_core.pipeline.publish import KnownPublishError

Expand Down Expand Up @@ -36,18 +38,23 @@ def process(self, instance):
rt.getNodeByName(camera)
if camera else rt.viewport.GetCamera()
)

for frame in range(int(rt.rendStart), int(rt.rendEnd) + 1):
kwargs = {
"camera_node": camera_node,
"cancelled": pymxs.byref(None),
"vfb": False,
}
for frame in instance.data["expectedFrameRange"]:
if instance.data["renderer"].startswith("Arnold"):
filename, ext = os.path.splitext(rt.rendOutputFilename)
new_output = f"{filename}{frame:04d}{ext}"
kwargs["outputFile"] = new_output
_, cancelled = rt.render(
frame=frame,
vfb=False,
camera=camera_node,
cancelled=pymxs.byref(None)
**kwargs
)
if cancelled:
raise KnownPublishError(f"Render cancelled at frame {frame}.")

self.log.debug("Local render extraction completed.")
self.log.debug("Local render extraction completed.")
else:
self.log.debug(
"Local render extraction for multi-camera is already "
Expand Down
2 changes: 1 addition & 1 deletion client/ayon_max/plugins/publish/validate_rendersettings.py
Original file line number Diff line number Diff line change
Expand Up @@ -496,12 +496,12 @@ def repair_arnold_settings(
if not path:
path = reset_rendersetting(instance, project_settings)
render_dir = os.path.dirname(path)
aov_manager.outputPath = path
filename = os.path.basename(path)
rt.rendOutputFilename = build_general_output_filename(
render_dir,
filename,
)
aov_manager.outputPath = os.path.dirname(rt.rendOutputFilename)
driver = aov_manager.drivers[0]
driver.multipart = get_multipass_setting(
renderer_name,
Expand Down
Loading