From c4d4f45973e8c1ec245367095546329d519b1554 Mon Sep 17 00:00:00 2001 From: Yungsiow Yang Date: Mon, 22 Jun 2026 13:09:22 -0400 Subject: [PATCH 1/5] Nuke flow host support - added .flake8 config copied from tk-core - added vim lock files to .gitignore - implemented NukeHost subclass of FlowHost NOTE: FlowWrite node will be ported in a separate ticket - instantiate NukeHost during engine set up --- .flake8 | 64 ++++ .gitignore | 7 + engine.py | 6 + python/flowam/__init__.py | 13 + python/flowam/host.py | 654 ++++++++++++++++++++++++++++++++++++++ 5 files changed, 744 insertions(+) create mode 100644 .flake8 create mode 100644 python/flowam/__init__.py create mode 100644 python/flowam/host.py diff --git a/.flake8 b/.flake8 new file mode 100644 index 0000000..9a651ef --- /dev/null +++ b/.flake8 @@ -0,0 +1,64 @@ +# Copyright (c) 2017 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. + +# Flake 8 PEP and lint configuration - https://gitlab.com/pycqa/flake8 +# +# This defines the official lint and PEP8 rules for this repository +# +# You can run this locally by doing pep install flake8 and then +# >flake8 /path/to/core +# +# This is also used by the hound CI - see the .hound.yml config file. +# +# +[flake8] + +# things we don't want to lint +exclude = + .tox, + .git, + .flake8, + .gitignore, + .travis.yml, + .cache, + .eggs, + *.rst, + *.yml, + *.pyc, + *.pyo, + *.egg-info, + __pycache__, + # Those are our third parties, do not lint them! + python/tank_vendor, + # Otherwise you'll have a lot of 'xxx' imported but unused + python/tank/__init__.py, + python/tank/*/__init__.py, + # Skip the auto-generated ui file. + python/tank/authentication/ui/login_dialog.py, + python/tank/authentication/ui/resources_rc.py, + python/tank/platform/qt/ui_busy_dialog.py, + python/tank/platform/qt/ui_item.py, + python/tank/platform/qt/ui_tank_dialog.py, + python/tank/authentication/sso_saml2/*, + tests/python + +# toolkit exceptions +# +# E203 whitespace before ':' (this is not PEP8 compliant) +# E221 multiple spaces before operator +# E261 two whitespaces before end of line comment. +# E402 module level import not top of file +# E501 line too long (112 > 79 characters) +# W503 Line break occurred before a binary operator +# N802 Variables should be lower case. (clashes with Qt naming conventions) +# N806 Variables should be lower case. (clashes with Qt naming conventions) +# E999 SyntaxError: invalid syntax (hack for hound CI which runs python 3.x) + +ignore = E203, E221, E261, E402, E501, W503, N802, N806, E999 diff --git a/.gitignore b/.gitignore index 386e7af..2ba5f8a 100644 --- a/.gitignore +++ b/.gitignore @@ -31,3 +31,10 @@ pip-log.txt # Max OS X Desktop Services Store files .DS_Store + +# IDEs and Editors +*.swo +*.swp +*.swm +*~ + diff --git a/engine.py b/engine.py index 11af8b7..70c6795 100644 --- a/engine.py +++ b/engine.py @@ -406,6 +406,12 @@ def post_app_init(self): else: self.post_app_init_nuke() + # Instantiate FlowHost if current context is configured with Flow + if self.context.flow_project_id: + from flowam.host import NukeHost + self.logger.info("Instantiating Flow host as NukeHost...") + self._flow_host = NukeHost(self.context) + def post_app_init_studio(self): """ The Nuke Studio specific portion of the engine's post-init process. diff --git a/python/flowam/__init__.py b/python/flowam/__init__.py new file mode 100644 index 0000000..ac8ad39 --- /dev/null +++ b/python/flowam/__init__.py @@ -0,0 +1,13 @@ +# Copyright (c) 2015 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. + +# flake8: noqa + +from . import host diff --git a/python/flowam/host.py b/python/flowam/host.py new file mode 100644 index 0000000..82f0973 --- /dev/null +++ b/python/flowam/host.py @@ -0,0 +1,654 @@ +# - +# ***************************************************************************** +# Copyright 2026 Autodesk, Inc. All rights reserved. +# +# These coded instructions, statements, and computer programs contain +# unpublished proprietary information written by Autodesk, Inc. and are +# protected by Federal copyright law. They may not be disclosed to third +# parties or copied or duplicated in any form, in whole or in part, without +# the prior written consent of Autodesk, Inc. +# ***************************************************************************** +# + +import os + +from tank import LogManager +from tank.flowam.host import FlowHost +from tank.flowam.utils import search_file_expression +from tank_vendor.flow_integration_sdk.dependency import DependencyData +from tank_vendor.flow_integration_sdk.utils import ( + cleanpath, + fileext, + trace, +) + +import nuke + + +class NukeHost(FlowHost): + """Nuke implementation of FlowHost interface. + This is a collection of required capabilities to support Flow AM integration. + """ + + logger = LogManager.get_logger("NukeHost") + + #: Desktop application deals with generic workfiles + WORKFILE_TYPE = "type.workfile.nuke" + #: Nuke native file types + FILE_TYPES = ["nk"] + #: Commonly supported read file types + COMMON_READ_FILE_TYPES = [ + "ari", + "arx", + "avi", + "bmp", + "braw", + "cin", + "dng", + "dpx", + "exr", + "gif", + "hdr", + "jpeg", + "jpg", + "mov", + "mp4", + "mxf", + "pic", + "png", + "psd", + "r3d", + "rgb", + "rgba", + "sgi", + "tga", + "tif", + "tiff", + ] + + def __init__(self, context): + + self.logger.info("Doing NukeHost initialization...") + + super().__init__(context) + + # Detect user modifications in scene and explicitly + # set the modified flag on scene + # NOTE: Nuke seems inconsistent with setting this flag + # automatically, so adding this guardrail to protect + # unsaved changes. + nuke.addOnUserCreate(self._set_scene_modified) + nuke.addKnobChanged(self._set_scene_modified) + nuke.addOnScriptLoad(self._on_script_load) + nuke.addOnScriptClose(self._on_script_close) + + @trace + def current_file(self) -> str: + """Return currently opened file in Nuke.""" + return cleanpath(nuke.Root().name()) + + @trace + def new_scene(self, force: bool = False) -> bool: + """Start new scene in Nuke. + + Args: + force: If true, force action even if there are unsaved changes. + + Returns: + True if new scene is opened, False if operation is cancelled. + """ + # Force new scene + if force: + # This option allows you to suppress the unsaved changes dialog + nuke.scriptSaveAndClear(ignoreUnsavedChanges=True) + return True + else: + # This will check for unsaved changes and automatically + # prompt the user with an option to save + # If user chooses to cancel, this will return False + return nuke.scriptClose() + + @trace + def open_file(self, file_path: str) -> bool: + """Open given file path in Nuke. + + Args: + file_path: Full path to file to be opened. + + Returns: + True if file is opened, False on error or if operation is cancelled. + """ + # NOTE: in toolkit, scriptOpen() function seems to be wired to + # launch a new instance of Nuke if there are any unsaved + # changes in current scene. To circumvent this, we will + # clear the scene before opening. + # NOTE: new_scene() will check unsaved changes and allow the user + # to save, not save, or cancel. + if not self.new_scene(): + return False + nuke.scriptOpen(file_path) + # NOTE: the act of opening the file triggers changes which marks + # the scene as modified via our callback. Flag the scene + # as unmodified post open to avoid confusion. + self._set_scene_modified(False) + return True + + @trace + def save_file(self, file_path: str): + """Save the current script to the specified file path. + + Args: + file_path: Absolute local path to save file. + + Raises: + ValueError + """ + ext = fileext(file_path) + if ext not in self.FILE_TYPES: + raise ValueError(f'Invalid native file extension "{ext}" provided.') + + # Save without overwrite prompt + nuke.scriptSaveAs(file_path, overwrite=1) + + @trace + def dialog( + self, + title: str, + msg: str, + buttons: list[str] | None = None, + default: int | None = None, + cancel: int | None = None, + no_ui_option: int | None = None, + input_fields: dict | None = None, + ) -> int: + """Pop up a dialog in the dcc. + + Args: + title: Title of dialog window. + msg: Message to be displayed. + buttons: List of strings denoting buttons to be added to dialog. + NOTE: Maximum of 9 buttons can be supported for this host. + If not provided, a default "OK" button will be created. + default: Index of default button. Default behaviour is to use the second option + if available, otherwise the first. + cancel: Index of cancel button. Default behaviour is to use the first option. + no_ui_option: If Nuke is running without UI, this option will automatically be returned. + If None, use default value. + input_fields: Dictionary of string keys to default string values. + Input text fields will be added to the dialog + with labels matching keys. Upon dialog acceptance, + the values of the fields will be stored in the + same dictionary overwriting the default values + provided. + + NOTE: This is a quick stop-gap solution enabling + user input in a nuke dialog (required for + FlowWrite publish process). A nicer solution + will follow when a custom Nuke dialog is + implemented. + + Returns: + The index of the button selected by user. Value of -1 indicates dismissed dialog. + + Raises: + ValueError + """ + + # Ensure that we have at least one button + # and appropriate default/cancel indices + if not buttons: + buttons = ["OK"] + if default is None: + default = 1 if len(buttons) >= 2 else 0 + if cancel is None: + cancel = 0 + if default >= len(buttons): + raise ValueError("Default index provided is out of range.") + if cancel >= len(buttons): + raise ValueError("Cancel index provided is out of range.") + + # Ensure that button list is unique, otherwise it will make the + # mapping at the end of this function difficult. + if not len(buttons) == len(set(buttons)): + raise ValueError("Button values must be unique.") + + # Return default option when no UI is available + if not nuke.GUI: + return no_ui_option if no_ui_option is not None else default + + # NOTE: nuke automatically wires cancel action to first option + # and default action to second option if it exists. + # We must renumber the options to respect selected default/cancel + # options, but preserve initial order to return accurate result. + nuke_order = [] + default_opt = cancel_opt = None + # First we'll add all the non-special actions in order to a list + # Match the default and cancel options and store them separately + for i, button in enumerate(buttons): + if i in [default, cancel]: + if i == default: + default_opt = button + if i == cancel: + cancel_opt = button + continue + nuke_order.append(button) + # Next we will insert the cancel and default actions to the start + # of the list. + # NOTE: if these happen to be the same action, they will be added + # twice, intentionally! This is necessary to support default and cancel + # option being the same which is commonly desired in many situations where + # "Cancel" is the safest default action. + # TODO: need to figure out how to hide the duplicate button in the UI to + # avoid confusion, but for now, this is the safest correct implementation! + nuke_order.insert(0, cancel_opt) # type: ignore[arg-type] + nuke_order.insert(1, default_opt) # type: ignore[arg-type] + + # Create a nuke panel + panel = nuke.Panel(title) + + # Add message label + # NOTE: this is not read only unfortunately! + # TODO: find a better way to display message + panel.addNotepad("", msg) + + # Add input fields + if input_fields: + for field_name, value in input_fields.items(): + panel.addNotepad(field_name, value) + + # Add the buttons in order + # NOTE: for now, this may result in duplicate buttons if default and cancel + # are the same! Ideally we'd want to hide one of them, but can't do that + # with current nuke.Panel implementation. + cancel_and_default_are_same = nuke_order[0] == nuke_order[1] and len( + nuke_order + ) > len(buttons) + for i, button in enumerate(nuke_order): + panel.addButton(button) + if i == 1 and cancel_and_default_are_same: + # TODO: hide button somehow... + pass + + # Show dialog modally and capture the result + result = panel.show() + # Map back result to original order + value = nuke_order[result] + mapped_result = buttons.index(value) + # Retrieve input fields and store values in original dictionary + if input_fields: + for field_name in input_fields: + value = panel.value(field_name) + input_fields[field_name] = value + return mapped_result + + @trace + def file_dialog( + self, + title: str, + starting_dir: str = "", + folder_mode: bool = False, + file_type: str = "*", + multi_select: bool = False, + ) -> list[str]: + """Invoke a file dialog for selecting one or more file paths. + + Args: + title: Title of dialog. + starting_dir: Starting location of dialog. + folder_mode: If True, dialog will browse folders instead of files. + file_type: Extension of file type to filter for. + Applicable only when browsing files. + multi_select: If True, allow multiple selection of files. + Applicable only when browsing files. + + Returns: + A list of file/directory paths. + If multi_select = False, the return value will be a list of size 1. + If user cancels, list will be empty. + If Nuke is running without a GUI, empty list is returned. + """ + if not nuke.GUI: + return [] + + if starting_dir and not starting_dir.endswith("/"): + # This will ensure user starts in this directory when they browse + starting_dir += "/" + + file_filter = "*/" if folder_mode else f"*.{file_type}" + result = nuke.getFilename( + title, file_filter, starting_dir, multiple=multi_select + ) + if result is None: + return [] # user cancelled + + if not multi_select: + result = [result] + + return [cleanpath(path) for path in result] + + @trace + def copy_to_clipboard(self, text: str) -> bool: + """Copy given text to clipboard. + + Args: + text: Text to be copied. + + Returns: + True on success. + """ + from tank.platform.qt import QtGui as qtg + + qtg.QApplication.instance().clipboard().setText(text) + return True + + def get_dependency_tree(self, must_exist: bool = True) -> DependencyData: + """Return a DependencyData object which is the root of the + dependency tree for the scene. + + Args: + must_exist: Only return dependencies that can be found on disk. + """ + dependencies = self._get_nuke_dependencies(must_exist=must_exist) + dependencies.sort() + root = DependencyData(dependencies=dependencies) + for d in dependencies: + d.parent = root + return root + + @trace + def update_dependency( + self, + dep: DependencyData, + file_path: str, + ) -> DependencyData: + """Update an existing dependency to point to given file in current script. + + Args: + dep: DependencyData node which identifies the dependency to be updated. + file_path: New path to set dependency to. + + Returns: + DependencyData object describing new state of dependency. + NOTE: This will be an isolated node, not including sub-dependency info. + + Raises: + UpdateDependencyError + """ + node_handle = dep.node_handle + attribute = dep.attribute + + node = nuke.toNode(node_handle) + if not node: + msg = "Error updating dependency. " + msg += f"Invalid node handle provided: {node_handle}." + raise RuntimeError(msg) + + knob = node.knob(attribute) + if not knob: + msg = "Error updating dependency. " + msg += f"Invalid knob provided for node [{node_handle}]: {attribute}." + raise RuntimeError(msg) + + # Set path only if it has changed + orig_path = cleanpath(knob.value() or "") + if file_path != orig_path: + knob.setValue(file_path) + + updated_dep = DependencyData( + dep_type=dep.dep_type, + node_handle=dep.node_handle, + node_type=dep.node_type, + attribute=dep.attribute, + file_path=self._resolve_path(file_path), + raw_path=file_path, + ) + + self.logger.info( + f'Dependency node "{node_handle}" updated to point to file "{file_path}".' + ) + return updated_dep + + def env_var_marker(self, var_name: str) -> str: + """Return the environment variable marker format for Nuke. + + Args: + var_name: The environment variable name. + + Returns: + Environment variable marker in TCL format: [getenv VAR_NAME] + """ + return f"[getenv {var_name}]" + + # ------------------------------------------ + # ADDITIONAL SUBCLASS FUNCTIONS + # ------------------------------------------ + + @trace + def create_reference(self, file_path: str, namespace: str) -> DependencyData: + """Create a nuke read node to file. + + Args: + file_path: Path to be read into Nuke. + namespace: Namespace to be added to read node. + + Returns: + DependencyData object with all pertinent info about asset reference created. + + Raises: + ValueError + """ + # NOTE: Nuke does not provide a definitive list of extensions that it + # supports in its read nodes as of version 16.0. + # The best we can do is check the file type against a list of + # commonly supported types to block out any obvious outliers. + ext = fileext(file_path) + if ext not in self.COMMON_READ_FILE_TYPES: + msg = f'File type "{ext}" not supported for reading into Nuke.' + raise ValueError(msg) + + # NOTE: Nuke does not enforce unique names, but to avoid a plethora of + # problems we should always avoid duplicate node names, so we + # must manually manage unique names. + node_name = self._get_unique_name(name=namespace) + + # Create the read node in Nuke + read_node = nuke.nodes.Read(name=node_name, file=file_path) + msg = f'Read node "{node_name}" created pointing to file "{file_path}".' + self.logger.info(msg) + + return DependencyData( + node_handle=node_name, + node_type=read_node.Class(), + file_path=self._resolve_path(file_path), + attribute="file", + raw_path=file_path, + ) + + def _get_nuke_dependencies(self, must_exist: bool = True) -> list[DependencyData]: + """Returns all relevant file dependencies in current Nuke script. + Examples include media reads, and geometry caches. + + Args: + must_exist: Only return dependencies that can be found on disk. + + Returns: + List of DependencyData objects containing all pertinent information + related to a file dependency. + """ + + # NOTE: this initial version (Phase 1) does not deal with gizmos and subdependencies + # of gizmos. If there are gizmos present all subdependencies will simply + # appear as a flat list with this solution. In the next iteration (Phase 2), + # we may consider gizmos as special dependencies that hold sub-dependencies + # and build a tree structure this way. + + # Phase 1: External dependency scan for a Nuke script + # Includes: Read/DeepRead inputs + geometry caches + read-from-file cameras read-from-file + # Excludes: ALL output paths (Write/DeepWrite/WriteGeo*) + # Excludes: fonts, luts, ocio configs, gizmos, templates (by using a whitelist approach) + + # Nuke nodes that read from external files + # We need to filter for these + INPUT_MEDIA_NODES = { + "Read", # regular read node + "DeepRead", # read node for deep image formats (eg. exr) + "ReadOIIO", # read node using OpenImageIO + } + + GEO_CACHE_NODES = { + "ReadGeo2", + "ReadGeo", # older + } + + # Some camera nodes can be set to read from file + CAMERA_NODES = { + "Camera2", + "Camera", + } + + # Output node classes to exclude entirely + # Output nodes do not use dependencies, but create biproducts + OUTPUT_NODES = { + "Write", + "DeepWrite", + "WriteGeo", + "WriteGeo2", + } + + deps = [] + + # Get list of all dependencies in all containers and nested containers + # within Nuke script + for node in nuke.allNodes(recurseGroups=True): + node_class = node.Class() + + # Exclude outputs entirely + if node_class in OUTPUT_NODES: + continue + + # Media reads + if node_class in INPUT_MEDIA_NODES: + dep = self._get_dependency_info(node, "file", must_exist) + if dep: + deps.append(dep) + + dep = self._get_dependency_info(node, "proxy", must_exist) + if dep: + deps.append(dep) + + # Geometry cache reads + elif node_class in GEO_CACHE_NODES: + dep = self._get_dependency_info(node, "file", must_exist) + if dep: + deps.append(dep) + + # Check for cameras that are read from file + elif node_class in CAMERA_NODES: + # Some camera nodes have a "file" knob, and sometimes a "read_from_file" toggle. + if "read_from_file" in node.knobs(): + # NOTE: this knob is a Boolean_Knob which always returns a float + # 0.0 -> off + # 1.0 -> on + if node["read_from_file"].value(): + dep = self._get_dependency_info(node, "file", must_exist) + if dep: + deps.append(dep) + + return deps + + def _get_dependency_info(self, node, attr, must_exist) -> DependencyData | None: + """Return DependencyData node with file path information + of given Nuke node and knob name. If knob or path is not defined, + return None. If must_exist=True, return None if the path does not + exist on disk. + """ + if attr not in node.knobs(): + return None + raw_path = node[attr].value() + if not raw_path: + return None + # Get resolved file path with env vars replaced + # and path normalized + file_path = self._resolve_path(raw_path) + if must_exist: + if "%" in file_path: + # Handle paths with frame padding by searching for matching pattern + file_list = search_file_expression(file_path) + if not file_list: + self.logger.warning( + f"Could not find dependency matching file path: {file_path}" + ) + return None + elif not os.path.isfile(file_path): + self.logger.warning(f"Could not find dependency file: {file_path}") + return None + + # Create a dependency node + # NOTE: no sub-dependency handling for now + dep = DependencyData( + node_handle=node.fullName(), + node_type=node.Class(), + attribute=attr, + file_path=file_path, + raw_path=raw_path, + ) + # Determine whether the dependency is an asset + # or local dependency + dep.identify_component() + dep.set_type() + return dep + + def _tcl_subst(self, s: str) -> str: + """Expand TCL like [value root.name], [getenv ...], etc.""" + try: + return nuke.tcl("subst", s) + except Exception: + return s + + def _resolve_path(self, raw: str) -> str: + """Normalize and expand env vars, ~, and TCL substitutions.""" + raw = (raw or "").strip().strip('"').strip("'") + raw = self._tcl_subst(raw) + raw = os.path.expandvars(raw) + raw = os.path.expanduser(raw) + return cleanpath(raw) + + def _get_unique_name(self, name: str) -> str: + """Provided a name for a new node, determine + whether the node already exists and add an index + to avoid duplication. Nodes are assumed to be top-level. + + e.g. base name = "snow" + + existing nodes: + - snow + - snow2 + + return value = "snow3" + + NOTE: the assumption that the base name does not include an index. + """ + # Check if node exists as is + node = nuke.nodeAtPath(name) + if node is None: + return name + + i = 2 + while node is not None: + unique_name = f"{name}{i}" + node = nuke.nodeAtPath(unique_name) + i += 1 + return unique_name + + def _set_scene_modified(self, state: bool = True): + """Set the modified flag for nuke script explicitly.""" + nuke.root().setModified(state) + + def _on_script_load(self): + """Callback when script is loaded.""" + file_path = self.current_file() + self.context.set_flow_context(file_path) + + def _on_script_close(self): + """Callback when script is closed.""" + self.context.clear_flow_context() From 273d97337bdc4fa0ca8aa089b101252586fe7419 Mon Sep 17 00:00:00 2001 From: Yungsiow Yang Date: Mon, 22 Jun 2026 15:39:46 -0400 Subject: [PATCH 2/5] Add comment --- engine.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/engine.py b/engine.py index 70c6795..2a636c2 100644 --- a/engine.py +++ b/engine.py @@ -408,7 +408,10 @@ def post_app_init(self): # Instantiate FlowHost if current context is configured with Flow if self.context.flow_project_id: + # NOTE: placing this import at the top of the module causes errors + # so it must be localized from flowam.host import NukeHost + self.logger.info("Instantiating Flow host as NukeHost...") self._flow_host = NukeHost(self.context) From 98bc99a3de2dbcbc6da236a8230fc88a3a3b8e61 Mon Sep 17 00:00:00 2001 From: Yungsiow Yang Date: Mon, 22 Jun 2026 16:03:17 -0400 Subject: [PATCH 3/5] Update .gitignore --- .gitignore | 1 - 1 file changed, 1 deletion(-) diff --git a/.gitignore b/.gitignore index 2ba5f8a..fa25921 100644 --- a/.gitignore +++ b/.gitignore @@ -37,4 +37,3 @@ pip-log.txt *.swp *.swm *~ - From 6d89dd9a51734c27c5853ac4d04638668d85f3df Mon Sep 17 00:00:00 2001 From: Yungsiow Yang Date: Mon, 22 Jun 2026 18:14:02 -0400 Subject: [PATCH 4/5] Remove import alias --- python/flowam/host.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/python/flowam/host.py b/python/flowam/host.py index 82f0973..1414f81 100644 --- a/python/flowam/host.py +++ b/python/flowam/host.py @@ -336,9 +336,9 @@ def copy_to_clipboard(self, text: str) -> bool: Returns: True on success. """ - from tank.platform.qt import QtGui as qtg + from tank.platform.qt import QtGui - qtg.QApplication.instance().clipboard().setText(text) + QtGui.QApplication.instance().clipboard().setText(text) return True def get_dependency_tree(self, must_exist: bool = True) -> DependencyData: From 09b307c894636715cc258c3c7f8bd1b3d7d08bc0 Mon Sep 17 00:00:00 2001 From: Yungsiow Yang Date: Tue, 23 Jun 2026 14:38:33 -0400 Subject: [PATCH 5/5] Addressed PR comments. --- python/flowam/host.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/python/flowam/host.py b/python/flowam/host.py index 1414f81..174fc49 100644 --- a/python/flowam/host.py +++ b/python/flowam/host.py @@ -32,7 +32,7 @@ class NukeHost(FlowHost): logger = LogManager.get_logger("NukeHost") - #: Desktop application deals with generic workfiles + #: The schema name associated with Nuke workfiles WORKFILE_TYPE = "type.workfile.nuke" #: Nuke native file types FILE_TYPES = ["nk"] @@ -338,8 +338,11 @@ def copy_to_clipboard(self, text: str) -> bool: """ from tank.platform.qt import QtGui - QtGui.QApplication.instance().clipboard().setText(text) - return True + app = QtGui.QApplication.instance() + if app: + app.clipboard().setText(text) + return True + return False def get_dependency_tree(self, must_exist: bool = True) -> DependencyData: """Return a DependencyData object which is the root of the