# Extending Oink (for TDs)

> Studio pipeline guide: custom nodes, Python panels, menus, callbacks, init.py / menu.py, PySide tools, gizmos, OFX and MCP.

Oink runs a studio's Nuke pipeline code as it is: `init.py`, `menu.py`, callbacks, Python panels, gizmos and PySide tools. This page is for pipeline TDs setting it up. Every feature on it is checked against a set of real studio scripts before each release.

## Startup scripts and the plugin path

At startup the backend runs, in this order:

| Step | What |
|---|---|
| 1 | Every plugin folder's `init.py`: `~/.opencomp` (or `$OPENCOMP_USER_DIR`), then each folder of `NUKE_PATH`, then each of `OPENCOMP_PLUGIN_PATH` |
| 2 | `nuke.pluginAddPath(dir)` inside an `init.py` adds `dir`: its own `init.py` runs at once, its `menu.py` later |
| 3 | Every `menu.py`, in the same order (the app always has a UI, like Nuke's GUI mode) |
| 4 | Every `oc_nodes/` folder on the plugin path: [custom nodes](#custom-nodes) |

- `NUKE_PATH` works exactly as in Nuke, so point it at your existing studio folder. `OPENCOMP_PLUGIN_PATH` is for folders only this app should read.
- `__file__` is set while `init.py` / `menu.py` run, so `os.path.dirname(__file__)` works.
- An error in one file is printed (Script Editor, backend log) and never stops the others.

A typical `init.py`:

```python
import os, nuke
ROOT = os.path.dirname(__file__)
nuke.pluginAddPath(os.path.join(ROOT, "gizmos"))
nuke.pluginAddPath(os.path.join(ROOT, "python"))
nuke.addFormat("2048 858 1.0 STUDIO_Scope_2K")
nuke.knobDefault("Root.format", "STUDIO_Scope_2K")
nuke.knobDefault("Blur.size", "3")
```

## Menus

| Menu | Where it shows |
|---|---|
| `nuke.menu('Nuke')` | the menu bar: items under an existing menu (`findItem('Edit')`) join it, new menus go after Help |
| `nuke.menu('Nodes')` | the node toolbar on the left (a new menu gets a button) and the Tab menu |
| `nuke.menu('Node Graph')` | the Tab menu |
| `nuke.menu('Viewer')` | the Viewer menu |
| `nuke.menu('Pane')` | the pane menu (the small square at the left of every tab strip), under a separator: registered panels and Qt tools |

```python
m = nuke.menu("Nuke").addMenu("Studio")
m.addCommand("Shot Setup...", shot_setup.run, "alt+shift+s")      # a callable or a string
tb = nuke.menu("Nodes").addMenu("Studio", icon="studio.png")
tb.addCommand("Gizmos/StudioVignette", "nuke.createNode('StudioVignette')")
```

Shortcuts use Nuke's forms (`^#b` = Ctrl+Alt+B) or words (`ctrl+alt+b`) and work anywhere in the app.

## Callbacks

| Function | Fires |
|---|---|
| `addOnUserCreate(fn, nodeClass=...)` | a node created by the user (Tab menu, toolbar, `nuke.createNode`) |
| `addOnCreate(fn, nodeClass=...)` | every new node, also when a script opens or a .nk is pasted |
| `addOnDestroy` | a node deleted |
| `addKnobChanged(fn, nodeClass=...)` | a knob edited in the Properties panel (`nuke.thisNode()`, `nuke.thisKnob()` are set); a node's own `knobChanged` knob runs too |
| `addBeforeRender` / `addAfterRender` / `addBeforeFrameRender` / `addAfterFrameRender` | around every render of a Write (the Render dialog, `nuke.execute`) |
| `addOnScriptLoad` / `addOnScriptSave` / `addOnScriptClose` | File > Open, Save, New / Close |
| `addFilenameFilter(fn)` | every file path a node reads or writes (Read, Write, ReadGeo, Precomp...): path mapping between platforms |

The two most common studio callbacks, as they run here unchanged:

```python
def create_write_dir():                      # beforeRender: make the output folder
    d = os.path.dirname(nuke.filename(nuke.thisNode()))
    d = nuke.callbacks.filenameFilter(d)
    if not os.path.isdir(d):
        os.makedirs(d)
nuke.addBeforeRender(create_write_dir)

def path_map(f):                             # a Windows artist's P:/ opens on Linux
    return f.replace("P:/", "/mnt/proj/") if f.startswith("P:/") else f
nuke.addFilenameFilter(path_map)
```

Callbacks run in the backend. Their `print` output and errors go to the Script Editor; nodes they create or change show up in the Node Graph at once and join the undo step of the edit that fired them.

## Python panels

`nukescripts.PythonPanel` and `nuke.Panel` are drawn with the same knob widgets as the Properties panel (fields, sliders, dropdowns, checkboxes, colour knobs, file browser). The panel's `knobChanged` runs in the backend on every edit and the panel redraws from the answer, so `setEnabled`, `setVisible`, `setValues`, `setValue` and adding / removing knobs all show at once.

| Call | Shows |
|---|---|
| `panel.showModalDialog()` | a modal dialog with OK / Cancel; returns `True` / `False`. Knobs named `OK` / `Cancel` replace the default buttons; `finishModalDialog(result)` closes it from `knobChanged` |
| `panel.show()` | a floating panel (it can be docked like any pane) |
| `panel.addToPane()` | a tab beside Properties |
| `nuke.Panel(title)` + `addSingleLineInput`, `addEnumerationPulldown`, `addBooleanCheckBox`, `addFilenameSearch`, `addClipnameSearch`, `addMultilineTextInput`, `addPasswordInput`, `addRGBColorChip`, `addButton`... `.show()` | the old-style modal form; `show()` returns 1 / 0 or the index of the pressed `addButton` |
| `nuke.message`, `ask`, `getInput`, `getFilename`, `getClipname`, `choice`, `getColor` | small modal dialogs; the script waits for the answer |
| `nuke.ProgressTask(title)` | a progress dialog; `setMessage`, `setProgress`, and `isCancelled()` turns `True` when the user presses Cancel |

Every knob type the Python API can create has a widget: String, EvalString, Multiline, Password, Int, Double, Array, XY, XYZ, UV, WH, Scale, BBox, Box3, Range, Keyer, Color, AColor, ColorChip, Enumeration, Radio, CascadingEnumeration, Boolean, File, Format, Channel, ChannelMask, Font, PyScript, Script, Pulldown, Text (a text line, or a divider when it has no label and no text), Help, Tab, BeginTabGroup / EndTabGroup, and the `-STARTLINE` layout flag. Link, Obsolete, Transform2d, Histogram and PyCustom knobs are not shown.

Register a panel for the pane menu the Nuke way:

```python
nukescripts.registerPanel("com.studio.ShotSetup", ShotSetupPanel)
nuke.menu("Pane").addCommand("Shot Setup", lambda: ShotSetupPanel().addToPane())
```

Headless (farm, tests) there is no one to answer: dialogs return what Nuke returns in `-t` mode (`ask` is `False`, `getInput` is `None`, panels are cancelled).

## PySide / Qt tools

The app's interface is not Qt, so a Qt widget can't be drawn inside it. Studio PySide tools run instead in a **Qt helper**: a separate Python process with PySide6 that the backend starts when a Qt tool is first opened. Their windows float next to the app (they can't dock into its panes).

Set the helper up once per machine: make a Python environment with PySide6 in the default place, or point the app at one you already have.

```sh
python3 -m venv ~/.cache/opencomp/qt-venv
~/.cache/opencomp/qt-venv/bin/pip install PySide6
```

`OPENCOMP_QT_PYTHON` (a Python that has PySide6 or PySide2) or `OPENCOMP_QT_VENV` (a venv folder) pick another one. PySide6 is LGPL; it is installed separately and loaded, unmodified, by that separate process.

What works unchanged:

- `import nuke` inside the tool: every call goes to the backend's `nuke` (`nuke.selectedNode()`, `node['size'].setValue(7)`, `nuke.createNode`, `nuke.nodes.Write()`, `with nuke.Undo():`, `nuke.message`...). Nodes and knobs come back as live handles. Changes are undoable and the Node Graph, Properties and Viewer follow.
- `nuke.addKnobChanged(self.refresh)` and the other callbacks: the backend calls back into the tool on its Qt thread (after the event, not waiting for the tool).
- `nukescripts.panels.registerWidgetAsPanel('studio.tools.MyPanel', 'My Panel', 'com.studio.MyPanel')`: the tool gets an item in the pane menu; `create=True` opens it at once.
- A menu command that builds and shows a Qt dialog (`mytool.show_dialog()` doing `MyDialog().show()`) opens it in the helper.
- `menu.py` can import Qt tool modules: in the backend, PySide2 / PySide6 / Qt.py imports load stand-in modules so the class definitions work, and the widget is only ever built in the helper.
- `nuke.executeInMainThread` inside a tool runs on the tool's Qt thread.

Limits: a tool's windows can't dock into the app; a tool can't subclass the backend's nuke classes (for example `class P(nukescripts.PythonPanel)` inside a Qt tool: keep that in `menu.py`); `PyCustom_Knob` widgets inside node panels are not shown; callbacks registered from a tool can't return a value to the backend (so a `filenameFilter` belongs in `init.py`).

## Custom nodes

A studio node is a Python class and a GLSL fragment shader in an `oc_nodes/` folder of any plugin directory. No change to the app is needed. Example:

```python
# oc_nodes/studio_tint.py
import bpy
from opencomp_core import plugins
from opencomp_core.gpu_pipeline.executor import evaluate_shader
from opencomp_core.nodes.base import OpenCompNode
from opencomp_core.nodes.common import CommonKnobs, add_common_sockets, process


class StudioTintNode(CommonKnobs, OpenCompNode):
    """Multiply the colour by a tint."""
    bl_idname = "OC_N_studio_tint"                  # always OC_N_<name>
    bl_label = "StudioTint"

    tint: bpy.props.FloatVectorProperty(name="tint", size=3, default=(1.0, 0.8, 0.6))
    amount: bpy.props.FloatProperty(name="amount", default=1.0, soft_min=0.0, soft_max=1.0)

    def init(self, context):
        super().init(context)
        add_common_sockets(self)                    # image + mask in, image out

    def evaluate_image(self, texture_pool):
        u = {"u_tint": list(self.tint), "u_amount": float(self.amount)}
        return process(self, texture_pool,
                       lambda tex: evaluate_shader("studio_tint.frag", tex, u, texture_pool))


def register():
    plugins.register_node(StudioTintNode, label="StudioTint", category="Studio")
```

```glsl
// oc_nodes/studio_tint.frag
uniform sampler2D u_image;
uniform vec3      u_tint;
uniform float     u_amount;
in  vec2 v_uv;
out vec4 out_color;

void main() {
    vec4 src = texelFetch(u_image, ivec2(gl_FragCoord.xy), 0);
    out_color = vec4(mix(src.rgb, src.rgb * u_tint, u_amount), src.a);
}
```

`plugins.register_node(cls, label, category, nk_class=label)`:

- registers the class and puts it in the node menu (Tab menu and left toolbar) under `category`;
- maps the Nuke class name, so `nuke.createNode('StudioTint')`, `nuke.nodes.StudioTint(tint=[1, .5, .25])` and .nk import / export work; knob names are the property names;
- the `.frag` next to the module is found by name.

Rules (the same as for the built-in nodes): properties use a colon (`tint: bpy.props...`), never `=`; the pipeline is scene-linear float, never clamp; get textures only through `evaluate_shader` / the texture pool; return `None` on failure; uniforms must match their GLSL types exactly (a `vec3` takes 3 floats). `CommonKnobs` + `process()` give the node Nuke's channels / mask / mix knobs. The Properties panel is laid out from the knob values.

## Gizmos and Groups

`.gizmo` files are found in every plugin folder (and its sub-folders), `~/.nuke`, `~/.opencomp/gizmos` and `NUKE_PATH`, and appear in the Tab menu. A gizmo's own `knobChanged` / `onCreate` runs for each instance, so gizmo panels that show / hide knobs work. User knobs (`addUserKnob`, `node.addKnob(nuke.Double_Knob(...))`) appear in the Properties panel. More in [.nk import and export](/docs/nuke-users/nk-import).

## OFX and BlinkScript

OpenFX image plugins become nodes in the Tab menu and on the OFX toolbar button; they run in their own process. BlinkScript kernels are translated to GLSL. See [BlinkScript and OFX](/docs/nuke-users/blink-ofx).

## MCP

The backend serves the Model Context Protocol on your machine, so agents and pipeline services can read the graph, render frames and (with the artist's approval) edit the comp. See [MCP and the assistant](/docs/developers/mcp).

## ShotGrid Toolkit

tk-nuke is not installed or tested. It would need a tk engine that runs in the Qt helper, with its scene-operation, publish and Write-node hooks going through the bridged `nuke` module.


---
Sources: opencomp_core/plugins.py, opencomp_core/nukepy/nuke/__init__.py, opencomp_core/nukepy/nuke/_panels.py, opencomp_core/nukepy/nuke/_callbacks.py, opencomp_core/nukepy/nuke/_menus.py, opencomp_core/nukepy/nuke/_qtstub.py, opencomp_core/nukepy/nukescripts/panels.py, opencomp_core/nukepy/qtbridge/host.py, opencomp_core/nukepy/qtbridge/helper/oc_qt_helper.py, opencomp_core/nk/gizmos.py, opencomp_core/samples.py, opencomp_server/nuke_commands.py, opencomp_electron/src/components/scripteditor/PanelKnobs.tsx
