Last active
March 12, 2026 06:47
-
-
Save artandmath/22d9bf83128c7a73cca3136506c4d559 to your computer and use it in GitHub Desktop.
Copy/paste utilities for Nuke that preserve and restore node input connections.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| """ | |
| "SideFX Houdini-Style" copy/paste. | |
| https://gist.github.com/artandmath/22d9bf83128c7a73cca3136506c4d559 | |
| Copy/paste tools for Nuke that preserve and restore node input connections. | |
| How it works | |
| ------------ | |
| When copying with input restore, input connection metadata is written to a | |
| temporary ``.nk`` file alongside the standard node data. The system clipboard | |
| receives the normal (unmodified) node text with a single comment prepended: | |
| # copy_with_input_restore: /path/to/temp.nk | |
| Nuke treats lines beginning with ``#`` as comments and ignores them, so the | |
| clipboard is valid for ordinary paste on any installation. | |
| When pasting with input restore, the first clipboard line is inspected. If the | |
| header is present the temp file is loaded into the clipboard, nodes are pasted, | |
| and original input connections are re-established from the embedded metadata. | |
| The clipboard is then restored to its previous state (header + normal .nk text). | |
| If no header is present the clipboard is pasted as-is with no extra processing. | |
| Menu integration overrides ``Ctrl+X`` with the tracking cut, ``Ctrl+C`` with | |
| the tracking copy, and ``Ctrl+V`` with :func:`paste_without_input_restore` | |
| (strips knobs, no reconnection). Additional paste variants are grouped under | |
| ``Edit > Paste Special``: | |
| - ``Paste and Restore Inputs`` (Alt+Shift+V) — paste and reconnect original inputs. | |
| - ``Paste to Selected`` (Ctrl+Shift+V) — pastes the clipboard once per | |
| originally-selected node, connecting the pasted nodes to each target in turn. | |
| - ``Paste to Selected and Restore Inputs`` (Ctrl+Alt+Shift+V) — same as above | |
| but also restores original input connections after each individual paste. | |
| """ | |
| TRACKING_HEADER_PREFIX = "# copy_with_input_restore: " | |
| # Keyboard shortcuts — override any of these before the menu register functions are called. | |
| # HOTKEY_CUT = "Ctrl+X" | |
| # HOTKEY_COPY = "Ctrl+C" | |
| # HOTKEY_PASTE = "Ctrl+V" | |
| # HOTKEY_PASTE_AND_RESTORE_INPUTS = "Alt+Shift+V" | |
| # HOTKEY_PASTE_TO_SELECTED = "Ctrl+Shift+V" | |
| # HOTKEY_PASTE_TO_SELECTED_AND_RESTORE = "Ctrl+Alt+Shift+V" | |
| import re | |
| import tempfile | |
| import nuke | |
| try: | |
| from PySide6 import QtWidgets | |
| except ImportError: | |
| from PySide2 import QtWidgets | |
| def copy_with_input_restore(): | |
| """Copy selected nodes with input-connection metadata written to a temp file. | |
| For each selected node, the indices and names of its connected inputs are | |
| serialised into a hidden ``addUserKnob`` entry injected into the ``.nk`` text | |
| and saved to a temporary file. The system clipboard receives the normal | |
| (unmodified) node text prefixed with a single comment line: | |
| # copy_with_input_restore: /path/to/temp.nk | |
| Nuke ignores comment lines, so the clipboard remains valid for ordinary | |
| paste on installations that do not have this toolset. A subsequent call to | |
| :func:`paste_and_restore_inputs` reads the header, loads the temp file, and | |
| reconnects the nodes automatically. | |
| Does nothing when no nodes are selected. | |
| """ | |
| sel = nuke.selectedNodes() | |
| if not sel: | |
| return | |
| input_map = {} | |
| for node in sel: | |
| inputs = [] | |
| for i in range(node.inputs()): | |
| inp = node.input(i) | |
| if inp: | |
| inputs.append(f"{i}:{inp.name()}") | |
| input_map[node.name()] = "\\n".join(inputs) | |
| tmp = tempfile.NamedTemporaryFile(delete=False, suffix=".nk") | |
| path = tmp.name | |
| tmp.close() | |
| nuke.nodeCopy(path) | |
| with open(path, "r") as f: | |
| nk = f.read() | |
| lines = nk.split("\n") | |
| node_name = None | |
| new_lines = [] | |
| for line in lines: | |
| name_match = re.match(r"\s*name\s+(\S+)", line) | |
| if name_match: | |
| node_name = name_match.group(1) | |
| if line.strip() == "}" and node_name in input_map: | |
| inputs_text = input_map[node_name] | |
| new_lines.append(" addUserKnob {20 original_inputs_tracking_tab}") | |
| new_lines.append( | |
| f' addUserKnob {{26 original_inputs_tracking_knob -STARTLINE T "{inputs_text}"}}' | |
| ) | |
| node_name = None | |
| new_lines.append(line) | |
| with open(path, "w") as f: | |
| f.write("\n".join(new_lines)) | |
| nuke.nodeCopy("%clipboard%") | |
| clipboard = QtWidgets.QApplication.clipboard() | |
| normal_nk = clipboard.text() | |
| clipboard.setText(f"{TRACKING_HEADER_PREFIX}{path}\n{normal_nk}") | |
| def cut_with_input_restore(): | |
| """Copy selected nodes with input-connection metadata, then delete them. | |
| Delegates to :func:`copy_with_input_restore` to write the temp file and | |
| prepare the clipboard, then deletes every originally-selected node. | |
| Does nothing when no nodes are selected. | |
| """ | |
| sel = nuke.selectedNodes() | |
| if not sel: | |
| return | |
| copy_with_input_restore() | |
| for node in sel: | |
| nuke.delete(node) | |
| def _strip_tracking_knobs(node): | |
| """Remove input-tracking knobs from *node* if present. | |
| Removes ``original_inputs_tracking_tab`` first (so the tab is gone before | |
| the child knob), then ``original_inputs_tracking_knob``. Safe to call on | |
| nodes that carry neither knob. | |
| """ | |
| if "original_inputs_tracking_knob" in node.knobs(): | |
| node.removeKnob(node["original_inputs_tracking_knob"]) | |
| if "original_inputs_tracking_tab" in node.knobs(): | |
| node.removeKnob(node["original_inputs_tracking_tab"]) | |
| def _prepare_clipboard_for_paste(): | |
| """Check the clipboard for an input-tracking header and swap in the temp file. | |
| If the clipboard's first line matches ``# copy_with_input_restore: <path>``, | |
| the temp file at *path* is loaded into the clipboard so that | |
| ``nuke.nodePaste("%clipboard%")`` will receive nodes with tracking knobs | |
| embedded. Returns ``True`` when the swap was performed, ``False`` when the | |
| clipboard contains no such header and should be pasted as-is. | |
| """ | |
| clipboard = QtWidgets.QApplication.clipboard() | |
| text = clipboard.text() | |
| first_line = text.split("\n", 1)[0] | |
| if not first_line.startswith(TRACKING_HEADER_PREFIX): | |
| return False | |
| path = first_line[len(TRACKING_HEADER_PREFIX):].strip() | |
| try: | |
| with open(path, "r") as f: | |
| nk = f.read() | |
| except OSError: | |
| return False | |
| clipboard.setText(nk) | |
| return True | |
| def _restore_inputs(nodes): | |
| """Restore input connections from tracking metadata for a list of *nodes*. | |
| For each node that carries an ``original_inputs_tracking_knob`` knob, the | |
| encoded connection data is parsed and each input is reconnected to the named | |
| source node (if that node exists in the current script and the input slot is | |
| not already occupied). Tracking knobs are removed after processing. | |
| """ | |
| for node in nodes: | |
| if "original_inputs_tracking_knob" not in node.knobs(): | |
| _strip_tracking_knobs(node) | |
| continue | |
| data = node["original_inputs_tracking_knob"].value().strip() | |
| if not data: | |
| _strip_tracking_knobs(node) | |
| continue | |
| for line in data.split("\n"): | |
| try: | |
| idx_str, input_name = line.split(":", 1) | |
| idx = int(idx_str) | |
| except ValueError: | |
| continue | |
| if node.input(idx): | |
| continue | |
| target = nuke.toNode(input_name) | |
| if target: | |
| node.setInput(idx, target) | |
| _strip_tracking_knobs(node) | |
| def paste_and_restore_inputs(): | |
| """Paste nodes from the clipboard and restore their original input connections. | |
| If the clipboard begins with a ``# copy_with_input_restore:`` header the | |
| associated temp file (which carries the tracking knobs) is loaded into the | |
| clipboard first, then a standard paste is performed and | |
| :func:`_restore_inputs` reconnects inputs and strips the tracking knobs. | |
| The clipboard is restored to its original state (the header line + normal | |
| .nk text) after the operation completes. | |
| If no header is present the clipboard is pasted as-is with no further | |
| processing, matching ordinary Nuke paste behaviour. | |
| Does nothing when the clipboard yields no new nodes. | |
| """ | |
| clipboard = QtWidgets.QApplication.clipboard() | |
| original_clipboard = clipboard.text() | |
| has_tracking = _prepare_clipboard_for_paste() | |
| for n in nuke.allNodes(): | |
| n["selected"].setValue(False) | |
| nuke.nodePaste("%clipboard%") | |
| pasted = nuke.selectedNodes() | |
| if pasted and has_tracking: | |
| _restore_inputs(pasted) | |
| if has_tracking: | |
| clipboard.setText(original_clipboard) | |
| def paste_without_input_restore(): | |
| """Paste nodes from the clipboard and strip input-tracking knobs without reconnecting. | |
| If the clipboard begins with a ``# copy_with_input_restore:`` header the | |
| associated temp file is loaded into the clipboard first so that the tracking | |
| knobs are present and can be cleaned up after the paste. No attempt is made | |
| to restore input connections. The clipboard is restored to its original | |
| state (the header line + normal .nk text) after the operation completes. | |
| If no header is present the clipboard is pasted as-is with no further | |
| processing (there are no tracking knobs to clean up). | |
| Does nothing when the clipboard yields no new nodes. | |
| """ | |
| clipboard = QtWidgets.QApplication.clipboard() | |
| original_clipboard = clipboard.text() | |
| has_tracking = _prepare_clipboard_for_paste() | |
| for n in nuke.allNodes(): | |
| n["selected"].setValue(False) | |
| nuke.nodePaste("%clipboard%") | |
| pasted = nuke.selectedNodes() | |
| if has_tracking: | |
| for node in pasted: | |
| _strip_tracking_knobs(node) | |
| clipboard.setText(original_clipboard) | |
| def paste_to_selected(): | |
| """Paste clipboard nodes once per originally-selected node, then re-select all pasted nodes. | |
| Captures the current selection, deselects everything, then iterates over | |
| each originally-selected node. For each one, that node is selected in | |
| isolation and a paste is performed so that Nuke connects the incoming nodes | |
| to it. All pasted nodes are collected across every iteration. Once the | |
| loop is complete, only the pasted nodes are selected. | |
| If the clipboard begins with a ``# copy_with_input_restore:`` header the | |
| temp file is loaded into the clipboard before the loop so that tracking | |
| knobs are available to clean up. If no header is present, nodes are pasted | |
| as-is with no extra processing. | |
| Does nothing when no nodes are selected. | |
| """ | |
| original_selection = nuke.selectedNodes() | |
| if not original_selection: | |
| return | |
| has_tracking = _prepare_clipboard_for_paste() | |
| for n in nuke.allNodes(): | |
| n["selected"].setValue(False) | |
| all_pasted = [] | |
| for target in original_selection: | |
| target["selected"].setValue(True) | |
| nuke.nodePaste("%clipboard%") | |
| pasted = nuke.selectedNodes() | |
| all_pasted.extend(pasted) | |
| for n in nuke.allNodes(): | |
| n["selected"].setValue(False) | |
| for node in all_pasted: | |
| if has_tracking: | |
| _strip_tracking_knobs(node) | |
| node["selected"].setValue(True) | |
| def paste_to_selected_and_restore_inputs(): | |
| """Paste clipboard nodes once per originally-selected node and restore input connections. | |
| Identical to :func:`paste_to_selected` except that after each individual | |
| paste the input-connection metadata embedded by :func:`copy_with_input_restore` | |
| is read and the connections are re-established before moving on to the next | |
| target node. | |
| If the clipboard begins with a ``# copy_with_input_restore:`` header the | |
| temp file is loaded into the clipboard before the loop and the clipboard is | |
| restored to its original state (the header line + normal .nk text) after | |
| all pastes complete. If no header is present, nodes are pasted as-is and | |
| no input restore is attempted. | |
| Does nothing when no nodes are selected. | |
| """ | |
| original_selection = nuke.selectedNodes() | |
| if not original_selection: | |
| return | |
| clipboard = QtWidgets.QApplication.clipboard() | |
| original_clipboard = clipboard.text() | |
| has_tracking = _prepare_clipboard_for_paste() | |
| for n in nuke.allNodes(): | |
| n["selected"].setValue(False) | |
| all_pasted = [] | |
| for target in original_selection: | |
| target["selected"].setValue(True) | |
| nuke.nodePaste("%clipboard%") | |
| pasted = nuke.selectedNodes() | |
| if has_tracking: | |
| _restore_inputs(pasted) | |
| all_pasted.extend(pasted) | |
| for n in nuke.allNodes(): | |
| n["selected"].setValue(False) | |
| for node in all_pasted: | |
| node["selected"].setValue(True) | |
| if has_tracking: | |
| clipboard.setText(original_clipboard) | |
| # --------------------------------------------------------------------------- | |
| # Menu integration | |
| # --------------------------------------------------------------------------- | |
| def _register_menu(): | |
| """Register menu commands. | |
| Overrides: | |
| - ``Edit > Cut`` (Ctrl+X) — cut with tracking metadata. | |
| - ``Edit > Copy`` (Ctrl+C) — copy with tracking metadata. | |
| - ``Edit > Paste`` (Ctrl+V) — paste; strips tracking knobs but does not | |
| restore connections. | |
| Adds ``Edit > Paste Special`` immediately after Paste, containing: | |
| - ``Paste and Restore Inputs`` (Alt+Shift+V) — paste with full reconnection. | |
| - ``Paste to Selected`` (Ctrl+Shift+V) — paste once per selected node. | |
| - ``Paste to Selected and Restore Inputs`` (Ctrl+Alt+Shift+V) — paste once | |
| per selected node and restore input connections. | |
| """ | |
| menu = nuke.menu("Nuke") | |
| edit_menu = menu.findItem("Edit") | |
| cut_index = None | |
| copy_index = None | |
| paste_index = None | |
| for i, item in enumerate(edit_menu.items()): | |
| name = item.name() | |
| if name == "Cut": | |
| cut_index = i | |
| elif name == "Copy": | |
| copy_index = i | |
| elif name == "Paste": | |
| paste_index = i | |
| if cut_index is not None: | |
| menu.addCommand( | |
| "Edit/Cut", | |
| "cut_with_input_restore()", | |
| globals().get("HOTKEY_CUT", "Ctrl+X"), | |
| ) | |
| if copy_index is not None: | |
| menu.addCommand( | |
| "Edit/Copy", | |
| "copy_with_input_restore()", | |
| globals().get("HOTKEY_COPY", "Ctrl+C"), | |
| ) | |
| if paste_index is not None: | |
| menu.addCommand( | |
| "Edit/Paste", | |
| "paste_without_input_restore()", | |
| globals().get("HOTKEY_PASTE", "Ctrl+V"), | |
| ) | |
| menu.addMenu( | |
| "Edit/Paste Special", | |
| index=paste_index + 1, | |
| ) | |
| menu.addCommand( | |
| "Edit/Paste Special/Paste and Restore Inputs", | |
| "paste_and_restore_inputs()", | |
| globals().get("HOTKEY_PASTE_AND_RESTORE_INPUTS", "Alt+Shift+V"), | |
| ) | |
| menu.addCommand( | |
| "Edit/Paste Special/Paste to Selected", | |
| "paste_to_selected()", | |
| globals().get("HOTKEY_PASTE_TO_SELECTED", "Ctrl+Shift+V"), | |
| ) | |
| menu.addCommand( | |
| "Edit/Paste Special/Paste to Selected and Restore Inputs", | |
| "paste_to_selected_and_restore_inputs()", | |
| globals().get("HOTKEY_PASTE_TO_SELECTED_AND_RESTORE", "Ctrl+Alt+Shift+V"), | |
| ) | |
| _register_menu() |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment