├── screenshot.png ├── dot_nuke └── init.py ├── menu.py ├── README.md └── dag_capture.py /screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/herronelou/nuke_dag_capture/HEAD/screenshot.png -------------------------------------------------------------------------------- /dot_nuke/init.py: -------------------------------------------------------------------------------- 1 | import os.path 2 | 3 | import nuke 4 | 5 | if (nuke.env["NukeVersionMajor"]) >= 13: # Nuke 12 is python 2, not 3 6 | userfolder = os.path.expanduser("~") 7 | path = os.path.join(userfolder, "Documents/nuke_screenshot") 8 | nuke.pluginAddPath(path) 9 | -------------------------------------------------------------------------------- /menu.py: -------------------------------------------------------------------------------- 1 | import nuke 2 | 3 | import dag_capture 4 | 5 | 6 | def create_nuke_menu(): 7 | nuke_menu = nuke.menu("Nuke") 8 | nuke_menu.addCommand( 9 | name="Screenshot", 10 | command=dag_capture.open_dag_capture, 11 | tooltip="Open DAG screenshot menu", 12 | ) 13 | 14 | 15 | if __name__ == '__main__': 16 | create_nuke_menu() 17 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Nuke DAG Capture 2 | 3 | This is a python script that allows to capture a PNG image of the Nuke node graph. 4 | The first version was written as an answer to a [Stack Overflow Question](https://stackoverflow.com/questions/64674724/extract-a-vector-image-or-high-res-image-from-nukes-node-graph), but the code has since evolved a bit to include more options and a UI panel. 5 | 6 | # Compatibility 7 | 8 | The current version is compatible with Nuke 13 and above (python 3). For code compatible with older versions of Nuke, look at release 1.0.0 on GitHub. 9 | 10 | ## Usage 11 | 12 | 13 | This script can be utilized in two methods. 14 | 15 | The first is a temporary approach where you can directly copy and paste the 16 | contents of [dag_capture.py](dag_capture.py) into the script editor and execute it entirely. 17 | 18 | The second method involves a more permanent 19 | setup. To do this, add the repository folder to the `%userprofile%/.nuke/init.py` file. If the init.py file does not exist 20 | in your `%userprofile%/.nuke/` directory, simply copy the one provided in `dot_nuke` folder. Then, adjust the path in the 21 | copied `init.py` file to correspond with the location where you cloned this repository. 22 | 23 | Pick a path for the desired location of the screenshot (.png format) and edit the options in the UI if necessary (defaults should work okay). 24 | 25 | ![ui screenshot](screenshot.png) 26 | 27 | Then click OK and hang on while the script does its work. 28 | 29 | ## Known issues 30 | - I have noticed that sometimes the very first tile of the screenshot is showing black with random colored pixels. I am not sure why that is the case, and usually retrying the capture fixes the issue. 31 | - Some node input labels move around when scrolling in the DAG, and can sometimes end up being in the screenshot multiple times, or being slightly cropped. The crop issue can be fixed by reducing the DAG preview (in lower right corner of DAG) to be invisible, and setting no right crop in the capture settings. Once the dag preview is entirely collapsed, I am not sure how to bring it back apart from restarting nuke. 32 | -------------------------------------------------------------------------------- /dag_capture.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import nuke 3 | import time 4 | 5 | from math import ceil 6 | from typing import Tuple 7 | 8 | if nuke.NUKE_VERSION_MAJOR < 16: 9 | from PySide2 import QtWidgets, QtOpenGL, QtGui, QtCore 10 | DAGType = QtOpenGL.QGLWidget 11 | 12 | def is_dag_widget(widget): 13 | return isinstance(widget, QtOpenGL.QGLWidget) 14 | else: 15 | from PySide6 import QtWidgets, QtGui, QtCore 16 | DAGType = QtWidgets.QWidget 17 | 18 | def is_dag_widget(widget): 19 | return widget.objectName() == 'DAG' 20 | 21 | 22 | def get_dag() -> DAGType: 23 | """Retrieve the QGLWidget of DAG graph""" 24 | stack = QtWidgets.QApplication.topLevelWidgets() 25 | while stack: 26 | widget = stack.pop() 27 | if widget.objectName() == 'DAG.1': 28 | for c in widget.children(): 29 | if is_dag_widget(c): 30 | return c 31 | 32 | stack.extend(c for c in widget.children() if c.isWidgetType()) 33 | 34 | 35 | def grab_dag(dag: DAGType, painter: QtGui.QPainter, xpos: int, ypos: int) -> None: 36 | """Draw dag frame buffer to painter image at given coordinates""" 37 | # updateGL does some funky stuff because grabFrameBuffer grabs the wrong thing without it 38 | try: 39 | dag.updateGL() 40 | img = dag.grabFrameBuffer() 41 | except AttributeError: 42 | # For PySide6 / Nuke 16. Can't seem to access the Open GL Widget anymore. 43 | # Sadly widget.grab() doesn't work either, so we fallback to doing screen captures, even 44 | # though it could have the cursor or other windows in the way. 45 | QtWidgets.QApplication.processEvents() 46 | screen = QtWidgets.QApplication.primaryScreen() 47 | # Convert the widget position to screen position 48 | pos = dag.mapToGlobal(QtCore.QPoint(0, 0)) 49 | pix = screen.grabWindow(0, pos.x(), pos.y(), dag.width(), dag.height()) 50 | 51 | # Need a QImage for the painter 52 | img = pix.toImage() 53 | painter.drawImage(xpos, ypos, img) 54 | 55 | 56 | class DagCapturePanel(QtWidgets.QDialog): 57 | """UI Panel for DAG capture options""" 58 | 59 | def __init__(self) -> None: 60 | parent = QtWidgets.QApplication.instance().activeWindow() 61 | super(DagCapturePanel, self).__init__(parent) 62 | 63 | # Variables 64 | self.dag = get_dag() 65 | if not self.dag: 66 | raise RuntimeError("Couldn't get DAG widget") 67 | 68 | self.dag_bbox = None 69 | self.capture_thread = DagCapture(self.dag) 70 | self.capture_thread.finished.connect(self.on_thread_finished) 71 | self.selection = [] 72 | 73 | # UI 74 | self.setWindowTitle("DAG Capture options") 75 | 76 | main_layout = QtWidgets.QVBoxLayout() 77 | form_layout = QtWidgets.QFormLayout() 78 | form_layout.setFieldGrowthPolicy(QtWidgets.QFormLayout.AllNonFixedFieldsGrow) 79 | form_layout.setLabelAlignment(QtCore.Qt.AlignRight) 80 | main_layout.addLayout(form_layout) 81 | 82 | # region Options 83 | # Path 84 | container = QtWidgets.QWidget() 85 | path_layout = QtWidgets.QHBoxLayout() 86 | path_layout.setContentsMargins(0, 0, 0, 0) 87 | container.setLayout(path_layout) 88 | self.path = QtWidgets.QLineEdit() 89 | browse_button = QtWidgets.QPushButton("Browse") 90 | browse_button.clicked.connect(self.show_file_browser) 91 | path_layout.addWidget(self.path) 92 | path_layout.addWidget(browse_button) 93 | form_layout.addRow("File Path", container) 94 | 95 | # Zoom 96 | self.zoom_level = QtWidgets.QDoubleSpinBox() 97 | self.zoom_level.setValue(1.0) 98 | self.zoom_level.setRange(0.01, 5) 99 | self.zoom_level.setSingleStep(.5) 100 | self.zoom_level.valueChanged.connect(self.display_info) 101 | form_layout.addRow("Zoom Level", self.zoom_level) 102 | 103 | # Margins 104 | self.margins = QtWidgets.QSpinBox() 105 | self.margins.setRange(0, 1000) 106 | self.margins.setValue(20) 107 | self.margins.setSuffix("px") 108 | self.margins.setSingleStep(10) 109 | self.margins.valueChanged.connect(self.display_info) 110 | form_layout.addRow("Margins", self.margins) 111 | 112 | # Right Crop 113 | self.ignore_right = QtWidgets.QSpinBox() 114 | self.ignore_right.setRange(0, 1000) 115 | self.ignore_right.setValue(200) 116 | self.ignore_right.setSuffix("px") 117 | self.ignore_right.setToolTip( 118 | "The right side of the DAG usually contains a mini version of itself.\n" 119 | "This gets included in the screen capture, so it is required to crop it out. \n" 120 | "If you scaled it down, you can reduce this number to speed up capture slightly." 121 | ) 122 | self.ignore_right.valueChanged.connect(self.display_info) 123 | form_layout.addRow("Crop Right Side", self.ignore_right) 124 | 125 | # Delay 126 | self.delay = QtWidgets.QDoubleSpinBox() 127 | self.delay.setValue(0) 128 | self.delay.setRange(0, 1) 129 | self.delay.setSuffix("s") 130 | self.delay.setSingleStep(.1) 131 | self.delay.valueChanged.connect(self.display_info) 132 | self.delay.setToolTip( 133 | "A longer delay ensures the Nuke DAG has fully refreshed between capturing tiles.\n" 134 | "It makes the capture slower, but ensures a correct result.\n" 135 | "Feel free to adjust based on results you have seen on your machine.\n" 136 | "Increase if the capture looks incorrect." 137 | ) 138 | form_layout.addRow("Delay Between Captures", self.delay) 139 | 140 | # Capture all nodes or selection 141 | self.capture = QtWidgets.QComboBox() 142 | self.capture.addItems(["All Nodes", "Selected Nodes"]) 143 | self.capture.currentIndexChanged.connect(self.inspect_dag) 144 | form_layout.addRow("Nodes to Capture", self.capture) 145 | 146 | # Deselect Nodes before Capture? 147 | self.deselect = QtWidgets.QCheckBox("Deselect Nodes before capture") 148 | self.deselect.setChecked(True) 149 | form_layout.addWidget(self.deselect) 150 | # endregion Options 151 | 152 | # Add Information box 153 | self.info = QtWidgets.QLabel("Hi") 154 | info_box = QtWidgets.QFrame() 155 | info_box.setFrameStyle(QtWidgets.QFrame.StyledPanel) 156 | info_box.setLayout(QtWidgets.QVBoxLayout()) 157 | info_box.layout().addWidget(self.info) 158 | main_layout.addWidget(info_box) 159 | 160 | # Buttons 161 | button_box = QtWidgets.QDialogButtonBox( 162 | QtWidgets.QDialogButtonBox.Ok | QtWidgets.QDialogButtonBox.Cancel 163 | ) 164 | button_box.accepted.connect(self.do_capture) 165 | button_box.rejected.connect(self.reject) 166 | main_layout.addWidget(button_box) 167 | 168 | self.setLayout(main_layout) 169 | 170 | self.inspect_dag() 171 | 172 | def display_info(self) -> None: 173 | """Displays the calculated information""" 174 | zoom = self.zoom_level.value() 175 | 176 | # Check the size of the current widget, excluding the right side (because of minimap) 177 | capture_width = self.dag.width() 178 | crop = self.ignore_right.value() 179 | if crop >= capture_width: 180 | self.info.setText( 181 | "Error: Crop is larger than capture area.\n" 182 | "Increase DAG size or reduce crop." 183 | ) 184 | return 185 | 186 | capture_width -= crop 187 | capture_height = self.dag.height() 188 | 189 | # Calculate the number of tiles required to cover all 190 | min_x, min_y, max_x, max_y = self.dag_bbox 191 | image_width = (max_x - min_x) * zoom + self.margins.value() * 2 192 | image_height = (max_y - min_y) * zoom + self.margins.value() * 2 193 | 194 | horizontal_tiles = int(ceil(image_width / float(capture_width))) 195 | vertical_tiles = int(ceil(image_height / float(capture_height))) 196 | total_tiles = horizontal_tiles * vertical_tiles 197 | total_time = total_tiles * self.delay.value() 198 | 199 | info = "Image Size: {width}x{height}\n" \ 200 | "Number of tiles required: {tiles} (Increase DAG size to reduce) \n" \ 201 | "Estimated Capture Duration: {time}s" 202 | info = info.format(width=int(image_width), height=int(image_height), tiles=total_tiles, 203 | time=total_time) 204 | self.info.setText(info) 205 | 206 | def inspect_dag(self) -> None: 207 | """Calculate the bounding box for DAG""" 208 | nodes = nuke.allNodes() if self.capture.currentIndex() == 0 else nuke.selectedNodes() 209 | 210 | # Calculate the total size of the DAG 211 | min_x, min_y, max_x, max_y = [], [], [], [] 212 | for node in nodes: 213 | min_x.append(node.xpos()) 214 | min_y.append(node.ypos()) 215 | max_x.append(node.xpos() + node.screenWidth()) 216 | max_y.append(node.ypos() + node.screenHeight()) 217 | 218 | self.dag_bbox = (min(min_x), min(min_y), max(max_x), max(max_y)) 219 | self.display_info() 220 | 221 | def show_file_browser(self) -> None: 222 | """Display the file browser""" 223 | filename, _filter = QtWidgets.QFileDialog.getSaveFileName( 224 | parent=self, caption='Select output file', 225 | filter="PNG Image (*.png)") 226 | self.path.setText(filename) 227 | 228 | def do_capture(self) -> None: 229 | """Run the capture thread""" 230 | self.hide() 231 | 232 | # Deselect nodes if required: 233 | if self.deselect.isChecked(): 234 | for selected_node in nuke.selectedNodes(): 235 | self.selection.append(selected_node) 236 | selected_node.setSelected(False) 237 | 238 | # Push settings to Thread 239 | self.capture_thread.path = self.path.text() 240 | self.capture_thread.margins = self.margins.value() 241 | self.capture_thread.ignore_right = self.ignore_right.value() 242 | self.capture_thread.delay = self.delay.value() 243 | self.capture_thread.bbox = self.dag_bbox 244 | self.capture_thread.zoom = self.zoom_level.value() 245 | 246 | # Run thread 247 | self.capture_thread.start() 248 | 249 | def on_thread_finished(self) -> None: 250 | """Re-Select previously selected items and display a result popup""" 251 | # Re-Select previously selected items 252 | for node in self.selection: 253 | node.setSelected(True) 254 | 255 | # Display a result popup 256 | if self.capture_thread.successful: 257 | nuke.message( 258 | "Capture complete:\n" 259 | "{}".format(self.path.text()) 260 | ) 261 | else: 262 | nuke.message( 263 | "Something went wrong with the DAG capture, please check script editor for details" 264 | ) 265 | 266 | 267 | class DagCapture(QtCore.QThread): 268 | """Thread class for capturing screenshot of Nuke DAG""" 269 | def __init__( 270 | self, 271 | dag: DAGType, 272 | path: str = '', 273 | margins: int = 20, 274 | ignore_right: int = 200, 275 | delay=0, 276 | bbox: Tuple[int, int, int, int] = (-50, 50, -50, 50), 277 | zoom: int = 1.0 278 | ) -> None: 279 | super(DagCapture, self).__init__() 280 | self.dag = dag 281 | self.path = path 282 | self.margins = margins 283 | self.ignore_right = ignore_right 284 | self.delay = delay 285 | self.bbox = bbox 286 | self.zoom = zoom 287 | self.successful = False 288 | 289 | def run(self) -> None: 290 | """On thread start""" 291 | # Store the current dag size and zoom 292 | original_zoom = nuke.zoom() 293 | original_center = nuke.center() 294 | 295 | # Calculate the total size of the DAG 296 | min_x, min_y, max_x, max_y = self.bbox 297 | zoom = self.zoom 298 | min_x -= int(self.margins / zoom) 299 | min_y -= int(self.margins / zoom) 300 | max_x += int(self.margins / zoom) 301 | max_y += int(self.margins / zoom) 302 | 303 | # Get the Dag Widget 304 | dag = self.dag 305 | 306 | # Check the size of the current widget, excluding the right side (because of minimap) 307 | capture_width = dag.width() - self.ignore_right 308 | capture_height = dag.height() 309 | 310 | # Calculate the number of tiles required to cover all 311 | image_width = int((max_x - min_x) * zoom) 312 | image_height = int((max_y - min_y) * zoom) 313 | horizontal_tiles = int(ceil(image_width / float(capture_width))) 314 | vertical_tiles = int(ceil(image_height / float(capture_height))) 315 | 316 | # Create a pixmap to store the results 317 | pixmap = QtGui.QPixmap(image_width, image_height) 318 | painter = QtGui.QPainter(pixmap) 319 | painter.setCompositionMode(QtGui.QPainter.CompositionMode_SourceOver) 320 | 321 | # Move the dag so that the top left corner is in the top left corner, 322 | # screenshot, paste in the pixmap, repeat 323 | for tile_x in range(horizontal_tiles): 324 | x_offset_tile = (min_x + capture_width / zoom * tile_x) 325 | x_offset_zoom = (capture_width + self.ignore_right) / zoom / 2 326 | center_x = x_offset_tile + x_offset_zoom 327 | for tile_y in range(vertical_tiles): 328 | center_y = (min_y + capture_height / zoom * tile_y) + capture_height / zoom / 2 329 | nuke.executeInMainThreadWithResult(nuke.zoom, (zoom, (center_x, center_y))) 330 | time.sleep(self.delay) 331 | nuke.executeInMainThreadWithResult(grab_dag, 332 | (dag, painter, capture_width * tile_x, 333 | capture_height * tile_y)) 334 | time.sleep(self.delay) 335 | painter.end() 336 | nuke.executeInMainThreadWithResult(nuke.zoom, (original_zoom, original_center)) 337 | save_successful = pixmap.save(self.path) 338 | if not save_successful: 339 | raise IOError("Failed to save PNG: %s" % self.path) 340 | 341 | self.successful = True 342 | 343 | 344 | def open_dag_capture() -> None: 345 | """Opens a blocking dag capture""" 346 | logging.info("Opening dag capture window") 347 | dag_capture_panel = DagCapturePanel() 348 | dag_capture_panel.show() 349 | 350 | 351 | if __name__ == '__main__': 352 | open_dag_capture() 353 | --------------------------------------------------------------------------------