diff --git a/hooks/tk-desktop_actions.py b/hooks/tk-desktop_actions.py new file mode 100644 index 0000000..c87efaf --- /dev/null +++ b/hooks/tk-desktop_actions.py @@ -0,0 +1,291 @@ +# Copyright (c) 2026 Shotgun Software Inc. +# +# CONFIDENTIAL AND PROPRIETARY +# +# This work is provided "AS IS" and subject to the Shotgun Pipeline Toolkit +# Source Code License included in this distribution package. See LICENSE. +# By accessing, using, copying or modifying this work you indicate your +# agreement to the Shotgun Pipeline Toolkit Source Code License. All rights +# not expressly granted therein are reserved by Shotgun Software Inc. + +""" +Hook that loads defines all the available actions, broken down by publish type. +""" + +import os +from typing import Any + +import sgtk +from sgtk import TankError + +HookBaseClass = sgtk.get_hook_baseclass() + + +class DesktopActions(HookBaseClass): + """ + Stub implementation of the shell actions, used for testing. + """ + + def generate_actions( + self, + sg_publish_data: dict, + actions: list, + ui_area: str, + am_base_obj: Any = None, + ) -> list: + """ + Return a list of action instances for a particular publish. + This method is called each time a user clicks a publish somewhere in the UI. + The data returned from this hook will be used to populate the actions menu for a publish. + + The mapping between Publish types and actions are kept in a different place + (in the configuration) so at the point when this hook is called, the loader app + has already established *which* actions are appropriate for this object. + + The hook should return at least one action for each item passed in via the + actions parameter. + + This method needs to return detailed data for those actions, in the form of a list + of dictionaries, each with name, params, caption and description keys. + + Because you are operating on a particular publish, you may tailor the output + (caption, tooltip etc) to contain custom information suitable for this publish. + + The ui_area parameter is a string and indicates where the publish is to be shown. + - If it will be shown in the main browsing area, "main" is passed. + - If it will be shown in the details area, "details" is passed. + - If it will be shown in the history area, "history" is passed. + + Please note that it is perfectly possible to create more than one action "instance" for + an action! You can for example do scene introspection - if the action passed in + is "character_attachment" you may for example scan the scene, figure out all the nodes + where this object can be attached and return a list of action instances: + "attach to left hand", "attach to right hand" etc. In this case, when more than + one object is returned for an action, use the params key to pass additional + data into the run_action hook. + + :param sg_publish_data: Shotgun data dictionary with all the standard publish fields. + :param actions: List of action strings which have been defined in the app configuration. + :param ui_area: String denoting the UI Area (see above). + :returns List of dictionaries, each with keys name, params, caption and description + """ + app = self.parent + app.log_debug( + "Generate actions called for UI element %s. " + "Actions: %s. Publish Data: %s" % (ui_area, actions, sg_publish_data) + ) + + action_instances = [] + + if "download" in actions and sg_publish_data.get("type") == "PublishedFile": + version_number = sg_publish_data.get("version_number") + + if ( + version_number is not None + and version_number != am_base_obj.DRAFT_VERSION_IDENTIFIER + ): + action_instances.append( + { + "name": "download", + "params": "Download 'params'", + "caption": "Download", + "description": "Downloads the published file to a user specified location.", + } + ) + + if "publish" in actions: + # Show publish action only for published files (not drafts) + # Drafts (version == -1) are not supported in generic workflow + version_number = sg_publish_data.get("version_number") + + if version_number is not None and version_number >= 0: + action_instances.append( + { + "name": "publish", + "params": "Publish 'params'", + "caption": "Publish", + "description": "Publish a new revision of this generic asset.", + } + ) + + if "create_generic_asset" in actions: + action_instances.append( + { + "name": "create_generic_asset", + "params": "Create Generic Asset 'params'", + "caption": "Create Generic Asset", + "description": "Executes Create Generic Asset.", + } + ) + + if ( + "reference_copy_link" in actions + and sg_publish_data.get( + "version_number", am_base_obj.DRAFT_VERSION_IDENTIFIER + ) + > am_base_obj.DRAFT_VERSION_IDENTIFIER + ): + action_instances.append( + { + "name": "reference_copy_link", + "params": None, + "caption": "Copy Reference Link", + "description": "This will copy the reference as a string to the clipboard", + "multi_select": False, + } + ) + + return action_instances + + def execute_multiple_actions(self, actions: list, am_base_obj: Any = None) -> None: + """ + Executes the specified action on a list of items. + + The default implementation dispatches each item from ``actions`` to + the ``execute_action`` method. + + The ``actions`` is a list of dictionaries holding all the actions to execute. + Each entry will have the following values: + + name: Name of the action to execute + sg_publish_data: Publish information coming from Shotgun + params: Parameters passed down from the generate_actions hook. + + .. note:: + This is the default entry point for the hook. It reuses the ``execute_action`` + method for backward compatibility with hooks written for the previous + version of the loader. + + .. note:: + The hook will stop applying the actions on the selection if an error + is raised midway through. + + :param list actions: Action dictionaries. + """ + app = self.parent + app.log_info("Executing action '%s' on the selection") + # Helps to visually scope selections + # Execute each action. + for single_action in actions: + name = single_action["name"] + sg_publish_data = single_action["sg_publish_data"] + params = single_action["params"] + self.execute_action(name, params, sg_publish_data, am_base_obj) + + def execute_action( + self, + name: str, + params: Any, + sg_publish_data: dict, + am_base_obj: Any = None, + ) -> None: + """ + Print out all actions. The data sent to this be method will + represent one of the actions enumerated by the generate_actions method. + + :param name: Action name string representing one of the items returned by generate_actions. + :param params: Params data, as specified by generate_actions. + :param sg_publish_data: Shotgun data dictionary with all the standard publish fields. + :returns: No return value expected. + """ + app = self.parent + app.log_debug( + "Execute action called for action %s. " + "Parameters: %s. Publish Data: %s" % (name, params, sg_publish_data) + ) + + if name == "create_generic_asset": + # Right click a task the left panel + self._launch_publisher(name, sg_publish_data) + + elif name == "publish": + # action for a single PublishedFile + self._launch_publisher(name, sg_publish_data) + + elif name == "reference_copy_link": + am_base_obj._create_reference_copy_link(sg_publish_data) + + elif name == "download": + am_base_obj._download_asset_revision(sg_publish_data) + + def _launch_publisher(self, action_name: str, sg_publish_data: dict) -> None: + """ + Launches the publisher app in the context of the specified entity (task or project). + :param str action_name: Action name that triggered the publisher launch. + :param dict sg_publish_data: Shotgun data dictionary with all the standard publish fields. + """ + engine = sgtk.platform.current_engine() + + entity_type = sg_publish_data.get("type") + if not entity_type: + raise TankError("sg_publish_data missing 'type' field") + + if entity_type == "Task": + # Case when action is triggered from a Task item + # as with "Create generic asset" action + task_id = sg_publish_data["id"] + entity_id = task_id + elif entity_type == "Project": + # Case when action is triggered from a Project item + # as with "Create generic asset" action + task_id = None # in this case task id is not relevant + entity_id = sg_publish_data["id"] + elif sg_publish_data.get("task"): + # Case when action is triggered from a PublishedFile item + # from task level, as with "Publish" action + task_id = sg_publish_data["task"]["id"] + entity_type = "Task" + entity_id = task_id + elif sg_publish_data.get("project"): + # Case when action is trigger from a PublishedFile item + # from project level, as with "Publish" action + task_id = None # in this case task id is not relevant + entity_type = "Project" + entity_id = sg_publish_data["project"]["id"] + else: + raise TankError(f"Invalid entity type for publish: {entity_type}.") + + # Use different env var naming for project-level vs task-level contexts + if task_id is not None: + revision_id_env_var = f"TK_FLOWAM_REVISION_ID_{task_id}" + else: + # For project-level contexts, use project ID + project_id = entity_id + revision_id_env_var = f"TK_FLOWAM_REVISION_ID_PROJECT_{project_id}" + + if action_name == "publish": + revision_id = sg_publish_data.get("sg_flow_revision_id") + os.environ[revision_id_env_var] = revision_id + else: + # Clear possible previously existing publish states from an unfinished publish + # (Finished publishes should clear this value) + if revision_id_env_var in os.environ: + os.environ.pop(revision_id_env_var) + + # NOTE: the context should be either a Task or a Project + entity_ctx = engine.tank.context_from_entity(entity_type, entity_id) + + # Get Publisher app from engine + publisher_app = engine.apps.get("tk-multi-publish2") + if not publisher_app: + # Publisher not configured + available_apps = list(engine.apps.keys()) + raise TankError( + "Could not find Publisher app (tk-multi-publish2)!\n\n" + f"Available apps in current engine: {available_apps}\n\n" + "Please ensure tk-multi-publish2 is configured in:\n" + "env/includes/desktop/project.yml under 'desktop.project: apps:'\n\n" + ) + + try: + # Set context and launch Publisher using the pre-imported module + publisher_app._set_context(entity_ctx) + + # For republish action, restrict publisher to single file mode + single_file_mode = action_name == "publish" + publisher_app.show_publish_dialog(single_file_mode=single_file_mode) + except Exception as e: + raise TankError( + f"Failed to launch Publisher: {e}\n\n" + f"The Publisher app was found but failed to start." + ) diff --git a/hooks/tk-houdini_actions.py b/hooks/tk-houdini_actions.py index 1b7f37c..7fd0364 100644 --- a/hooks/tk-houdini_actions.py +++ b/hooks/tk-houdini_actions.py @@ -14,6 +14,7 @@ import os import re + import sgtk HookBaseClass = sgtk.get_hook_baseclass() @@ -24,7 +25,7 @@ class HoudiniActions(HookBaseClass): ############################################################################################################## # public interface - to be overridden by deriving classes - def generate_actions(self, sg_publish_data, actions, ui_area): + def generate_actions(self, sg_publish_data, actions, ui_area, am_base_obj=None): """ Returns a list of action instances for a particular publish. This method is called each time a user clicks a publish somewhere in the UI. @@ -98,9 +99,98 @@ def generate_actions(self, sg_publish_data, actions, ui_area): } ) + # ----------------------- + # FlowAM specific actions + # ----------------------- + if "open" in actions and sg_publish_data.get("type") == "PublishedFile": + if ( + am_base_obj._is_local_draft(sg_publish_data) + or sg_publish_data.get( + "version_number", am_base_obj.DRAFT_VERSION_IDENTIFIER + ) + > am_base_obj.DRAFT_VERSION_IDENTIFIER + ): + action_instances.append( + { + "name": "open", + "params": None, + "caption": "Open", + "description": "This will open the item into the current scene.", + "multi_select": False, + } + ) + + if "download" in actions and sg_publish_data.get("type") == "PublishedFile": + version_number = sg_publish_data.get("version_number") + + if ( + version_number is not None + and version_number != am_base_obj.DRAFT_VERSION_IDENTIFIER + ): + action_instances.append( + { + "name": "download", + "params": "Download 'params'", + "caption": "Download", + "description": "Downloads the published file to a user specified location.", + } + ) + + if "discard_draft" in actions: + draft_id = sg_publish_data.get("sg_flow_revision_id") + + if am_base_obj._is_local_draft( + sg_publish_data + ) and am_base_obj._is_new_asset(draft_id): + action_instances.append( + { + "name": "discard_draft", + "params": None, + "caption": "Discard Draft", + "description": "This will discard the local draft for this publish.", + } + ) + + if ( + "reference_copy_link" in actions + and sg_publish_data.get( + "version_number", am_base_obj.DRAFT_VERSION_IDENTIFIER + ) + != am_base_obj.DRAFT_VERSION_IDENTIFIER + ): + action_instances.append( + { + "name": "reference_copy_link", + "params": None, + "caption": "Copy Reference Link", + "description": "This will copy the reference as a string to the clipboard", + "multi_select": False, + } + ) + + if "build_new_scene" in actions: + action_instances.append( + { + "name": "build_new_scene", + "params": None, + "caption": "Build New Scene", + "description": "This will create a new scene in the current project.", + } + ) + + if "build_new_template" in actions: + action_instances.append( + { + "name": "build_new_template", + "params": None, + "caption": "Build New Template", + "description": "This will create a new template scene in the current project.", + } + ) + return action_instances - def execute_multiple_actions(self, actions): + def execute_multiple_actions(self, actions, am_base_obj=None): """ Executes the specified action on a list of items. @@ -129,9 +219,9 @@ def execute_multiple_actions(self, actions): name = single_action["name"] sg_publish_data = single_action["sg_publish_data"] params = single_action["params"] - self.execute_action(name, params, sg_publish_data) + self.execute_action(name, params, sg_publish_data, am_base_obj) - def execute_action(self, name, params, sg_publish_data): + def execute_action(self, name, params, sg_publish_data, am_base_obj=None): """ Execute a given action. The data sent to this be method will represent one of the actions enumerated by the generate_actions method. @@ -147,6 +237,31 @@ def execute_action(self, name, params, sg_publish_data): "Parameters: %s. Publish Data: %s" % (name, params, sg_publish_data) ) + # ----------------------- + # FlowAM specific actions + # ----------------------- + use_medm_data = app.get_setting("use_medm_data", False) + if use_medm_data: + if name == "open": + am_base_obj._do_open(sg_publish_data) + + if name == "reference_copy_link": + am_base_obj._create_reference_copy_link(sg_publish_data) + + if name == "discard_draft": + am_base_obj._discard_draft(sg_publish_data) + + if name == "build_new_scene": + am_base_obj._build_new_scene(sg_publish_data) + + if name == "build_new_template": + am_base_obj._build_new_template(sg_publish_data) + + if name == "download": + am_base_obj._download_asset_revision(sg_publish_data) + + return + # resolve path path = self.get_publish_path(sg_publish_data) diff --git a/hooks/tk-maya_actions.py b/hooks/tk-maya_actions.py index c3275dd..f54ffea 100644 --- a/hooks/tk-maya_actions.py +++ b/hooks/tk-maya_actions.py @@ -15,6 +15,7 @@ import glob import os import re + import maya.cmds as cmds import maya.mel as mel import sgtk @@ -27,7 +28,7 @@ class MayaActions(HookBaseClass): ############################################################################################################## # public interface - to be overridden by deriving classes - def generate_actions(self, sg_publish_data, actions, ui_area): + def generate_actions(self, sg_publish_data, actions, ui_area, am_base_obj=None): """ Returns a list of action instances for a particular publish. This method is called each time a user clicks a publish somewhere in the UI. @@ -125,9 +126,119 @@ def generate_actions(self, sg_publish_data, actions, ui_area): } ) + # ----------------------- + # FlowAM specific actions + # ----------------------- + if ( + "open" in actions + and sg_publish_data.get("type") == "PublishedFile" + and ( + am_base_obj._is_local_draft(sg_publish_data) + or sg_publish_data.get( + "version_number", am_base_obj.DRAFT_VERSION_IDENTIFIER + ) + > am_base_obj.DRAFT_VERSION_IDENTIFIER + ) + ): + action_instances.append( + { + "name": "open", + "params": None, + "caption": "Open", + "description": "This will open the item into the current scene.", + "multi_select": False, + } + ) + + if ( + "download" in actions + and sg_publish_data.get("type") == "PublishedFile" + and ( + sg_publish_data.get("version_number") is not None + and sg_publish_data.get("version_number") + != am_base_obj.DRAFT_VERSION_IDENTIFIER + ) + ): + action_instances.append( + { + "name": "download", + "params": "Download 'params'", + "caption": "Download", + "description": "Downloads the published file to a user specified location.", + } + ) + + if "discard_draft" in actions: + draft_id = sg_publish_data.get("sg_flow_revision_id") + + if am_base_obj._is_local_draft( + sg_publish_data + ) and am_base_obj._is_new_asset(draft_id): + action_instances.append( + { + "name": "discard_draft", + "params": None, + "caption": "Discard Draft", + "description": "This will discard the local draft for this publish.", + } + ) + + if ( + "reference_am" in actions + and sg_publish_data.get( + "version_number", am_base_obj.DRAFT_VERSION_IDENTIFIER + ) + != am_base_obj.DRAFT_VERSION_IDENTIFIER + ): + action_instances.append( + { + "name": "reference_am", + "params": None, + "caption": "Reference", + "description": "This will load the item into the current scene as a reference", + } + ) + + if ( + "reference_copy_link" in actions + and sg_publish_data.get( + "version_number", am_base_obj.DRAFT_VERSION_IDENTIFIER + ) + != am_base_obj.DRAFT_VERSION_IDENTIFIER + ): + action_instances.append( + { + "name": "reference_copy_link", + "params": None, + "caption": "Copy Reference Link", + "description": "This will copy the reference as a string to the clipboard", + "multi_select": False, + } + ) + + if "build_new_scene" in actions: + action_instances.append( + { + "name": "build_new_scene", + "params": None, + "caption": "Build New Scene", + "description": "This will create a new scene in the current project.", + } + ) + + if "build_new_template" in actions: + action_instances.append( + { + "name": "build_new_template", + "params": None, + "caption": "Build New Template", + "description": "This will create a new template scene in the current project.", + } + ) + return action_instances - def execute_multiple_actions(self, actions): + def execute_multiple_actions(self, actions, am_base_obj=None): """ Executes the specified action on a list of items. @@ -156,9 +267,9 @@ def execute_multiple_actions(self, actions): name = single_action["name"] sg_publish_data = single_action["sg_publish_data"] params = single_action["params"] - self.execute_action(name, params, sg_publish_data) + self.execute_action(name, params, sg_publish_data, am_base_obj) - def execute_action(self, name, params, sg_publish_data): + def execute_action(self, name, params, sg_publish_data, am_base_obj=None): """ Execute a given action. The data sent to this be method will represent one of the actions enumerated by the generate_actions method. @@ -174,6 +285,34 @@ def execute_action(self, name, params, sg_publish_data): "Parameters: %s. Publish Data: %s" % (name, params, sg_publish_data) ) + # ----------------------- + # FlowAM specific actions + # ----------------------- + use_medm_data = app.get_setting("use_medm_data", False) + if use_medm_data: + if name == "reference_am": + am_base_obj._create_reference_am(sg_publish_data) + + if name == "reference_copy_link": + am_base_obj._create_reference_copy_link(sg_publish_data) + + if name == "open": + am_base_obj._do_open(sg_publish_data) + + if name == "discard_draft": + am_base_obj._discard_draft(sg_publish_data) + + if name == "build_new_scene": + am_base_obj._build_new_scene(sg_publish_data) + + if name == "build_new_template": + am_base_obj._build_new_template(sg_publish_data) + + if name == "download": + am_base_obj._download_asset_revision(sg_publish_data) + + return + path = self.get_publish_path(sg_publish_data) if name == "reference": diff --git a/hooks/tk-nuke_actions.py b/hooks/tk-nuke_actions.py index 06d5128..36c7cf2 100644 --- a/hooks/tk-nuke_actions.py +++ b/hooks/tk-nuke_actions.py @@ -12,9 +12,9 @@ Hook that loads defines all the available actions, broken down by publish type. """ +import glob import os import re -import glob import sys import sgtk @@ -27,7 +27,7 @@ class NukeActions(HookBaseClass): ############################################################################################################## # public interface - to be overridden by deriving classes - def generate_actions(self, sg_publish_data, actions, ui_area): + def generate_actions(self, sg_publish_data, actions, ui_area, am_base_obj=None): """ Returns a list of action instances for a particular publish. This method is called each time a user clicks a publish somewhere in the UI. @@ -112,9 +112,91 @@ def generate_actions(self, sg_publish_data, actions, ui_area): } ) + # ----------------------- + # FlowAM specific actions + # ----------------------- + if "build_new_script" in actions: + action_instances.append( + { + "name": "build_new_script", + "params": None, + "caption": "Build New Script", + "description": "This will create a new script in the current project.", + } + ) + if "build_new_template" in actions: + action_instances.append( + { + "name": "build_new_template", + "params": None, + "caption": "Build New Template", + "description": "This will create a new template script in the current project.", + } + ) + if "open" in actions and sg_publish_data.get("type") == "PublishedFile": + # Show open action for: + # 1. Local drafts (version_number == -1 and is_local_draft) + # 2. Published revisions (version_number > -1) + if ( + am_base_obj._is_local_draft(sg_publish_data) + or sg_publish_data.get( + "version_number", am_base_obj.DRAFT_VERSION_IDENTIFIER + ) + > am_base_obj.DRAFT_VERSION_IDENTIFIER + ): + action_instances.append( + { + "name": "open", + "params": None, + "caption": "Open", + "description": "This will open the item into the current script.", + } + ) + + if "discard_draft" in actions and am_base_obj._is_local_draft(sg_publish_data): + action_instances.append( + { + "name": "discard_draft", + "params": None, + "caption": "Discard Draft", + "description": "This will discard the local draft.", + } + ) + if ( + "reference_copy_link" in actions + and sg_publish_data.get( + "version_number", am_base_obj.DRAFT_VERSION_IDENTIFIER + ) + != am_base_obj.DRAFT_VERSION_IDENTIFIER + ): + action_instances.append( + { + "name": "reference_copy_link", + "params": None, + "caption": "Copy Reference Link", + "description": "This will copy the reference link as a string to the clipboard.", + "multi_select": False, + } + ) + if ( + "create_read_node" in actions + and sg_publish_data.get( + "version_number", am_base_obj.DRAFT_VERSION_IDENTIFIER + ) + != am_base_obj.DRAFT_VERSION_IDENTIFIER + ): + action_instances.append( + { + "name": "create_read_node", + "params": None, + "caption": "Create Read Node", + "description": "This will load the item into the current script as a new Read node.", + } + ) + return action_instances - def execute_multiple_actions(self, actions): + def execute_multiple_actions(self, actions, am_base_obj=None): """ Executes the specified action on a list of items. @@ -143,9 +225,9 @@ def execute_multiple_actions(self, actions): name = single_action["name"] sg_publish_data = single_action["sg_publish_data"] params = single_action["params"] - self.execute_action(name, params, sg_publish_data) + self.execute_action(name, params, sg_publish_data, am_base_obj) - def execute_action(self, name, params, sg_publish_data): + def execute_action(self, name, params, sg_publish_data, am_base_obj=None): """ Execute a given action. The data sent to this be method will represent one of the actions enumerated by the generate_actions method. @@ -162,6 +244,31 @@ def execute_action(self, name, params, sg_publish_data): "Parameters: %s. Publish Data: %s" % (name, params, sg_publish_data) ) + # ----------------------- + # FlowAM specific actions + # ----------------------- + use_medm_data = app.get_setting("use_medm_data", False) + if use_medm_data: + if name == "build_new_script": + am_base_obj._build_new_scene(sg_publish_data) + + if name == "build_new_template": + am_base_obj._build_new_template(sg_publish_data) + + if name == "open": + am_base_obj._do_open(sg_publish_data) + + if name == "discard_draft": + am_base_obj._discard_draft(sg_publish_data) + + if name == "reference_copy_link": + am_base_obj._create_reference_copy_link(sg_publish_data) + + if name == "create_read_node": + am_base_obj._create_reference(sg_publish_data) + + return + # resolve path - forward slashes on all platforms in Nuke path = self.get_publish_path(sg_publish_data).replace(os.path.sep, "/") @@ -197,11 +304,7 @@ def _import_clip(self, path, sg_publish_data): ) import hiero - from hiero.core import ( - BinItem, - MediaSource, - Clip, - ) + from hiero.core import BinItem, Clip, MediaSource if not hiero.core.projects(): raise Exception("An active project must exist to import clips into.") diff --git a/python/tk_multi_loader/api/manager.py b/python/tk_multi_loader/api/manager.py index b2fe809..90e9b88 100644 --- a/python/tk_multi_loader/api/manager.py +++ b/python/tk_multi_loader/api/manager.py @@ -128,6 +128,7 @@ def get_actions_for_publish(self, sg_data, ui_area): sg_publish_data=sg_data, actions=actions, ui_area=ui_area_str, + am_base_obj=self.get_am_base_obj(), ) except Exception: self._logger.exception("Could not execute generate_actions hook.") @@ -241,6 +242,7 @@ def execute_action(self, sg_data, action): name=action["name"], params=action["params"], sg_publish_data=sg_data, + am_base_obj=self.get_am_base_obj(), ) except Exception as e: self._logger.exception( @@ -261,7 +263,10 @@ def execute_multiple_actions(self, actions): try: self._bundle.execute_hook_method( - "actions_hook", "execute_multiple_actions", actions=actions + "actions_hook", + "execute_multiple_actions", + actions=actions, + am_base_obj=self.get_am_base_obj(), ) except Exception as e: self._logger.exception( @@ -303,6 +308,7 @@ def get_actions_for_entity(self, sg_data): sg_publish_data=sg_data, actions=actions, ui_area="main", + am_base_obj=self.get_am_base_obj(), ) # folder options only found in main ui area except Exception: self._logger.exception("Could not execute generate_actions hook.") @@ -342,3 +348,10 @@ def _fix_timestamp(sg_data): unix_timestamp, shotgun_api3.sg_timezone.LocalTimezone() ) sg_data["created_at"] = sg_timestamp + + def get_am_base_obj(self) -> "FlowAMActions | None": + """ """ + if sgtk.platform.current_bundle().get_setting("use_medm_data", False): + from ..medm import FlowAMActions + + return FlowAMActions() diff --git a/python/tk_multi_loader/dialog.py b/python/tk_multi_loader/dialog.py index 4fc0581..8c62062 100644 --- a/python/tk_multi_loader/dialog.py +++ b/python/tk_multi_loader/dialog.py @@ -13,14 +13,19 @@ import os from functools import partial +from typing import Any import sgtk from sgtk import TankError from sgtk.platform.qt import QtCore, QtGui -from .model_hierarchy import SgHierarchyModel -from .model_entity import SgEntityModel -from .model_latestpublish import SgLatestPublishModel +from . import constants, model_item_data +from .banner import Banner +from .delegate_publish_history import SgPublishHistoryDelegate +from .delegate_publish_list import SgPublishListDelegate +from .delegate_publish_thumb import SgPublishThumbDelegate +from .framework_qtwidgets import ShotgunFilterMenu +from .loader_action_manager import LoaderActionManager from .medm import ( MedmEntityModel, MedmLatestPublishModel, @@ -28,24 +33,17 @@ MedmSharedCache, MedmThumbnailService, ) +from .model_entity import SgEntityModel +from .model_hierarchy import SgHierarchyModel +from .model_latestpublish import SgLatestPublishModel +from .model_publishhistory import SgPublishHistoryModel from .model_publishtype import SgPublishTypeModel from .model_status import SgStatusModel -from .proxymodel_latestpublish import SgLatestPublishProxyModel from .proxymodel_entity import SgEntityProxyModel -from .delegate_publish_thumb import SgPublishThumbDelegate -from .delegate_publish_list import SgPublishListDelegate -from .model_publishhistory import SgPublishHistoryModel -from .delegate_publish_history import SgPublishHistoryDelegate +from .proxymodel_latestpublish import SgLatestPublishProxyModel from .search_widget import SearchWidget -from .banner import Banner -from .loader_action_manager import LoaderActionManager -from .utils import resolve_filters, get_field_display_name, get_human_readable_value -from .framework_qtwidgets import ShotgunFilterMenu - -from . import constants -from . import model_item_data - from .ui.dialog import Ui_Dialog +from .utils import get_field_display_name, get_human_readable_value, resolve_filters # import frameworks shotgun_model = sgtk.platform.import_framework( @@ -1995,6 +1993,7 @@ def on_action_click(act): name=act["name"], params=act["params"], sg_publish_data=sg_data, + am_base_obj=self._action_manager.get_am_base_obj(), ) action = QtGui.QAction(entity_action["caption"], view) diff --git a/python/tk_multi_loader/loader_action_manager.py b/python/tk_multi_loader/loader_action_manager.py index 8ddb00a..03b2d5e 100644 --- a/python/tk_multi_loader/loader_action_manager.py +++ b/python/tk_multi_loader/loader_action_manager.py @@ -213,6 +213,17 @@ def get_actions_for_folder(self, sg_data): def get_actions_for_entity(self, sg_data): return self._loader_manager.get_actions_for_entity(sg_data) + def get_am_base_obj(self) -> "FlowAMActions | None": + """ + Returns the base object for asset management actions, if available. + + This is used to provide context for actions related to asset management, + such as showing details in Shotgun or Media Center. + + :returns: The base object for asset management actions, or None if not available. + """ + return self._loader_manager.get_am_base_obj() + ######################################################################################## # callbacks diff --git a/python/tk_multi_loader/medm/__init__.py b/python/tk_multi_loader/medm/__init__.py index 4ebbf58..8ea1734 100644 --- a/python/tk_multi_loader/medm/__init__.py +++ b/python/tk_multi_loader/medm/__init__.py @@ -18,12 +18,14 @@ """ from .entity_model import MedmEntityModel +from .flowam_actions import FlowAMActions from .latestpublish_model import MedmLatestPublishModel from .publishhistory_model import MedmPublishHistoryModel from .shared_cache import MedmSharedCache from .thumbnail_service import MedmThumbnailService __all__ = [ + "FlowAMActions", "MedmEntityModel", "MedmLatestPublishModel", "MedmPublishHistoryModel", diff --git a/python/tk_multi_loader/medm/flowam_actions.py b/python/tk_multi_loader/medm/flowam_actions.py new file mode 100644 index 0000000..e928c89 --- /dev/null +++ b/python/tk_multi_loader/medm/flowam_actions.py @@ -0,0 +1,456 @@ +# Copyright (c) 2025 Shotgun Software Inc. +# +# CONFIDENTIAL AND PROPRIETARY +# +# This work is provided "AS IS" and subject to the Shotgun Pipeline Toolkit +# Source Code License included in this distribution package. See LICENSE. +# By accessing, using, copying or modifying this work you indicate your +# agreement to the Shotgun Pipeline Toolkit Source Code License. All rights +# not expressly granted therein are reserved by Shotgun Software Inc. +from __future__ import annotations + +import functools +from types import ModuleType +from typing import Any + +import sgtk +from sgtk import TankError +from sgtk.platform.qt import QtGui + + +class FlowAMActions: + """ + Plugin for loading items published with Flow Asset Management. + + This hook has been tested with Maya and Houdini. + """ + + DRAFT_VERSION_IDENTIFIER = -1 + + def __init__(self): + self._app = sgtk.platform.current_bundle() + + def load_framework( + self, framework_instance_name: str, module_name: str + ) -> ModuleType: + """ + Simple wrapper around the base class implementation to + provide user feedback if the framework cannot be loaded. + + :param framework_instance_name: Name of the framework instance to load + :returns: sgtk.platform.Framework instance + """ + try: + return sgtk.platform.import_framework(framework_instance_name, module_name) + except Exception as e: + message = f"Could not load the required framework '{framework_instance_name}'.\n\nError details: {e}" + self._app.log_error(message) + QtGui.QMessageBox.critical(None, "Error", message) + + def _do_open(self, sg_publish_data: dict) -> None: + """ + Open the given PublishedFile. + + :param sg_publish_data: Shotgun data dictionary with all the standard publish fields. + """ + flow_revision_id = sg_publish_data.get("sg_flow_revision_id") + version_number = sg_publish_data.get("version_number") + + if not flow_revision_id: + item_id = sg_publish_data.get("code") or sg_publish_data.get( + "name", "unknown" + ) + raise TankError("No Revision ID found for this item {}.".format(item_id)) + + flow_module = self.load_framework("tk-framework-flowam", "flow") + + if version_number == self.DRAFT_VERSION_IDENTIFIER and self._is_local_draft( + sg_publish_data + ): + flow_module.asset_management.open_draft(flow_revision_id) + elif version_number > self.DRAFT_VERSION_IDENTIFIER: + # Checkout the revision to the local sandbox + flow_module.asset_management.checkout_revision(flow_revision_id) + else: + raise TankError( + f"Cannot open item {sg_publish_data['name']} with version number {version_number}. " + "This draft is not local to your sandbox." + ) + + def _create_reference_am(self, sg_publish_data: dict) -> None: + """ + Create a reference to the given PublishedFile. + + :param sg_publish_data: Shotgun data dictionary with all the standard publish fields. + """ + flow_revision_id = sg_publish_data.get("sg_flow_revision_id") + + if not flow_revision_id: + item_id = sg_publish_data.get("code") or sg_publish_data.get( + "name", "unknown" + ) + raise TankError("No Revision ID found for this item {}.".format(item_id)) + + flow_module = self.load_framework("tk-framework-flowam", "flow") + flow_module.asset_management.reference_revision(flow_revision_id) + + def _create_reference_copy_link(self, sg_publish_data: dict) -> None: + """ + Copy the link path to the given PublishedFile to the clipboard. + + :param sg_publish_data: Shotgun data dictionary with all the standard publish fields. + """ + flow_revision_id = sg_publish_data.get("sg_flow_revision_id") + + if not flow_revision_id: + item_id = sg_publish_data.get("code") or sg_publish_data.get( + "name", "unknown" + ) + raise TankError("No Revision ID found for this item {}.".format(item_id)) + + flow_module = self.load_framework("tk-framework-flowam", "flow") + path = flow_module.asset_management.copy_reference_link(flow_revision_id) + + self._app.log_info(f"Reference path copied: {path}") + + def _build_new_scene(self, sg_publish_data: dict) -> None: + """ + Open a dialog to build a new scene. If accepted, create a new draft + for the given task using `_on_build_scene_dialog_accepted` callback. + + :param sg_publish_data: Shotgun data dictionary with all the standard publish fields. + """ + flow_ui_module = self.load_framework("tk-framework-flowam", "ui") + parent_window = self._get_dialog_parent() + sg_flow_am_id = self._app.context.project.get("sg_flow_am_id") + # Get the pipeline step from the task + task = sg_publish_data.get("task") + task_id = task.get("id") if task else None + task_pipeline_step = self._get_task_pipeline_step(task_id) if task_id else None + # Open the build scene dialog + build_scene_dialog = flow_ui_module.BuildAssetDialog( + project_id=sg_flow_am_id, + parent=parent_window, + pipeline_step=task_pipeline_step, + ) + build_scene_dialog.accepted.connect( + lambda: self._on_build_scene_dialog_accepted( + build_scene_dialog, sg_publish_data + ) + ) + build_scene_dialog.exec_() + + def _on_build_scene_dialog_accepted( + self, dialog: Any, sg_publish_data: dict + ) -> None: + if not dialog.build: + message = "Not enough data from the build dialog." + self._app.log_warning(message) + return + + flow_module = self.load_framework("tk-framework-flowam", "flow") + parent_window = self._get_dialog_parent() + + sg_flow_am_id = self._get_flowam_id() + + if dialog.build == flow_module.asset_management.CreateMode.TEMPLATE: + template_path = dialog.template_source_path + else: + template_path = "" + + task = ( + self._app.shotgun.find_one( + "Task", + filters=[["id", "is", sg_publish_data["id"]]], + fields=["step", "content"], + ) + or {} + ) + + create_inputs = flow_module.asset_management.CreateInputs( + sg_entity_type=sg_publish_data["entity"]["type"], # Asset, Shot, etc. + sg_entity_name=sg_publish_data["entity"]["name"], + sg_pipeline_step=(task.get("step") or {}).get( + "name", "" + ), # Layout, Animation, etc. + sg_task_name=sg_publish_data["content"], + am_project_id=sg_flow_am_id, + create_mode=dialog.build, + source_path=template_path, + prep_scene_callback=functools.partial(self._prep_scene, sg_publish_data), + ) + + try: + draft_info = flow_module.asset_management.create_dcc_workfile(create_inputs) + self._app.log_debug( + f"Created a DCC workfile on Flow AM framework with the draft_id: {draft_info.draft_id}" + ) + except flow_module.CreateAssetError as exc: + self._app.log_error(f"Create asset failed: {exc}\nInput data: {exc.data}") + + QtGui.QMessageBox.critical( + parent_window, + "Error", + str(exc), + ) + return + + def _prep_scene(self, sg_publish_data: dict) -> None: + """ + Let clients run set-up scripts when building a new scene/asset. + + :param sg_publish_data: Shotgun data dictionary with all the standard publish fields. + """ + + self._app.log_info( + f"prep_scene() called with sg_publish_data: {sg_publish_data}" + ) + # TDs can override this method to add custom scene prep logic + pass + + def _is_local_draft(self, sg_publish_data: dict) -> bool: + """ + Check if the given PublishedFile is a local AM draft. + + :param sg_publish_data: FPTR data dictionary with all the standard entity fields. + :returns: True if it's a local draft, False otherwise. + """ + flow_module = self.load_framework("tk-framework-flowam", "flow") + + return flow_module.sandbox.is_local_draft( + sg_publish_data.get("sg_flow_revision_id") + ) + + def _is_new_asset(self, draft_id: str | None) -> bool: + """ + Check if the given draft ID corresponds to a new asset draft. + + :param draft_id: The draft ID to check. + :returns: True if it's a new asset draft, False otherwise. + """ + flow_module = self.load_framework("tk-framework-flowam", "flow") + + return flow_module.sandbox.is_new_asset(draft_id) + + def _discard_draft(self, sg_publish_data: dict) -> None: + """ + Discard the local draft for the given PublishedFile. + + :param sg_publish_data: FPTR data dictionary with all the standard entity fields. + """ + flow_module = self.load_framework("tk-framework-flowam", "flow") + parent_window = self._get_dialog_parent() + + draft_folder = flow_module.sandbox.get_draft_folder( + sg_publish_data.get("sg_flow_revision_id") + ) + + if flow_module.sandbox.is_new_asset(sg_publish_data.get("sg_flow_revision_id")): + # Case 1: new asset + message = ( + f"Discard the new unpublished asset {sg_publish_data.get('name')}?" + "\n\nThis operation will remove the contents of the following directory and cannot be undone:" + f"\n{draft_folder}" + ) + else: + # Case 2: draft of existing asset + draft_info = flow_module.sandbox.read_draft_info( + sg_publish_data.get("sg_flow_revision_id") + ) + version = draft_info.version + message = ( + f"Discard the draft of asset {sg_publish_data.get('name')} checked out from version {version}?" + "\n\nThis operation will remove the contents of the following directory and cannot be undone:" + f"\n{draft_folder}" + ) + + message_response = QtGui.QMessageBox.question( + parent_window, + "Discard work in progress", + message, + buttons=QtGui.QMessageBox.StandardButtons( + QtGui.QMessageBox.StandardButton.Yes + | QtGui.QMessageBox.StandardButton.Cancel + ), + ) + + if message_response == QtGui.QMessageBox.StandardButton.Yes: + flow_module.asset_management.discard_draft( + sg_publish_data.get("sg_flow_revision_id") + ) + + QtGui.QMessageBox.information( + parent_window, + "Info", + "The local draft has been discarded successfully.", + ) + + def _get_pipeline_steps(self) -> list[str]: + current_engine = sgtk.platform.current_engine() + sg = current_engine.shotgun + pipeline_steps = sg.find( + # Bring all steps related to Assets and Shots. + # Asset and Shot entity types are hardcoded for now. + "Step", + [ + ["entity_type", "in", ["Asset", "Shot"]], + ], + ["code"], + [{"field_name": "code"}], + ) + return list(set([step["code"] for step in pipeline_steps])) + + def _get_task_pipeline_step(self, task_id: int) -> str | None: + """ + Get the pipeline step name for the given task. + + :param task_id: The task ID. + :returns: The pipeline step name or None if not found. + """ + current_engine = sgtk.platform.current_engine() + sg = current_engine.shotgun + task = sg.find_one( + "Task", + [["id", "is", task_id]], + ["step", "content"], + ) + if task and task.get("step"): + return task["step"]["name"] + + self._app.log_warning(f"Pipeline step not found for task ID: {task_id}") + return None + + def _build_new_template(self, sg_publish_data: dict) -> None: + """ + Open a dialog to build a new template scene. If accepted, create a new draft + for the given project using `_on_build_template_dialog_accepted` callback. + + :param sg_publish_data: Shotgun data dictionary with all the standard publish fields. + """ + flow_am_fw = self.load_framework("tk-framework-flowam") + flow_ui_module = flow_am_fw.import_module("ui") + + # Get the sg_flow_am_id from the Project + sg_flow_am_id = self._get_flowam_id() + + parent_window = self._get_dialog_parent() + + build_template_dialog = flow_ui_module.BuildTemplateDialog( + sg_flow_am_id, self._get_pipeline_steps(), parent_window + ) + build_template_dialog.accepted.connect( + lambda: self._on_build_template_dialog_accepted( + build_template_dialog, sg_publish_data + ) + ) + build_template_dialog.exec_() + + def _on_build_template_dialog_accepted( + self, dialog: Any, sg_publish_data: dict + ) -> None: + if not dialog.mode: + message = "Not enough data from the build dialog." + self._app.log_warning(message) + return + + # Validate pipeline step is not empty + parent_window = self._get_dialog_parent() + if not dialog.step or not dialog.step.strip(): + message = "Pipeline step is required for template creation." + self._app.log_error(message) + QtGui.QMessageBox.critical(parent_window, "Error", message) + return + + flow_module = self.load_framework("tk-framework-flowam", "flow") + + sg_flow_am_id = self._get_flowam_id() + + create_inputs = flow_module.asset_management.CreateTemplateInputs( + sg_pipeline_step=dialog.step, + am_project_id=sg_flow_am_id, + template_name=dialog.template, + create_mode=dialog.mode, + ) + draft_info = flow_module.asset_management.create_template_workfile( + create_inputs + ) + self._app.log_debug( + f"Created a Template workfile on Flow AM framework with the draft_id: {draft_info.draft_id}" + ) + + def _get_flowam_id(self) -> str: + """ + Retrieve the Flow Asset Management project ID from the current context. + + :returns: The Flow AM project ID or None if not found. + """ + parent_window = self._get_dialog_parent() + sg_flow_am_id = self._app.context.project.get("sg_flow_am_id") + if not sg_flow_am_id: + project = self._app.shotgun.find_one( + "Project", + [["id", "is", self._app.context.project["id"]]], + ["sg_flow_am_id"], + ) + sg_flow_am_id = project.get("sg_flow_am_id") + + if not sg_flow_am_id: + err_details = { + "Context project": self._app.context.project, + "Project ID": ( + self._app.context.project.get("id") + if self._app.context.project + else "None" + ), + "sg_flow_am_id value": sg_flow_am_id, + } + details_str = "\n".join([f" {k}: {v}" for k, v in err_details.items()]) + message = ( + "The current project does not have an associated Asset Management project. " + "Please contact your FPT administrator.\n\n" + f"Details:\n{details_str}" + ) + self._app.log_error(message) + QtGui.QMessageBox.critical( + parent_window, + "Error", + message, + ) + raise TankError(message) + + return sg_flow_am_id + + def _get_dialog_parent(self) -> QtGui.QWidget | None: + """ + Get the parent widget for dialogs. + + :returns: The parent widget. + """ + engine = sgtk.platform.current_engine() + return engine._get_dialog_parent() + + def _download_asset_revision(self, sg_publish_data: dict) -> None: + """ + Download the given PublishedFile revision to the location specified. + + :param sg_publish_data: Shotgun data dictionary with all the standard publish fields. + """ + flow_revision_id = sg_publish_data.get("sg_flow_revision_id") + + if not flow_revision_id: + item_id = sg_publish_data.get("code") or sg_publish_data.get( + "name", "unknown" + ) + raise TankError("No Revision ID found for this item {}.".format(item_id)) + + flow_module = self.load_framework("tk-framework-flowam", "flow") + result = flow_module.asset_management.download_revision(flow_revision_id) + + # Notify the user about the download result + if result: + msg_lines = ["Download complete for the following file(s):", ""] + for i, file_path in result.items(): + # Format: Blob 0: /path/to/file + msg_lines.append(f" • Blob {i}: {file_path}") + msg = "\n".join(msg_lines) + QtGui.QMessageBox.information(None, "Download Result", msg)