diff --git a/openpype/hosts/max/api/lib.py b/openpype/hosts/max/api/lib.py index dbaa6e4a24..995c35792a 100644 --- a/openpype/hosts/max/api/lib.py +++ b/openpype/hosts/max/api/lib.py @@ -284,6 +284,21 @@ def get_max_version(): return max_info[7] +@contextlib.contextmanager +def viewport_camera(camera): + original = rt.viewport.getCamera() + if not original: + # if there is no original camera + # use the current camera as original + original = rt.getNodeByName(camera) + review_camera = rt.getNodeByName(camera) + try: + rt.viewport.setCamera(review_camera) + yield + finally: + rt.viewport.setCamera(original) + + def set_timeline(frameStart, frameEnd): """Set frame range for timeline editor in Max """ diff --git a/openpype/hosts/max/plugins/create/create_review.py b/openpype/hosts/max/plugins/create/create_review.py new file mode 100644 index 0000000000..5737114fcc --- /dev/null +++ b/openpype/hosts/max/plugins/create/create_review.py @@ -0,0 +1,57 @@ +# -*- coding: utf-8 -*- +"""Creator plugin for creating review in Max.""" +from openpype.hosts.max.api import plugin +from openpype.lib import BoolDef, EnumDef, NumberDef + + +class CreateReview(plugin.MaxCreator): + """Review in 3dsMax""" + + identifier = "io.openpype.creators.max.review" + label = "Review" + family = "review" + icon = "video-camera" + + def create(self, subset_name, instance_data, pre_create_data): + + instance_data["imageFormat"] = pre_create_data.get("imageFormat") + instance_data["keepImages"] = pre_create_data.get("keepImages") + instance_data["percentSize"] = pre_create_data.get("percentSize") + instance_data["rndLevel"] = pre_create_data.get("rndLevel") + + super(CreateReview, self).create( + subset_name, + instance_data, + pre_create_data) + + def get_pre_create_attr_defs(self): + attrs = super(CreateReview, self).get_pre_create_attr_defs() + + image_format_enum = [ + "bmp", "cin", "exr", "jpg", "hdr", "rgb", "png", + "rla", "rpf", "dds", "sgi", "tga", "tif", "vrimg" + ] + + rndLevel_enum = [ + "smoothhighlights", "smooth", "facethighlights", + "facet", "flat", "litwireframe", "wireframe", "box" + ] + + return attrs + [ + BoolDef("keepImages", + label="Keep Image Sequences", + default=False), + EnumDef("imageFormat", + image_format_enum, + default="png", + label="Image Format Options"), + NumberDef("percentSize", + label="Percent of Output", + default=100, + minimum=1, + decimals=0), + EnumDef("rndLevel", + rndLevel_enum, + default="smoothhighlights", + label="Preference") + ] diff --git a/openpype/hosts/max/plugins/publish/collect_review.py b/openpype/hosts/max/plugins/publish/collect_review.py new file mode 100644 index 0000000000..7aeb45f46b --- /dev/null +++ b/openpype/hosts/max/plugins/publish/collect_review.py @@ -0,0 +1,92 @@ +# dont forget getting the focal length for burnin +"""Collect Review""" +import pyblish.api + +from pymxs import runtime as rt +from openpype.lib import BoolDef +from openpype.pipeline.publish import OpenPypePyblishPluginMixin + + +class CollectReview(pyblish.api.InstancePlugin, + OpenPypePyblishPluginMixin): + """Collect Review Data for Preview Animation""" + + order = pyblish.api.CollectorOrder + 0.02 + label = "Collect Review Data" + hosts = ['max'] + families = ["review"] + + def process(self, instance): + nodes = instance.data["members"] + focal_length = None + camera_name = None + for node in nodes: + if rt.classOf(node) in rt.Camera.classes: + camera_name = node.name + focal_length = node.fov + + attr_values = self.get_attr_values_from_data(instance.data) + data = { + "review_camera": camera_name, + "frameStart": instance.context.data["frameStart"], + "frameEnd": instance.context.data["frameEnd"], + "fps": instance.context.data["fps"], + "dspGeometry": attr_values.get("dspGeometry"), + "dspShapes": attr_values.get("dspShapes"), + "dspLights": attr_values.get("dspLights"), + "dspCameras": attr_values.get("dspCameras"), + "dspHelpers": attr_values.get("dspHelpers"), + "dspParticles": attr_values.get("dspParticles"), + "dspBones": attr_values.get("dspBones"), + "dspBkg": attr_values.get("dspBkg"), + "dspGrid": attr_values.get("dspGrid"), + "dspSafeFrame": attr_values.get("dspSafeFrame"), + "dspFrameNums": attr_values.get("dspFrameNums") + } + # Enable ftrack functionality + instance.data.setdefault("families", []).append('ftrack') + + burnin_members = instance.data.setdefault("burninDataMembers", {}) + burnin_members["focalLength"] = focal_length + + self.log.debug(f"data:{data}") + instance.data.update(data) + + @classmethod + def get_attribute_defs(cls): + + return [ + BoolDef("dspGeometry", + label="Geometry", + default=True), + BoolDef("dspShapes", + label="Shapes", + default=False), + BoolDef("dspLights", + label="Lights", + default=False), + BoolDef("dspCameras", + label="Cameras", + default=False), + BoolDef("dspHelpers", + label="Helpers", + default=False), + BoolDef("dspParticles", + label="Particle Systems", + default=True), + BoolDef("dspBones", + label="Bone Objects", + default=False), + BoolDef("dspBkg", + label="Background", + default=True), + BoolDef("dspGrid", + label="Active Grid", + default=False), + BoolDef("dspSafeFrame", + label="Safe Frames", + default=False), + BoolDef("dspFrameNums", + label="Frame Numbers", + default=False) + ] diff --git a/openpype/hosts/max/plugins/publish/extract_review_animation.py b/openpype/hosts/max/plugins/publish/extract_review_animation.py new file mode 100644 index 0000000000..8e06e52b5c --- /dev/null +++ b/openpype/hosts/max/plugins/publish/extract_review_animation.py @@ -0,0 +1,102 @@ +import os +import pyblish.api +from pymxs import runtime as rt +from openpype.pipeline import publish +from openpype.hosts.max.api.lib import viewport_camera, get_max_version + + +class ExtractReviewAnimation(publish.Extractor): + """ + Extract Review by Review Animation + """ + + order = pyblish.api.ExtractorOrder + 0.001 + label = "Extract Review Animation" + hosts = ["max"] + families = ["review"] + + def process(self, instance): + staging_dir = self.staging_dir(instance) + ext = instance.data.get("imageFormat") + filename = "{0}..{1}".format(instance.name, ext) + start = int(instance.data["frameStart"]) + end = int(instance.data["frameEnd"]) + fps = int(instance.data["fps"]) + filepath = os.path.join(staging_dir, filename) + filepath = filepath.replace("\\", "/") + filenames = self.get_files( + instance.name, start, end, ext) + + self.log.debug( + "Writing Review Animation to" + " '%s' to '%s'" % (filename, staging_dir)) + + review_camera = instance.data["review_camera"] + with viewport_camera(review_camera): + preview_arg = self.set_preview_arg( + instance, filepath, start, end, fps) + rt.execute(preview_arg) + + tags = ["review"] + if not instance.data.get("keepImages"): + tags.append("delete") + + self.log.debug("Performing Extraction ...") + + representation = { + "name": instance.data["imageFormat"], + "ext": instance.data["imageFormat"], + "files": filenames, + "stagingDir": staging_dir, + "frameStart": instance.data["frameStart"], + "frameEnd": instance.data["frameEnd"], + "tags": tags, + "preview": True, + "camera_name": review_camera + } + self.log.debug(f"{representation}") + + if "representations" not in instance.data: + instance.data["representations"] = [] + instance.data["representations"].append(representation) + + def get_files(self, filename, start, end, ext): + file_list = [] + for frame in range(int(start), int(end) + 1): + actual_name = "{}.{:04}.{}".format( + filename, frame, ext) + file_list.append(actual_name) + + return file_list + + def set_preview_arg(self, instance, filepath, + start, end, fps): + job_args = list() + default_option = f'CreatePreview filename:"{filepath}"' + job_args.append(default_option) + frame_option = f"outputAVI:false start:{start} end:{end} fps:{fps}" # noqa + job_args.append(frame_option) + rndLevel = instance.data.get("rndLevel") + if rndLevel: + option = f"rndLevel:#{rndLevel}" + job_args.append(option) + options = [ + "percentSize", "dspGeometry", "dspShapes", + "dspLights", "dspCameras", "dspHelpers", "dspParticles", + "dspBones", "dspBkg", "dspGrid", "dspSafeFrame", "dspFrameNums" + ] + + for key in options: + enabled = instance.data.get(key) + if enabled: + job_args.append(f"{key}:{enabled}") + + if get_max_version() == 2024: + # hardcoded for current stage + auto_play_option = "autoPlay:false" + job_args.append(auto_play_option) + + job_str = " ".join(job_args) + self.log.debug(job_str) + + return job_str diff --git a/openpype/hosts/max/plugins/publish/extract_thumbnail.py b/openpype/hosts/max/plugins/publish/extract_thumbnail.py new file mode 100644 index 0000000000..82f4fc7a8b --- /dev/null +++ b/openpype/hosts/max/plugins/publish/extract_thumbnail.py @@ -0,0 +1,91 @@ +import os +import tempfile +import pyblish.api +from pymxs import runtime as rt +from openpype.pipeline import publish +from openpype.hosts.max.api.lib import viewport_camera, get_max_version + + +class ExtractThumbnail(publish.Extractor): + """ + Extract Thumbnail for Review + """ + + order = pyblish.api.ExtractorOrder + label = "Extract Thumbnail" + hosts = ["max"] + families = ["review"] + + def process(self, instance): + # TODO: Create temp directory for thumbnail + # - this is to avoid "override" of source file + tmp_staging = tempfile.mkdtemp(prefix="pyblish_tmp_") + self.log.debug( + f"Create temp directory {tmp_staging} for thumbnail" + ) + fps = int(instance.data["fps"]) + frame = int(instance.data["frameStart"]) + instance.context.data["cleanupFullPaths"].append(tmp_staging) + filename = "{name}_thumbnail..png".format(**instance.data) + filepath = os.path.join(tmp_staging, filename) + filepath = filepath.replace("\\", "/") + thumbnail = self.get_filename(instance.name, frame) + + self.log.debug( + "Writing Thumbnail to" + " '%s' to '%s'" % (filename, tmp_staging)) + review_camera = instance.data["review_camera"] + with viewport_camera(review_camera): + preview_arg = self.set_preview_arg( + instance, filepath, fps, frame) + rt.execute(preview_arg) + + representation = { + "name": "thumbnail", + "ext": "png", + "files": thumbnail, + "stagingDir": tmp_staging, + "thumbnail": True + } + + self.log.debug(f"{representation}") + + if "representations" not in instance.data: + instance.data["representations"] = [] + instance.data["representations"].append(representation) + + def get_filename(self, filename, target_frame): + thumbnail_name = "{}_thumbnail.{:04}.png".format( + filename, target_frame + ) + return thumbnail_name + + def set_preview_arg(self, instance, filepath, fps, frame): + job_args = list() + default_option = f'CreatePreview filename:"{filepath}"' + job_args.append(default_option) + frame_option = f"outputAVI:false start:{frame} end:{frame} fps:{fps}" # noqa + job_args.append(frame_option) + rndLevel = instance.data.get("rndLevel") + if rndLevel: + option = f"rndLevel:#{rndLevel}" + job_args.append(option) + options = [ + "percentSize", "dspGeometry", "dspShapes", + "dspLights", "dspCameras", "dspHelpers", "dspParticles", + "dspBones", "dspBkg", "dspGrid", "dspSafeFrame", "dspFrameNums" + ] + + for key in options: + enabled = instance.data.get(key) + if enabled: + job_args.append(f"{key}:{enabled}") + if get_max_version() == 2024: + # hardcoded for current stage + auto_play_option = "autoPlay:false" + job_args.append(auto_play_option) + + job_str = " ".join(job_args) + self.log.debug(job_str) + + return job_str diff --git a/openpype/hosts/max/plugins/publish/validate_animation_timeline.py b/openpype/hosts/max/plugins/publish/validate_animation_timeline.py new file mode 100644 index 0000000000..2a9483c763 --- /dev/null +++ b/openpype/hosts/max/plugins/publish/validate_animation_timeline.py @@ -0,0 +1,48 @@ +import pyblish.api + +from pymxs import runtime as rt +from openpype.pipeline.publish import ( + RepairAction, + ValidateContentsOrder, + PublishValidationError +) +from openpype.hosts.max.api.lib import get_frame_range, set_timeline + + +class ValidateAnimationTimeline(pyblish.api.InstancePlugin): + """ + Validates Animation Timeline for Preview Animation in Max + """ + + label = "Animation Timeline for Review" + order = ValidateContentsOrder + families = ["review"] + hosts = ["max"] + actions = [RepairAction] + + def process(self, instance): + frame_range = get_frame_range() + frame_start_handle = frame_range["frameStart"] - int( + frame_range["handleStart"] + ) + frame_end_handle = frame_range["frameEnd"] + int( + frame_range["handleEnd"] + ) + if rt.animationRange.start != frame_start_handle or ( + rt.animationRange.end != frame_end_handle + ): + raise PublishValidationError("Incorrect animation timeline " + "set for preview animation.. " + "\nYou can use repair action to " + "the correct animation timeline") + + @classmethod + def repair(cls, instance): + frame_range = get_frame_range() + frame_start_handle = frame_range["frameStart"] - int( + frame_range["handleStart"] + ) + frame_end_handle = frame_range["frameEnd"] + int( + frame_range["handleEnd"] + ) + set_timeline(frame_start_handle, frame_end_handle) diff --git a/openpype/hosts/max/plugins/publish/validate_camera_contents.py b/openpype/hosts/max/plugins/publish/validate_camera_contents.py index ab13e5dc05..0c61e6431d 100644 --- a/openpype/hosts/max/plugins/publish/validate_camera_contents.py +++ b/openpype/hosts/max/plugins/publish/validate_camera_contents.py @@ -11,7 +11,7 @@ class ValidateCameraContent(pyblish.api.InstancePlugin): """ order = pyblish.api.ValidatorOrder - families = ["camera"] + families = ["camera", "review"] hosts = ["max"] label = "Camera Contents" camera_type = ["$Free_Camera", "$Target_Camera", diff --git a/openpype/hosts/max/plugins/publish/validate_no_max_content.py b/openpype/hosts/max/plugins/publish/validate_no_max_content.py index ba4a6882c2..c6a27dace3 100644 --- a/openpype/hosts/max/plugins/publish/validate_no_max_content.py +++ b/openpype/hosts/max/plugins/publish/validate_no_max_content.py @@ -13,7 +13,8 @@ class ValidateMaxContents(pyblish.api.InstancePlugin): order = pyblish.api.ValidatorOrder families = ["camera", "maxScene", - "maxrender"] + "maxrender", + "review"] hosts = ["max"] label = "Max Scene Contents" diff --git a/openpype/plugins/publish/extract_burnin.py b/openpype/plugins/publish/extract_burnin.py index 41d6cf81fc..e67739e842 100644 --- a/openpype/plugins/publish/extract_burnin.py +++ b/openpype/plugins/publish/extract_burnin.py @@ -51,7 +51,8 @@ class ExtractBurnin(publish.Extractor): "aftereffects", "photoshop", "flame", - "houdini" + "houdini", + "max" # "resolve" ] diff --git a/openpype/plugins/publish/extract_review.py b/openpype/plugins/publish/extract_review.py index d04893fa7e..f053d1b500 100644 --- a/openpype/plugins/publish/extract_review.py +++ b/openpype/plugins/publish/extract_review.py @@ -49,6 +49,7 @@ class ExtractReview(pyblish.api.InstancePlugin): "maya", "blender", "houdini", + "max", "shell", "hiero", "premiere", diff --git a/openpype/settings/defaults/project_settings/global.json b/openpype/settings/defaults/project_settings/global.json index a78c5cb7ac..802b964375 100644 --- a/openpype/settings/defaults/project_settings/global.json +++ b/openpype/settings/defaults/project_settings/global.json @@ -262,7 +262,8 @@ ], "hosts": [ "maya", - "houdini" + "houdini", + "max" ], "task_types": [], "task_names": [],