diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index 3f762bd2d8..e9b68a54f1 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -35,6 +35,7 @@ body: label: Version description: What version are you running? Look to OpenPype Tray options: + - 3.18.4-nightly.1 - 3.18.3 - 3.18.3-nightly.2 - 3.18.3-nightly.1 @@ -134,7 +135,6 @@ body: - 3.15.7-nightly.2 - 3.15.7-nightly.1 - 3.15.6 - - 3.15.6-nightly.3 validations: required: true - type: dropdown diff --git a/openpype/hosts/fusion/api/plugin.py b/openpype/hosts/fusion/api/plugin.py new file mode 100644 index 0000000000..63a74fbdb5 --- /dev/null +++ b/openpype/hosts/fusion/api/plugin.py @@ -0,0 +1,221 @@ +from copy import deepcopy +import os + +from openpype.hosts.fusion.api import ( + get_current_comp, + comp_lock_and_undo_chunk, +) + +from openpype.lib import ( + BoolDef, + EnumDef, +) +from openpype.pipeline import ( + legacy_io, + Creator, + CreatedInstance +) + + +class GenericCreateSaver(Creator): + default_variants = ["Main", "Mask"] + description = "Fusion Saver to generate image sequence" + icon = "fa5.eye" + + instance_attributes = [ + "reviewable" + ] + + settings_category = "fusion" + + image_format = "exr" + + # TODO: This should be renamed together with Nuke so it is aligned + temp_rendering_path_template = ( + "{workdir}/renders/fusion/{subset}/{subset}.{frame}.{ext}") + + def create(self, subset_name, instance_data, pre_create_data): + self.pass_pre_attributes_to_instance(instance_data, pre_create_data) + + instance = CreatedInstance( + family=self.family, + subset_name=subset_name, + data=instance_data, + creator=self, + ) + data = instance.data_to_store() + comp = get_current_comp() + with comp_lock_and_undo_chunk(comp): + args = (-32768, -32768) # Magical position numbers + saver = comp.AddTool("Saver", *args) + + self._update_tool_with_data(saver, data=data) + + # Register the CreatedInstance + self._imprint(saver, data) + + # Insert the transient data + instance.transient_data["tool"] = saver + + self._add_instance_to_context(instance) + + return instance + + def collect_instances(self): + comp = get_current_comp() + tools = comp.GetToolList(False, "Saver").values() + for tool in tools: + data = self.get_managed_tool_data(tool) + if not data: + continue + + # Add instance + created_instance = CreatedInstance.from_existing(data, self) + + # Collect transient data + created_instance.transient_data["tool"] = tool + + self._add_instance_to_context(created_instance) + + def update_instances(self, update_list): + for created_inst, _changes in update_list: + new_data = created_inst.data_to_store() + tool = created_inst.transient_data["tool"] + self._update_tool_with_data(tool, new_data) + self._imprint(tool, new_data) + + def remove_instances(self, instances): + for instance in instances: + # Remove the tool from the scene + + tool = instance.transient_data["tool"] + if tool: + tool.Delete() + + # Remove the collected CreatedInstance to remove from UI directly + self._remove_instance_from_context(instance) + + def _imprint(self, tool, data): + # Save all data in a "openpype.{key}" = value data + + # Instance id is the tool's name so we don't need to imprint as data + data.pop("instance_id", None) + + active = data.pop("active", None) + if active is not None: + # Use active value to set the passthrough state + tool.SetAttrs({"TOOLB_PassThrough": not active}) + + for key, value in data.items(): + tool.SetData(f"openpype.{key}", value) + + def _update_tool_with_data(self, tool, data): + """Update tool node name and output path based on subset data""" + if "subset" not in data: + return + + original_subset = tool.GetData("openpype.subset") + original_format = tool.GetData( + "openpype.creator_attributes.image_format" + ) + + subset = data["subset"] + if ( + original_subset != subset + or original_format != data["creator_attributes"]["image_format"] + ): + self._configure_saver_tool(data, tool, subset) + + def _configure_saver_tool(self, data, tool, subset): + formatting_data = deepcopy(data) + + # get frame padding from anatomy templates + frame_padding = self.project_anatomy.templates["frame_padding"] + + # get output format + ext = data["creator_attributes"]["image_format"] + + # Subset change detected + workdir = os.path.normpath(legacy_io.Session["AVALON_WORKDIR"]) + formatting_data.update({ + "workdir": workdir, + "frame": "0" * frame_padding, + "ext": ext, + "product": { + "name": formatting_data["subset"], + "type": formatting_data["family"], + }, + }) + + # build file path to render + filepath = self.temp_rendering_path_template.format(**formatting_data) + + comp = get_current_comp() + tool["Clip"] = comp.ReverseMapPath(os.path.normpath(filepath)) + + # Rename tool + if tool.Name != subset: + print(f"Renaming {tool.Name} -> {subset}") + tool.SetAttrs({"TOOLS_Name": subset}) + + def get_managed_tool_data(self, tool): + """Return data of the tool if it matches creator identifier""" + data = tool.GetData("openpype") + if not isinstance(data, dict): + return + + required = { + "id": "pyblish.avalon.instance", + "creator_identifier": self.identifier, + } + for key, value in required.items(): + if key not in data or data[key] != value: + return + + # Get active state from the actual tool state + attrs = tool.GetAttrs() + passthrough = attrs["TOOLB_PassThrough"] + data["active"] = not passthrough + + # Override publisher's UUID generation because tool names are + # already unique in Fusion in a comp + data["instance_id"] = tool.Name + + return data + + def get_instance_attr_defs(self): + """Settings for publish page""" + return self.get_pre_create_attr_defs() + + def pass_pre_attributes_to_instance(self, instance_data, pre_create_data): + creator_attrs = instance_data["creator_attributes"] = {} + for pass_key in pre_create_data.keys(): + creator_attrs[pass_key] = pre_create_data[pass_key] + + def _get_render_target_enum(self): + rendering_targets = { + "local": "Local machine rendering", + "frames": "Use existing frames", + } + if "farm_rendering" in self.instance_attributes: + rendering_targets["farm"] = "Farm rendering" + + return EnumDef( + "render_target", items=rendering_targets, label="Render target" + ) + + def _get_reviewable_bool(self): + return BoolDef( + "review", + default=("reviewable" in self.instance_attributes), + label="Review", + ) + + def _get_image_format_enum(self): + image_format_options = ["exr", "tga", "tif", "png", "jpg"] + return EnumDef( + "image_format", + items=image_format_options, + default=self.image_format, + label="Output Image Format", + ) diff --git a/openpype/hosts/fusion/plugins/create/create_image_saver.py b/openpype/hosts/fusion/plugins/create/create_image_saver.py new file mode 100644 index 0000000000..490228d488 --- /dev/null +++ b/openpype/hosts/fusion/plugins/create/create_image_saver.py @@ -0,0 +1,64 @@ +from openpype.lib import NumberDef + +from openpype.hosts.fusion.api.plugin import GenericCreateSaver +from openpype.hosts.fusion.api import get_current_comp + + +class CreateImageSaver(GenericCreateSaver): + """Fusion Saver to generate single image. + + Created to explicitly separate single ('image') or + multi frame('render) outputs. + + This might be temporary creator until 'alias' functionality will be + implemented to limit creation of additional product types with similar, but + not the same workflows. + """ + identifier = "io.openpype.creators.fusion.imagesaver" + label = "Image (saver)" + name = "image" + family = "image" + description = "Fusion Saver to generate image" + + default_frame = 0 + + def get_detail_description(self): + return """Fusion Saver to generate single image. + + This creator is expected for publishing of single frame `image` product + type. + + Artist should provide frame number (integer) to specify which frame + should be published. It must be inside of global timeline frame range. + + Supports local and deadline rendering. + + Supports selection from predefined set of output file extensions: + - exr + - tga + - png + - tif + - jpg + + Created to explicitly separate single frame ('image') or + multi frame ('render') outputs. + """ + + def get_pre_create_attr_defs(self): + """Settings for create page""" + attr_defs = [ + self._get_render_target_enum(), + self._get_reviewable_bool(), + self._get_frame_int(), + self._get_image_format_enum(), + ] + return attr_defs + + def _get_frame_int(self): + return NumberDef( + "frame", + default=self.default_frame, + label="Frame", + tooltip="Set frame to be rendered, must be inside of global " + "timeline range" + ) diff --git a/openpype/hosts/fusion/plugins/create/create_saver.py b/openpype/hosts/fusion/plugins/create/create_saver.py index 5870828b41..3a8ffe890b 100644 --- a/openpype/hosts/fusion/plugins/create/create_saver.py +++ b/openpype/hosts/fusion/plugins/create/create_saver.py @@ -1,187 +1,42 @@ -from copy import deepcopy -import os +from openpype.lib import EnumDef -from openpype.hosts.fusion.api import ( - get_current_comp, - comp_lock_and_undo_chunk, -) - -from openpype.lib import ( - BoolDef, - EnumDef, -) -from openpype.pipeline import ( - legacy_io, - Creator as NewCreator, - CreatedInstance, - Anatomy, -) +from openpype.hosts.fusion.api.plugin import GenericCreateSaver -class CreateSaver(NewCreator): +class CreateSaver(GenericCreateSaver): + """Fusion Saver to generate image sequence of 'render' product type. + + Original Saver creator targeted for 'render' product type. It uses + original not to descriptive name because of values in Settings. + """ identifier = "io.openpype.creators.fusion.saver" label = "Render (saver)" name = "render" family = "render" - default_variants = ["Main", "Mask"] description = "Fusion Saver to generate image sequence" - icon = "fa5.eye" - instance_attributes = ["reviewable"] - image_format = "exr" + default_frame_range_option = "asset_db" - # TODO: This should be renamed together with Nuke so it is aligned - temp_rendering_path_template = ( - "{workdir}/renders/fusion/{subset}/{subset}.{frame}.{ext}" - ) + def get_detail_description(self): + return """Fusion Saver to generate image sequence. - def create(self, subset_name, instance_data, pre_create_data): - self.pass_pre_attributes_to_instance(instance_data, pre_create_data) + This creator is expected for publishing of image sequences for 'render' + product type. (But can publish even single frame 'render'.) - instance_data.update( - {"id": "pyblish.avalon.instance", "subset": subset_name} - ) + Select what should be source of render range: + - "Current asset context" - values set on Asset in DB (Ftrack) + - "From render in/out" - from node itself + - "From composition timeline" - from timeline - comp = get_current_comp() - with comp_lock_and_undo_chunk(comp): - args = (-32768, -32768) # Magical position numbers - saver = comp.AddTool("Saver", *args) + Supports local and farm rendering. - self._update_tool_with_data(saver, data=instance_data) - - # Register the CreatedInstance - instance = CreatedInstance( - family=self.family, - subset_name=subset_name, - data=instance_data, - creator=self, - ) - data = instance.data_to_store() - self._imprint(saver, data) - - # Insert the transient data - instance.transient_data["tool"] = saver - - self._add_instance_to_context(instance) - - return instance - - def collect_instances(self): - comp = get_current_comp() - tools = comp.GetToolList(False, "Saver").values() - for tool in tools: - data = self.get_managed_tool_data(tool) - if not data: - continue - - # Add instance - created_instance = CreatedInstance.from_existing(data, self) - - # Collect transient data - created_instance.transient_data["tool"] = tool - - self._add_instance_to_context(created_instance) - - def update_instances(self, update_list): - for created_inst, _changes in update_list: - new_data = created_inst.data_to_store() - tool = created_inst.transient_data["tool"] - self._update_tool_with_data(tool, new_data) - self._imprint(tool, new_data) - - def remove_instances(self, instances): - for instance in instances: - # Remove the tool from the scene - - tool = instance.transient_data["tool"] - if tool: - tool.Delete() - - # Remove the collected CreatedInstance to remove from UI directly - self._remove_instance_from_context(instance) - - def _imprint(self, tool, data): - # Save all data in a "openpype.{key}" = value data - - # Instance id is the tool's name so we don't need to imprint as data - data.pop("instance_id", None) - - active = data.pop("active", None) - if active is not None: - # Use active value to set the passthrough state - tool.SetAttrs({"TOOLB_PassThrough": not active}) - - for key, value in data.items(): - tool.SetData(f"openpype.{key}", value) - - def _update_tool_with_data(self, tool, data): - """Update tool node name and output path based on subset data""" - if "subset" not in data: - return - - original_subset = tool.GetData("openpype.subset") - original_format = tool.GetData( - "openpype.creator_attributes.image_format" - ) - - subset = data["subset"] - if ( - original_subset != subset - or original_format != data["creator_attributes"]["image_format"] - ): - self._configure_saver_tool(data, tool, subset) - - def _configure_saver_tool(self, data, tool, subset): - formatting_data = deepcopy(data) - - # get frame padding from anatomy templates - anatomy = Anatomy() - frame_padding = anatomy.templates["frame_padding"] - - # get output format - ext = data["creator_attributes"]["image_format"] - - # Subset change detected - workdir = os.path.normpath(legacy_io.Session["AVALON_WORKDIR"]) - formatting_data.update( - {"workdir": workdir, "frame": "0" * frame_padding, "ext": ext} - ) - - # build file path to render - filepath = self.temp_rendering_path_template.format(**formatting_data) - - comp = get_current_comp() - tool["Clip"] = comp.ReverseMapPath(os.path.normpath(filepath)) - - # Rename tool - if tool.Name != subset: - print(f"Renaming {tool.Name} -> {subset}") - tool.SetAttrs({"TOOLS_Name": subset}) - - def get_managed_tool_data(self, tool): - """Return data of the tool if it matches creator identifier""" - data = tool.GetData("openpype") - if not isinstance(data, dict): - return - - required = { - "id": "pyblish.avalon.instance", - "creator_identifier": self.identifier, - } - for key, value in required.items(): - if key not in data or data[key] != value: - return - - # Get active state from the actual tool state - attrs = tool.GetAttrs() - passthrough = attrs["TOOLB_PassThrough"] - data["active"] = not passthrough - - # Override publisher's UUID generation because tool names are - # already unique in Fusion in a comp - data["instance_id"] = tool.Name - - return data + Supports selection from predefined set of output file extensions: + - exr + - tga + - png + - tif + - jpg + """ def get_pre_create_attr_defs(self): """Settings for create page""" @@ -193,29 +48,6 @@ class CreateSaver(NewCreator): ] return attr_defs - def get_instance_attr_defs(self): - """Settings for publish page""" - return self.get_pre_create_attr_defs() - - def pass_pre_attributes_to_instance(self, instance_data, pre_create_data): - creator_attrs = instance_data["creator_attributes"] = {} - for pass_key in pre_create_data.keys(): - creator_attrs[pass_key] = pre_create_data[pass_key] - - # These functions below should be moved to another file - # so it can be used by other plugins. plugin.py ? - def _get_render_target_enum(self): - rendering_targets = { - "local": "Local machine rendering", - "frames": "Use existing frames", - } - if "farm_rendering" in self.instance_attributes: - rendering_targets["farm"] = "Farm rendering" - - return EnumDef( - "render_target", items=rendering_targets, label="Render target" - ) - def _get_frame_range_enum(self): frame_range_options = { "asset_db": "Current asset context", @@ -227,42 +59,5 @@ class CreateSaver(NewCreator): "frame_range_source", items=frame_range_options, label="Frame range source", - ) - - def _get_reviewable_bool(self): - return BoolDef( - "review", - default=("reviewable" in self.instance_attributes), - label="Review", - ) - - def _get_image_format_enum(self): - image_format_options = ["exr", "tga", "tif", "png", "jpg"] - return EnumDef( - "image_format", - items=image_format_options, - default=self.image_format, - label="Output Image Format", - ) - - def apply_settings(self, project_settings): - """Method called on initialization of plugin to apply settings.""" - - # plugin settings - plugin_settings = project_settings["fusion"]["create"][ - self.__class__.__name__ - ] - - # individual attributes - self.instance_attributes = plugin_settings.get( - "instance_attributes", self.instance_attributes - ) - self.default_variants = plugin_settings.get( - "default_variants", self.default_variants - ) - self.temp_rendering_path_template = plugin_settings.get( - "temp_rendering_path_template", self.temp_rendering_path_template - ) - self.image_format = plugin_settings.get( - "image_format", self.image_format + default=self.default_frame_range_option ) diff --git a/openpype/hosts/fusion/plugins/publish/collect_inputs.py b/openpype/hosts/fusion/plugins/publish/collect_inputs.py index a6628300db..f23e4d0268 100644 --- a/openpype/hosts/fusion/plugins/publish/collect_inputs.py +++ b/openpype/hosts/fusion/plugins/publish/collect_inputs.py @@ -95,7 +95,7 @@ class CollectUpstreamInputs(pyblish.api.InstancePlugin): label = "Collect Inputs" order = pyblish.api.CollectorOrder + 0.2 hosts = ["fusion"] - families = ["render"] + families = ["render", "image"] def process(self, instance): diff --git a/openpype/hosts/fusion/plugins/publish/collect_instances.py b/openpype/hosts/fusion/plugins/publish/collect_instances.py index 4d6da79b77..a0131248e8 100644 --- a/openpype/hosts/fusion/plugins/publish/collect_instances.py +++ b/openpype/hosts/fusion/plugins/publish/collect_instances.py @@ -57,6 +57,18 @@ class CollectInstanceData(pyblish.api.InstancePlugin): start_with_handle = comp_start end_with_handle = comp_end + frame = instance.data["creator_attributes"].get("frame") + # explicitly publishing only single frame + if frame is not None: + frame = int(frame) + + start = frame + end = frame + handle_start = 0 + handle_end = 0 + start_with_handle = frame + end_with_handle = frame + # Include start and end render frame in label subset = instance.data["subset"] label = ( diff --git a/openpype/hosts/fusion/plugins/publish/collect_render.py b/openpype/hosts/fusion/plugins/publish/collect_render.py index a7daa0b64c..366eaa905c 100644 --- a/openpype/hosts/fusion/plugins/publish/collect_render.py +++ b/openpype/hosts/fusion/plugins/publish/collect_render.py @@ -50,7 +50,7 @@ class CollectFusionRender( continue family = inst.data["family"] - if family != "render": + if family not in ["render", "image"]: continue task_name = context.data["task"] @@ -59,7 +59,7 @@ class CollectFusionRender( instance_families = inst.data.get("families", []) subset_name = inst.data["subset"] instance = FusionRenderInstance( - family="render", + family=family, tool=tool, workfileComp=comp, families=instance_families, diff --git a/openpype/hosts/fusion/plugins/publish/save_scene.py b/openpype/hosts/fusion/plugins/publish/save_scene.py index 0798e7c8b7..da9b6ce41f 100644 --- a/openpype/hosts/fusion/plugins/publish/save_scene.py +++ b/openpype/hosts/fusion/plugins/publish/save_scene.py @@ -7,7 +7,7 @@ class FusionSaveComp(pyblish.api.ContextPlugin): label = "Save current file" order = pyblish.api.ExtractorOrder - 0.49 hosts = ["fusion"] - families = ["render", "workfile"] + families = ["render", "image", "workfile"] def process(self, context): diff --git a/openpype/hosts/fusion/plugins/publish/validate_background_depth.py b/openpype/hosts/fusion/plugins/publish/validate_background_depth.py index 6908889eb4..e268f8adec 100644 --- a/openpype/hosts/fusion/plugins/publish/validate_background_depth.py +++ b/openpype/hosts/fusion/plugins/publish/validate_background_depth.py @@ -17,7 +17,7 @@ class ValidateBackgroundDepth( order = pyblish.api.ValidatorOrder label = "Validate Background Depth 32 bit" hosts = ["fusion"] - families = ["render"] + families = ["render", "image"] optional = True actions = [SelectInvalidAction, publish.RepairAction] diff --git a/openpype/hosts/fusion/plugins/publish/validate_comp_saved.py b/openpype/hosts/fusion/plugins/publish/validate_comp_saved.py index 748047e8cf..6e6d10e09a 100644 --- a/openpype/hosts/fusion/plugins/publish/validate_comp_saved.py +++ b/openpype/hosts/fusion/plugins/publish/validate_comp_saved.py @@ -9,7 +9,7 @@ class ValidateFusionCompSaved(pyblish.api.ContextPlugin): order = pyblish.api.ValidatorOrder label = "Validate Comp Saved" - families = ["render"] + families = ["render", "image"] hosts = ["fusion"] def process(self, context): diff --git a/openpype/hosts/fusion/plugins/publish/validate_create_folder_checked.py b/openpype/hosts/fusion/plugins/publish/validate_create_folder_checked.py index 35c92163eb..d5c618af58 100644 --- a/openpype/hosts/fusion/plugins/publish/validate_create_folder_checked.py +++ b/openpype/hosts/fusion/plugins/publish/validate_create_folder_checked.py @@ -15,7 +15,7 @@ class ValidateCreateFolderChecked(pyblish.api.InstancePlugin): order = pyblish.api.ValidatorOrder label = "Validate Create Folder Checked" - families = ["render"] + families = ["render", "image"] hosts = ["fusion"] actions = [RepairAction, SelectInvalidAction] diff --git a/openpype/hosts/fusion/plugins/publish/validate_filename_has_extension.py b/openpype/hosts/fusion/plugins/publish/validate_filename_has_extension.py index 537e43c875..38cd578ff2 100644 --- a/openpype/hosts/fusion/plugins/publish/validate_filename_has_extension.py +++ b/openpype/hosts/fusion/plugins/publish/validate_filename_has_extension.py @@ -17,7 +17,7 @@ class ValidateFilenameHasExtension(pyblish.api.InstancePlugin): order = pyblish.api.ValidatorOrder label = "Validate Filename Has Extension" - families = ["render"] + families = ["render", "image"] hosts = ["fusion"] actions = [SelectInvalidAction] diff --git a/openpype/hosts/fusion/plugins/publish/validate_image_frame.py b/openpype/hosts/fusion/plugins/publish/validate_image_frame.py new file mode 100644 index 0000000000..734203f31c --- /dev/null +++ b/openpype/hosts/fusion/plugins/publish/validate_image_frame.py @@ -0,0 +1,27 @@ +import pyblish.api + +from openpype.pipeline import PublishValidationError + + +class ValidateImageFrame(pyblish.api.InstancePlugin): + """Validates that `image` product type contains only single frame.""" + + order = pyblish.api.ValidatorOrder + label = "Validate Image Frame" + families = ["image"] + hosts = ["fusion"] + + def process(self, instance): + render_start = instance.data["frameStartHandle"] + render_end = instance.data["frameEndHandle"] + too_many_frames = (isinstance(instance.data["expectedFiles"], list) + and len(instance.data["expectedFiles"]) > 1) + + if render_end - render_start > 0 or too_many_frames: + desc = ("Trying to render multiple frames. 'image' product type " + "is meant for single frame. Please use 'render' creator.") + raise PublishValidationError( + title="Frame range outside of comp range", + message=desc, + description=desc + ) diff --git a/openpype/hosts/fusion/plugins/publish/validate_instance_frame_range.py b/openpype/hosts/fusion/plugins/publish/validate_instance_frame_range.py index 06cd0ca186..edf219e752 100644 --- a/openpype/hosts/fusion/plugins/publish/validate_instance_frame_range.py +++ b/openpype/hosts/fusion/plugins/publish/validate_instance_frame_range.py @@ -7,8 +7,8 @@ class ValidateInstanceFrameRange(pyblish.api.InstancePlugin): """Validate instance frame range is within comp's global render range.""" order = pyblish.api.ValidatorOrder - label = "Validate Filename Has Extension" - families = ["render"] + label = "Validate Frame Range" + families = ["render", "image"] hosts = ["fusion"] def process(self, instance): diff --git a/openpype/hosts/fusion/plugins/publish/validate_saver_has_input.py b/openpype/hosts/fusion/plugins/publish/validate_saver_has_input.py index faf2102a8b..0103e990fb 100644 --- a/openpype/hosts/fusion/plugins/publish/validate_saver_has_input.py +++ b/openpype/hosts/fusion/plugins/publish/validate_saver_has_input.py @@ -13,7 +13,7 @@ class ValidateSaverHasInput(pyblish.api.InstancePlugin): order = pyblish.api.ValidatorOrder label = "Validate Saver Has Input" - families = ["render"] + families = ["render", "image"] hosts = ["fusion"] actions = [SelectInvalidAction] diff --git a/openpype/hosts/fusion/plugins/publish/validate_saver_passthrough.py b/openpype/hosts/fusion/plugins/publish/validate_saver_passthrough.py index 9004976dc5..6019bee93a 100644 --- a/openpype/hosts/fusion/plugins/publish/validate_saver_passthrough.py +++ b/openpype/hosts/fusion/plugins/publish/validate_saver_passthrough.py @@ -9,7 +9,7 @@ class ValidateSaverPassthrough(pyblish.api.ContextPlugin): order = pyblish.api.ValidatorOrder label = "Validate Saver Passthrough" - families = ["render"] + families = ["render", "image"] hosts = ["fusion"] actions = [SelectInvalidAction] diff --git a/openpype/hosts/fusion/plugins/publish/validate_saver_resolution.py b/openpype/hosts/fusion/plugins/publish/validate_saver_resolution.py index efa7295d11..f6aba170c0 100644 --- a/openpype/hosts/fusion/plugins/publish/validate_saver_resolution.py +++ b/openpype/hosts/fusion/plugins/publish/validate_saver_resolution.py @@ -64,7 +64,7 @@ class ValidateSaverResolution( order = pyblish.api.ValidatorOrder label = "Validate Asset Resolution" - families = ["render"] + families = ["render", "image"] hosts = ["fusion"] optional = True actions = [SelectInvalidAction] diff --git a/openpype/hosts/fusion/plugins/publish/validate_unique_subsets.py b/openpype/hosts/fusion/plugins/publish/validate_unique_subsets.py index 5b6ceb2fdb..d1693ef3dc 100644 --- a/openpype/hosts/fusion/plugins/publish/validate_unique_subsets.py +++ b/openpype/hosts/fusion/plugins/publish/validate_unique_subsets.py @@ -11,7 +11,7 @@ class ValidateUniqueSubsets(pyblish.api.ContextPlugin): order = pyblish.api.ValidatorOrder label = "Validate Unique Subsets" - families = ["render"] + families = ["render", "image"] hosts = ["fusion"] actions = [SelectInvalidAction] diff --git a/openpype/hosts/max/api/lib_renderproducts.py b/openpype/hosts/max/api/lib_renderproducts.py index 90608737c2..eaf5015ba8 100644 --- a/openpype/hosts/max/api/lib_renderproducts.py +++ b/openpype/hosts/max/api/lib_renderproducts.py @@ -37,6 +37,95 @@ class RenderProducts(object): ) } + def get_multiple_beauty(self, outputs, cameras): + beauty_output_frames = dict() + for output, camera in zip(outputs, cameras): + filename, ext = os.path.splitext(output) + filename = filename.replace(".", "") + ext = ext.replace(".", "") + start_frame = int(rt.rendStart) + end_frame = int(rt.rendEnd) + 1 + new_beauty = self.get_expected_beauty( + filename, start_frame, end_frame, ext + ) + beauty_output = ({ + f"{camera}_beauty": new_beauty + }) + beauty_output_frames.update(beauty_output) + return beauty_output_frames + + def get_multiple_aovs(self, outputs, cameras): + renderer_class = get_current_renderer() + renderer = str(renderer_class).split(":")[0] + aovs_frames = {} + for output, camera in zip(outputs, cameras): + filename, ext = os.path.splitext(output) + filename = filename.replace(".", "") + ext = ext.replace(".", "") + start_frame = int(rt.rendStart) + end_frame = int(rt.rendEnd) + 1 + + if renderer in [ + "ART_Renderer", + "V_Ray_6_Hotfix_3", + "V_Ray_GPU_6_Hotfix_3", + "Default_Scanline_Renderer", + "Quicksilver_Hardware_Renderer", + ]: + render_name = self.get_render_elements_name() + if render_name: + for name in render_name: + aovs_frames.update({ + f"{camera}_{name}": self.get_expected_aovs( + filename, name, start_frame, + end_frame, ext) + }) + elif renderer == "Redshift_Renderer": + render_name = self.get_render_elements_name() + if render_name: + rs_aov_files = rt.Execute("renderers.current.separateAovFiles") # noqa + # this doesn't work, always returns False + # rs_AovFiles = rt.RedShift_Renderer().separateAovFiles + if ext == "exr" and not rs_aov_files: + for name in render_name: + if name == "RsCryptomatte": + aovs_frames.update({ + f"{camera}_{name}": self.get_expected_aovs( + filename, name, start_frame, + end_frame, ext) + }) + else: + for name in render_name: + aovs_frames.update({ + f"{camera}_{name}": self.get_expected_aovs( + filename, name, start_frame, + end_frame, ext) + }) + elif renderer == "Arnold": + render_name = self.get_arnold_product_name() + if render_name: + for name in render_name: + aovs_frames.update({ + f"{camera}_{name}": self.get_expected_arnold_product( # noqa + filename, name, start_frame, + end_frame, ext) + }) + elif renderer in [ + "V_Ray_6_Hotfix_3", + "V_Ray_GPU_6_Hotfix_3" + ]: + if ext != "exr": + render_name = self.get_render_elements_name() + if render_name: + for name in render_name: + aovs_frames.update({ + f"{camera}_{name}": self.get_expected_aovs( + filename, name, start_frame, + end_frame, ext) + }) + + return aovs_frames + def get_aovs(self, container): render_dir = os.path.dirname(rt.rendOutputFilename) @@ -63,7 +152,7 @@ class RenderProducts(object): if render_name: for name in render_name: render_dict.update({ - name: self.get_expected_render_elements( + name: self.get_expected_aovs( output_file, name, start_frame, end_frame, img_fmt) }) @@ -77,14 +166,14 @@ class RenderProducts(object): for name in render_name: if name == "RsCryptomatte": render_dict.update({ - name: self.get_expected_render_elements( + name: self.get_expected_aovs( output_file, name, start_frame, end_frame, img_fmt) }) else: for name in render_name: render_dict.update({ - name: self.get_expected_render_elements( + name: self.get_expected_aovs( output_file, name, start_frame, end_frame, img_fmt) }) @@ -95,7 +184,8 @@ class RenderProducts(object): for name in render_name: render_dict.update({ name: self.get_expected_arnold_product( - output_file, name, start_frame, end_frame, img_fmt) + output_file, name, start_frame, + end_frame, img_fmt) }) elif renderer in [ "V_Ray_6_Hotfix_3", @@ -106,7 +196,7 @@ class RenderProducts(object): if render_name: for name in render_name: render_dict.update({ - name: self.get_expected_render_elements( + name: self.get_expected_aovs( output_file, name, start_frame, end_frame, img_fmt) # noqa }) @@ -169,8 +259,8 @@ class RenderProducts(object): return render_name - def get_expected_render_elements(self, folder, name, - start_frame, end_frame, fmt): + def get_expected_aovs(self, folder, name, + start_frame, end_frame, fmt): """Get all the expected render element output files. """ render_elements = [] for f in range(start_frame, end_frame): diff --git a/openpype/hosts/max/api/lib_rendersettings.py b/openpype/hosts/max/api/lib_rendersettings.py index 26e176aa8d..be50e296eb 100644 --- a/openpype/hosts/max/api/lib_rendersettings.py +++ b/openpype/hosts/max/api/lib_rendersettings.py @@ -74,13 +74,13 @@ class RenderSettings(object): output = os.path.join(output_dir, container) try: aov_separator = self._aov_chars[( - self._project_settings["maya"] + self._project_settings["max"] ["RenderSettings"] ["aov_separator"] )] except KeyError: aov_separator = "." - output_filename = "{0}..{1}".format(output, img_fmt) + output_filename = f"{output}..{img_fmt}" output_filename = output_filename.replace("{aov_separator}", aov_separator) rt.rendOutputFilename = output_filename @@ -146,13 +146,13 @@ class RenderSettings(object): for i in range(render_elem_num): renderlayer_name = render_elem.GetRenderElement(i) target, renderpass = str(renderlayer_name).split(":") - aov_name = "{0}_{1}..{2}".format(dir, renderpass, ext) + aov_name = f"{dir}_{renderpass}..{ext}" render_elem.SetRenderElementFileName(i, aov_name) def get_render_output(self, container, output_dir): output = os.path.join(output_dir, container) img_fmt = self._project_settings["max"]["RenderSettings"]["image_format"] # noqa - output_filename = "{0}..{1}".format(output, img_fmt) + output_filename = f"{output}..{img_fmt}" return output_filename def get_render_element(self): @@ -167,3 +167,61 @@ class RenderSettings(object): orig_render_elem.append(render_element) return orig_render_elem + + def get_batch_render_elements(self, container, + output_dir, camera): + render_element_list = list() + output = os.path.join(output_dir, container) + render_elem = rt.maxOps.GetCurRenderElementMgr() + render_elem_num = render_elem.NumRenderElements() + if render_elem_num < 0: + return + img_fmt = self._project_settings["max"]["RenderSettings"]["image_format"] # noqa + + for i in range(render_elem_num): + renderlayer_name = render_elem.GetRenderElement(i) + target, renderpass = str(renderlayer_name).split(":") + aov_name = f"{output}_{camera}_{renderpass}..{img_fmt}" + render_element_list.append(aov_name) + return render_element_list + + def get_batch_render_output(self, camera): + target_layer_no = rt.batchRenderMgr.FindView(camera) + target_layer = rt.batchRenderMgr.GetView(target_layer_no) + return target_layer.outputFilename + + def batch_render_elements(self, camera): + target_layer_no = rt.batchRenderMgr.FindView(camera) + target_layer = rt.batchRenderMgr.GetView(target_layer_no) + outputfilename = target_layer.outputFilename + directory = os.path.dirname(outputfilename) + render_elem = rt.maxOps.GetCurRenderElementMgr() + render_elem_num = render_elem.NumRenderElements() + if render_elem_num < 0: + return + ext = self._project_settings["max"]["RenderSettings"]["image_format"] # noqa + + for i in range(render_elem_num): + renderlayer_name = render_elem.GetRenderElement(i) + target, renderpass = str(renderlayer_name).split(":") + aov_name = f"{directory}_{camera}_{renderpass}..{ext}" + render_elem.SetRenderElementFileName(i, aov_name) + + def batch_render_layer(self, container, + output_dir, cameras): + outputs = list() + output = os.path.join(output_dir, container) + img_fmt = self._project_settings["max"]["RenderSettings"]["image_format"] # noqa + for cam in cameras: + camera = rt.getNodeByName(cam) + layer_no = rt.batchRenderMgr.FindView(cam) + renderlayer = None + if layer_no == 0: + renderlayer = rt.batchRenderMgr.CreateView(camera) + else: + renderlayer = rt.batchRenderMgr.GetView(layer_no) + # use camera name as renderlayer name + renderlayer.name = cam + renderlayer.outputFilename = f"{output}_{cam}..{img_fmt}" + outputs.append(renderlayer.outputFilename) + return outputs diff --git a/openpype/hosts/max/plugins/create/create_render.py b/openpype/hosts/max/plugins/create/create_render.py index 9cc3c8da8a..617334753a 100644 --- a/openpype/hosts/max/plugins/create/create_render.py +++ b/openpype/hosts/max/plugins/create/create_render.py @@ -2,6 +2,7 @@ """Creator plugin for creating camera.""" import os from openpype.hosts.max.api import plugin +from openpype.lib import BoolDef from openpype.hosts.max.api.lib_rendersettings import RenderSettings @@ -17,15 +18,33 @@ class CreateRender(plugin.MaxCreator): file = rt.maxFileName filename, _ = os.path.splitext(file) instance_data["AssetName"] = filename + instance_data["multiCamera"] = pre_create_data.get("multi_cam") + num_of_renderlayer = rt.batchRenderMgr.numViews + if num_of_renderlayer > 0: + rt.batchRenderMgr.DeleteView(num_of_renderlayer) instance = super(CreateRender, self).create( subset_name, instance_data, pre_create_data) + container_name = instance.data.get("instance_node") - sel_obj = self.selected_nodes - if sel_obj: - # set viewport camera for rendering(mandatory for deadline) - RenderSettings(self.project_settings).set_render_camera(sel_obj) # set output paths for rendering(mandatory for deadline) RenderSettings().render_output(container_name) + # TODO: create multiple camera options + if self.selected_nodes: + selected_nodes_name = [] + for sel in self.selected_nodes: + name = sel.name + selected_nodes_name.append(name) + RenderSettings().batch_render_layer( + container_name, filename, + selected_nodes_name) + + def get_pre_create_attr_defs(self): + attrs = super(CreateRender, self).get_pre_create_attr_defs() + return attrs + [ + BoolDef("multi_cam", + label="Multiple Cameras Submission", + default=False), + ] diff --git a/openpype/hosts/max/plugins/publish/collect_render.py b/openpype/hosts/max/plugins/publish/collect_render.py index 38194a0735..8abffa5ab6 100644 --- a/openpype/hosts/max/plugins/publish/collect_render.py +++ b/openpype/hosts/max/plugins/publish/collect_render.py @@ -4,8 +4,10 @@ import os import pyblish.api from pymxs import runtime as rt +from openpype.pipeline.publish import KnownPublishError from openpype.hosts.max.api import colorspace from openpype.hosts.max.api.lib import get_max_version, get_current_renderer +from openpype.hosts.max.api.lib_rendersettings import RenderSettings from openpype.hosts.max.api.lib_renderproducts import RenderProducts @@ -23,7 +25,6 @@ class CollectRender(pyblish.api.InstancePlugin): file = rt.maxFileName current_file = os.path.join(folder, file) filepath = current_file.replace("\\", "/") - context.data['currentFile'] = current_file files_by_aov = RenderProducts().get_beauty(instance.name) @@ -39,6 +40,28 @@ class CollectRender(pyblish.api.InstancePlugin): instance.data["cameras"] = [camera.name] if camera else None # noqa + if instance.data.get("multiCamera"): + cameras = instance.data.get("members") + if not cameras: + raise KnownPublishError("There should be at least" + " one renderable camera in container") + sel_cam = [ + c.name for c in cameras + if rt.classOf(c) in rt.Camera.classes] + container_name = instance.data.get("instance_node") + render_dir = os.path.dirname(rt.rendOutputFilename) + outputs = RenderSettings().batch_render_layer( + container_name, render_dir, sel_cam + ) + + instance.data["cameras"] = sel_cam + + files_by_aov = RenderProducts().get_multiple_beauty( + outputs, sel_cam) + aovs = RenderProducts().get_multiple_aovs( + outputs, sel_cam) + files_by_aov.update(aovs) + if "expectedFiles" not in instance.data: instance.data["expectedFiles"] = list() instance.data["files"] = list() diff --git a/openpype/hosts/max/plugins/publish/save_scenes_for_cameras.py b/openpype/hosts/max/plugins/publish/save_scenes_for_cameras.py new file mode 100644 index 0000000000..c39109417b --- /dev/null +++ b/openpype/hosts/max/plugins/publish/save_scenes_for_cameras.py @@ -0,0 +1,100 @@ +import pyblish.api +import os +import sys +import tempfile + +from pymxs import runtime as rt +from openpype.lib import run_subprocess +from openpype.hosts.max.api.lib_rendersettings import RenderSettings +from openpype.hosts.max.api.lib_renderproducts import RenderProducts + + +class SaveScenesForCamera(pyblish.api.InstancePlugin): + """Save scene files for multiple cameras without + editing the original scene before deadline submission + + """ + + label = "Save Scene files for cameras" + order = pyblish.api.ExtractorOrder - 0.48 + hosts = ["max"] + families = ["maxrender"] + + def process(self, instance): + current_folder = rt.maxFilePath + current_filename = rt.maxFileName + current_filepath = os.path.join(current_folder, current_filename) + camera_scene_files = [] + scripts = [] + filename, ext = os.path.splitext(current_filename) + fmt = RenderProducts().image_format() + cameras = instance.data.get("cameras") + if not cameras: + return + new_folder = f"{current_folder}_{filename}" + os.makedirs(new_folder, exist_ok=True) + for camera in cameras: + new_output = RenderSettings().get_batch_render_output(camera) # noqa + new_output = new_output.replace("\\", "/") + new_filename = f"{filename}_{camera}{ext}" + new_filepath = os.path.join(new_folder, new_filename) + new_filepath = new_filepath.replace("\\", "/") + camera_scene_files.append(new_filepath) + RenderSettings().batch_render_elements(camera) + rt.rendOutputFilename = new_output + rt.saveMaxFile(current_filepath) + script = (""" +from pymxs import runtime as rt +import os +filename = "{filename}" +new_filepath = "{new_filepath}" +new_output = "{new_output}" +camera = "{camera}" +rt.rendOutputFilename = new_output +directory = os.path.dirname(rt.rendOutputFilename) +directory = os.path.join(directory, filename) +render_elem = rt.maxOps.GetCurRenderElementMgr() +render_elem_num = render_elem.NumRenderElements() +if render_elem_num > 0: + ext = "{ext}" + for i in range(render_elem_num): + renderlayer_name = render_elem.GetRenderElement(i) + target, renderpass = str(renderlayer_name).split(":") + aov_name = f"{{directory}}_{camera}_{{renderpass}}..{ext}" + render_elem.SetRenderElementFileName(i, aov_name) +rt.saveMaxFile(new_filepath) + """).format(filename=instance.name, + new_filepath=new_filepath, + new_output=new_output, + camera=camera, + ext=fmt) + scripts.append(script) + + maxbatch_exe = os.path.join( + os.path.dirname(sys.executable), "3dsmaxbatch") + maxbatch_exe = maxbatch_exe.replace("\\", "/") + if sys.platform == "windows": + maxbatch_exe += ".exe" + maxbatch_exe = os.path.normpath(maxbatch_exe) + with tempfile.TemporaryDirectory() as tmp_dir_name: + tmp_script_path = os.path.join( + tmp_dir_name, "extract_scene_files.py") + self.log.info("Using script file: {}".format(tmp_script_path)) + + with open(tmp_script_path, "wt") as tmp: + for script in scripts: + tmp.write(script + "\n") + + try: + current_filepath = current_filepath.replace("\\", "/") + tmp_script_path = tmp_script_path.replace("\\", "/") + run_subprocess([maxbatch_exe, tmp_script_path, + "-sceneFile", current_filepath]) + except RuntimeError: + self.log.debug("Checking the scene files existing") + + for camera_scene in camera_scene_files: + if not os.path.exists(camera_scene): + self.log.error("Camera scene files not existed yet!") + raise RuntimeError("MaxBatch.exe doesn't run as expected") + self.log.debug(f"Found Camera scene:{camera_scene}") diff --git a/openpype/hosts/maya/plugins/publish/extract_camera_mayaScene.py b/openpype/hosts/maya/plugins/publish/extract_camera_mayaScene.py index 38cf00bbdd..f67e9db14f 100644 --- a/openpype/hosts/maya/plugins/publish/extract_camera_mayaScene.py +++ b/openpype/hosts/maya/plugins/publish/extract_camera_mayaScene.py @@ -265,13 +265,16 @@ def transfer_image_planes(source_cameras, target_cameras, try: for source_camera, target_camera in zip(source_cameras, target_cameras): - image_planes = cmds.listConnections(source_camera, + image_plane_plug = "{}.imagePlane".format(source_camera) + image_planes = cmds.listConnections(image_plane_plug, + source=True, + destination=False, type="imagePlane") or [] # Split of the parent path they are attached - we want - # the image plane node name. + # the image plane node name if attached to a camera. # TODO: Does this still mean the image plane name is unique? - image_planes = [x.split("->", 1)[1] for x in image_planes] + image_planes = [x.split("->", 1)[-1] for x in image_planes] if not image_planes: continue @@ -282,7 +285,7 @@ def transfer_image_planes(source_cameras, target_cameras, if source_camera == target_camera: continue _attach_image_plane(target_camera, image_plane) - else: # explicitly dettaching image planes + else: # explicitly detach image planes cmds.imagePlane(image_plane, edit=True, detach=True) originals[source_camera].append(image_plane) yield diff --git a/openpype/hosts/substancepainter/plugins/publish/validate_ouput_maps.py b/openpype/hosts/substancepainter/plugins/publish/validate_ouput_maps.py index b57cf4c5a2..252683b6c8 100644 --- a/openpype/hosts/substancepainter/plugins/publish/validate_ouput_maps.py +++ b/openpype/hosts/substancepainter/plugins/publish/validate_ouput_maps.py @@ -80,6 +80,7 @@ class ValidateOutputMaps(pyblish.api.InstancePlugin): self.log.warning(f"Disabling texture instance: " f"{image_instance}") image_instance.data["active"] = False + image_instance.data["publish"] = False image_instance.data["integrate"] = False representation.setdefault("tags", []).append("delete") continue diff --git a/openpype/lib/transcoding.py b/openpype/lib/transcoding.py index 316dedbd3d..03fd91bec2 100644 --- a/openpype/lib/transcoding.py +++ b/openpype/lib/transcoding.py @@ -110,8 +110,9 @@ def get_oiio_info_for_input(filepath, logger=None, subimages=False): if line == "": subimages_lines.append(lines) lines = [] + xml_started = False - if not xml_started: + if not subimages_lines: raise ValueError( "Failed to read input file \"{}\".\nOutput:\n{}".format( filepath, output diff --git a/openpype/modules/deadline/plugins/publish/submit_max_deadline.py b/openpype/modules/deadline/plugins/publish/submit_max_deadline.py index 23d4183132..f06bd4dbe6 100644 --- a/openpype/modules/deadline/plugins/publish/submit_max_deadline.py +++ b/openpype/modules/deadline/plugins/publish/submit_max_deadline.py @@ -15,6 +15,12 @@ from openpype.pipeline import ( from openpype.pipeline.publish.lib import ( replace_with_published_scene_path ) +from openpype.pipeline.publish import KnownPublishError +from openpype.hosts.max.api.lib import ( + get_current_renderer, + get_multipass_setting +) +from openpype.hosts.max.api.lib_rendersettings import RenderSettings from openpype_modules.deadline import abstract_submit_deadline from openpype_modules.deadline.abstract_submit_deadline import DeadlineJobInfo from openpype.lib import is_running_from_build @@ -54,7 +60,7 @@ class MaxSubmitDeadline(abstract_submit_deadline.AbstractSubmitDeadline, cls.priority) cls.chuck_size = settings.get("chunk_size", cls.chunk_size) cls.group = settings.get("group", cls.group) - + # TODO: multiple camera instance, separate job infos def get_job_info(self): job_info = DeadlineJobInfo(Plugin="3dsmax") @@ -71,7 +77,6 @@ class MaxSubmitDeadline(abstract_submit_deadline.AbstractSubmitDeadline, src_filepath = context.data["currentFile"] src_filename = os.path.basename(src_filepath) - job_info.Name = "%s - %s" % (src_filename, instance.name) job_info.BatchName = src_filename job_info.Plugin = instance.data["plugin"] @@ -134,11 +139,11 @@ class MaxSubmitDeadline(abstract_submit_deadline.AbstractSubmitDeadline, # Add list of expected files to job # --------------------------------- - exp = instance.data.get("expectedFiles") - - for filepath in self._iter_expected_files(exp): - job_info.OutputDirectory += os.path.dirname(filepath) - job_info.OutputFilename += os.path.basename(filepath) + if not instance.data.get("multiCamera"): + exp = instance.data.get("expectedFiles") + for filepath in self._iter_expected_files(exp): + job_info.OutputDirectory += os.path.dirname(filepath) + job_info.OutputFilename += os.path.basename(filepath) return job_info @@ -163,11 +168,11 @@ class MaxSubmitDeadline(abstract_submit_deadline.AbstractSubmitDeadline, def process_submission(self): instance = self._instance - filepath = self.scene_path + filepath = instance.context.data["currentFile"] files = instance.data["expectedFiles"] if not files: - raise RuntimeError("No Render Elements found!") + raise KnownPublishError("No Render Elements found!") first_file = next(self._iter_expected_files(files)) output_dir = os.path.dirname(first_file) instance.data["outputDir"] = output_dir @@ -181,9 +186,17 @@ class MaxSubmitDeadline(abstract_submit_deadline.AbstractSubmitDeadline, self.log.debug("Submitting 3dsMax render..") project_settings = instance.context.data["project_settings"] - payload = self._use_published_name(payload_data, project_settings) - job_info, plugin_info = payload - self.submit(self.assemble_payload(job_info, plugin_info)) + if instance.data.get("multiCamera"): + self.log.debug("Submitting jobs for multiple cameras..") + payload = self._use_published_name_for_multiples( + payload_data, project_settings) + job_infos, plugin_infos = payload + for job_info, plugin_info in zip(job_infos, plugin_infos): + self.submit(self.assemble_payload(job_info, plugin_info)) + else: + payload = self._use_published_name(payload_data, project_settings) + job_info, plugin_info = payload + self.submit(self.assemble_payload(job_info, plugin_info)) def _use_published_name(self, data, project_settings): # Not all hosts can import these modules. @@ -206,7 +219,7 @@ class MaxSubmitDeadline(abstract_submit_deadline.AbstractSubmitDeadline, files = instance.data.get("expectedFiles") if not files: - raise RuntimeError("No render elements found") + raise KnownPublishError("No render elements found") first_file = next(self._iter_expected_files(files)) old_output_dir = os.path.dirname(first_file) output_beauty = RenderSettings().get_render_output(instance.name, @@ -218,6 +231,7 @@ class MaxSubmitDeadline(abstract_submit_deadline.AbstractSubmitDeadline, plugin_data["RenderOutput"] = beauty_name # as 3dsmax has version with different languages plugin_data["Language"] = "ENU" + renderer_class = get_current_renderer() renderer = str(renderer_class).split(":")[0] @@ -249,6 +263,120 @@ class MaxSubmitDeadline(abstract_submit_deadline.AbstractSubmitDeadline, return job_info, plugin_info + def get_job_info_through_camera(self, camera): + """Get the job parameters for deadline submission when + multi-camera is enabled. + Args: + infos(dict): a dictionary with job info. + """ + instance = self._instance + context = instance.context + job_info = copy.deepcopy(self.job_info) + exp = instance.data.get("expectedFiles") + + src_filepath = context.data["currentFile"] + src_filename = os.path.basename(src_filepath) + job_info.Name = "%s - %s - %s" % ( + src_filename, instance.name, camera) + for filepath in self._iter_expected_files(exp): + if camera not in filepath: + continue + job_info.OutputDirectory += os.path.dirname(filepath) + job_info.OutputFilename += os.path.basename(filepath) + + return job_info + # set the output filepath with the relative camera + + def get_plugin_info_through_camera(self, camera): + """Get the plugin parameters for deadline submission when + multi-camera is enabled. + Args: + infos(dict): a dictionary with plugin info. + """ + instance = self._instance + # set the target camera + plugin_info = copy.deepcopy(self.plugin_info) + + plugin_data = {} + # set the output filepath with the relative camera + if instance.data.get("multiCamera"): + scene_filepath = instance.context.data["currentFile"] + scene_filename = os.path.basename(scene_filepath) + scene_directory = os.path.dirname(scene_filepath) + current_filename, ext = os.path.splitext(scene_filename) + camera_scene_name = f"{current_filename}_{camera}{ext}" + camera_scene_filepath = os.path.join( + scene_directory, f"_{current_filename}", camera_scene_name) + plugin_data["SceneFile"] = camera_scene_filepath + + files = instance.data.get("expectedFiles") + if not files: + raise KnownPublishError("No render elements found") + first_file = next(self._iter_expected_files(files)) + old_output_dir = os.path.dirname(first_file) + rgb_output = RenderSettings().get_batch_render_output(camera) # noqa + rgb_bname = os.path.basename(rgb_output) + dir = os.path.dirname(first_file) + beauty_name = f"{dir}/{rgb_bname}" + beauty_name = beauty_name.replace("\\", "/") + plugin_info["RenderOutput"] = beauty_name + renderer_class = get_current_renderer() + + renderer = str(renderer_class).split(":")[0] + if renderer in [ + "ART_Renderer", + "Redshift_Renderer", + "V_Ray_6_Hotfix_3", + "V_Ray_GPU_6_Hotfix_3", + "Default_Scanline_Renderer", + "Quicksilver_Hardware_Renderer", + ]: + render_elem_list = RenderSettings().get_batch_render_elements( + instance.name, old_output_dir, camera + ) + for i, element in enumerate(render_elem_list): + if camera in element: + elem_bname = os.path.basename(element) + new_elem = f"{dir}/{elem_bname}" + new_elem = new_elem.replace("/", "\\") + plugin_info["RenderElementOutputFilename%d" % i] = new_elem # noqa + + if camera: + # set the default camera and target camera + # (weird parameters from max) + plugin_data["Camera"] = camera + plugin_data["Camera1"] = camera + plugin_data["Camera0"] = None + + plugin_info.update(plugin_data) + return plugin_info + + def _use_published_name_for_multiples(self, data, project_settings): + """Process the parameters submission for deadline when + user enables multi-cameras option. + Args: + job_info_list (list): A list of multiple job infos + plugin_info_list (list): A list of multiple plugin infos + """ + job_info_list = [] + plugin_info_list = [] + instance = self._instance + cameras = instance.data.get("cameras", []) + plugin_data = {} + multipass = get_multipass_setting(project_settings) + if multipass: + plugin_data["DisableMultipass"] = 0 + else: + plugin_data["DisableMultipass"] = 1 + for cam in cameras: + job_info = self.get_job_info_through_camera(cam) + plugin_info = self.get_plugin_info_through_camera(cam) + plugin_info.update(plugin_data) + job_info_list.append(job_info) + plugin_info_list.append(plugin_info) + + return job_info_list, plugin_info_list + def from_published_scene(self, replace_in_path=True): instance = self._instance if instance.data["renderer"] == "Redshift_Renderer": diff --git a/openpype/pipeline/farm/pyblish_functions.py b/openpype/pipeline/farm/pyblish_functions.py index 54ff2627e1..975fdd31cc 100644 --- a/openpype/pipeline/farm/pyblish_functions.py +++ b/openpype/pipeline/farm/pyblish_functions.py @@ -582,16 +582,17 @@ def _create_instances_for_aov(instance, skeleton, aov_filter, additional_data, group_name = subset # if there are multiple cameras, we need to add camera name - if isinstance(col, (list, tuple)): - cam = [c for c in cameras if c in col[0]] - else: - # in case of single frame - cam = [c for c in cameras if c in col] - if cam: - if aov: - subset_name = '{}_{}_{}'.format(group_name, cam, aov) - else: - subset_name = '{}_{}'.format(group_name, cam) + expected_filepath = col[0] if isinstance(col, (list, tuple)) else col + cams = [cam for cam in cameras if cam in expected_filepath] + if cams: + for cam in cams: + if aov: + if not aov.startswith(cam): + subset_name = '{}_{}_{}'.format(group_name, cam, aov) + else: + subset_name = "{}_{}".format(group_name, aov) + else: + subset_name = '{}_{}'.format(group_name, cam) else: if aov: subset_name = '{}_{}'.format(group_name, aov) diff --git a/openpype/plugins/publish/collect_anatomy_instance_data.py b/openpype/plugins/publish/collect_anatomy_instance_data.py index 0a34848166..34df49ea5b 100644 --- a/openpype/plugins/publish/collect_anatomy_instance_data.py +++ b/openpype/plugins/publish/collect_anatomy_instance_data.py @@ -200,9 +200,15 @@ class CollectAnatomyInstanceData(pyblish.api.ContextPlugin): self._fill_task_data(instance, project_task_types, anatomy_data) # Define version + version_number = None if self.follow_workfile_version: version_number = context.data("version") - else: + + # Even if 'follow_workfile_version' is enabled, it may not be set + # because workfile version was not collected to 'context.data' + # - that can happen e.g. in 'traypublisher' or other hosts without + # a workfile + if version_number is None: version_number = instance.data.get("version") # use latest version (+1) if already any exist diff --git a/openpype/settings/ayon_settings.py b/openpype/settings/ayon_settings.py index a6d90d1cf0..36f96c9dc7 100644 --- a/openpype/settings/ayon_settings.py +++ b/openpype/settings/ayon_settings.py @@ -1291,12 +1291,6 @@ def _convert_global_project_settings(ayon_settings, output, default_settings): for extract_burnin_def in extract_burnin_defs } - ayon_integrate_hero = ayon_publish["IntegrateHeroVersion"] - for profile in ayon_integrate_hero["template_name_profiles"]: - if "product_types" not in profile: - break - profile["families"] = profile.pop("product_types") - if "IntegrateProductGroup" in ayon_publish: subset_group = ayon_publish.pop("IntegrateProductGroup") subset_group_profiles = subset_group.pop("product_grouping_profiles") diff --git a/openpype/settings/defaults/project_settings/fusion.json b/openpype/settings/defaults/project_settings/fusion.json index 8579442625..15b6bfc09b 100644 --- a/openpype/settings/defaults/project_settings/fusion.json +++ b/openpype/settings/defaults/project_settings/fusion.json @@ -32,6 +32,18 @@ "farm_rendering" ], "image_format": "exr" + }, + "CreateImageSaver": { + "temp_rendering_path_template": "{workdir}/renders/fusion/{subset}/{subset}.{ext}", + "default_variants": [ + "Main", + "Mask" + ], + "instance_attributes": [ + "reviewable", + "farm_rendering" + ], + "image_format": "exr" } }, "publish": { diff --git a/openpype/settings/entities/schemas/projects_schema/schema_project_fusion.json b/openpype/settings/entities/schemas/projects_schema/schema_project_fusion.json index fbd856b895..8669842087 100644 --- a/openpype/settings/entities/schemas/projects_schema/schema_project_fusion.json +++ b/openpype/settings/entities/schemas/projects_schema/schema_project_fusion.json @@ -74,7 +74,56 @@ "type": "dict", "collapsible": true, "key": "CreateSaver", - "label": "Create Saver", + "label": "Create Render Saver", + "is_group": true, + "children": [ + { + "type": "text", + "key": "temp_rendering_path_template", + "label": "Temporary rendering path template" + }, + { + "type": "list", + "key": "default_variants", + "label": "Default variants", + "object_type": { + "type": "text" + } + }, + { + "key": "instance_attributes", + "label": "Instance attributes", + "type": "enum", + "multiselection": true, + "enum_items": [ + { + "reviewable": "Reviewable" + }, + { + "farm_rendering": "Farm rendering" + } + ] + }, + { + "key": "image_format", + "label": "Output Image Format", + "type": "enum", + "multiselect": false, + "enum_items": [ + {"exr": "exr"}, + {"tga": "tga"}, + {"png": "png"}, + {"tif": "tif"}, + {"jpg": "jpg"} + ] + } + ] + }, + { + "type": "dict", + "collapsible": true, + "key": "CreateImageSaver", + "label": "Create Image Saver", "is_group": true, "children": [ { diff --git a/openpype/tools/utils/lib.py b/openpype/tools/utils/lib.py index 723e71e7aa..365caaafd9 100644 --- a/openpype/tools/utils/lib.py +++ b/openpype/tools/utils/lib.py @@ -91,7 +91,8 @@ def set_style_property(widget, property_name, property_value): if cur_value == property_value: return widget.setProperty(property_name, property_value) - widget.style().polish(widget) + style = widget.style() + style.polish(widget) def paint_image_with_color(image, color): diff --git a/server_addon/fusion/server/settings.py b/server_addon/fusion/server/settings.py index 21189b390e..bf295f3064 100644 --- a/server_addon/fusion/server/settings.py +++ b/server_addon/fusion/server/settings.py @@ -25,6 +25,24 @@ def _create_saver_instance_attributes_enum(): ] +def _image_format_enum(): + return [ + {"value": "exr", "label": "exr"}, + {"value": "tga", "label": "tga"}, + {"value": "png", "label": "png"}, + {"value": "tif", "label": "tif"}, + {"value": "jpg", "label": "jpg"}, + ] + + +def _frame_range_options_enum(): + return [ + {"value": "asset_db", "label": "Current asset context"}, + {"value": "render_range", "label": "From render in/out"}, + {"value": "comp_range", "label": "From composition timeline"}, + ] + + class CreateSaverPluginModel(BaseSettingsModel): _isGroup = True temp_rendering_path_template: str = Field( @@ -59,10 +77,29 @@ class HooksModel(BaseSettingsModel): ) +class CreateSaverModel(CreateSaverPluginModel): + default_frame_range_option: str = Field( + default="asset_db", + enum_resolver=_frame_range_options_enum, + title="Default frame range source" + ) + + +class CreateImageSaverModel(CreateSaverPluginModel): + default_frame: int = Field( + 0, + title="Default rendered frame" + ) class CreatPluginsModel(BaseSettingsModel): - CreateSaver: CreateSaverPluginModel = Field( - default_factory=CreateSaverPluginModel, - title="Create Saver" + CreateSaver: CreateSaverModel = Field( + default_factory=CreateSaverModel, + title="Create Saver", + description="Creator for render product type (eg. sequence)" + ) + CreateImageSaver: CreateImageSaverModel = Field( + default_factory=CreateImageSaverModel, + title="Create Image Saver", + description="Creator for image product type (eg. single)" ) @@ -117,15 +154,21 @@ DEFAULT_VALUES = { "reviewable", "farm_rendering" ], - "output_formats": [ - "exr", - "jpg", - "jpeg", - "jpg", - "tiff", - "png", - "tga" - ] + "image_format": "exr", + "default_frame_range_option": "asset_db" + }, + "CreateImageSaver": { + "temp_rendering_path_template": "{workdir}/renders/fusion/{product[name]}/{product[name]}.{ext}", + "default_variants": [ + "Main", + "Mask" + ], + "instance_attributes": [ + "reviewable", + "farm_rendering" + ], + "image_format": "exr", + "default_frame": 0 } } } diff --git a/server_addon/fusion/server/version.py b/server_addon/fusion/server/version.py index b3f4756216..ae7362549b 100644 --- a/server_addon/fusion/server/version.py +++ b/server_addon/fusion/server/version.py @@ -1 +1 @@ -__version__ = "0.1.2" +__version__ = "0.1.3"