From 94e313b458ac71582b27c0aea9d4cc182f5af253 Mon Sep 17 00:00:00 2001 From: charlesangus Date: Tue, 1 Sep 2026 17:52:54 -0400 Subject: [PATCH 01/15] add autolabel cache and debounce --- labelmaker.py | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/labelmaker.py b/labelmaker.py index 383ab6b..c7648a2 100644 --- a/labelmaker.py +++ b/labelmaker.py @@ -8,6 +8,9 @@ import labelmaker_deoverlap import labelmaker_prefs +# how long a built autolabel is reused before the node is rebuilt (milliseconds) +AUTOLABEL_DEBOUNCE_MS = 100 + # from https://gist.github.com/anonymous/a802f51391163a2bf0e3 def node_has_mask(node): @@ -88,6 +91,9 @@ def __init__(self, config): self._line_counts = {} # {node_name: int} last known line count per node self._pending_deoverlap = set() # node names whose height increased since last timer fire self._deoverlap_timer = None # created lazily; PySide6 is not imported at module level + self._label_cache = {} # {node_name: str} last built autolabel per node + self._label_fresh = set() # node names already rebuilt in the current debounce window + self._label_timer = None # created lazily; PySide6 is not imported at module level def register_autolabel(self): nuke.addAutolabel(self.create_autolabel) @@ -110,6 +116,15 @@ def _get_deoverlap_timer(self): self._deoverlap_timer.timeout.connect(self._run_deoverlap) return self._deoverlap_timer + def _get_label_timer(self): + if self._label_timer is None: + from PySide6 import QtCore + self._label_timer = QtCore.QTimer() + self._label_timer.setSingleShot(True) + self._label_timer.setInterval(AUTOLABEL_DEBOUNCE_MS) + self._label_timer.timeout.connect(self._label_fresh.clear) + return self._label_timer + def _run_deoverlap(self): pending = self._pending_deoverlap.copy() self._pending_deoverlap.clear() @@ -117,6 +132,14 @@ def _run_deoverlap(self): labelmaker_deoverlap.deoverlap_from_nodes(pending) def create_autolabel(self): + # Nuke calls the autolabel for every visible node on every DAG redraw, + # so building the label is throttled: a node is rebuilt at most once + # per AUTOLABEL_DEBOUNCE_MS, and redraws in between reuse the cached + # string. The single-shot timer is not restarted while it is running, + # so it acts as a refresh tick rather than a trailing-edge delay. + node_name = nuke.thisNode()["name"].getValue() + if node_name in self._label_fresh and node_name in self._label_cache: + return self._label_cache[node_name] self.update() self.set_indicators() self.name_line_creator() @@ -136,6 +159,11 @@ def create_autolabel(self): ): self._pending_deoverlap.add(self.node_name) self._get_deoverlap_timer().start() # restarts timer if already running + self._label_cache[self.node_name] = autolabel + self._label_fresh.add(self.node_name) + label_timer = self._get_label_timer() + if not label_timer.isActive(): + label_timer.start() return autolabel def update(self): From 6d76f277d3fd75f4fcd294eb159ced622e7546ba Mon Sep 17 00:00:00 2001 From: charlesangus Date: Sun, 13 Sep 2026 20:07:42 -0400 Subject: [PATCH 02/15] Replace the autolabel debounce with a burst-served label cache and idle refresh Profiling with real X input against 3k-10k node scripts showed the cost that matters is not building a label but handing Nuke a *changed* label string: every change costs a main-loop stall proportional to the script size (~70 ms at 3k nodes, ~200 ms at 10k). The 100 ms debounce made slider drags 120-260 ms per pointer event on those scripts (stock: 15-25 ms) and, once the stall exceeded its window, rebuilt on every move anyway. It also dropped trailing edits (two edits 50 ms apart left the first value on screen). Nuke asks for a label on a real knob change (one or two requests) or in a whole-script pass (every node back to back, after a viewer input change or any knob change followed by a frame step). The new scheme: - bursts of requests are answered from a per-node cache, so whole-script passes cost microseconds per node instead of a build - a changed label string is never returned while the user interacts; the previous text is shown and the node is marked stale - once label traffic has been quiet for >= 0.4 s (5x the measured stall), stale nodes get a dope_sheet change-and-revert (one relabel, no undo entry, no visible change), which releases the new text - frame-dependent nodes (keys, expressions, TCL labels) are cached per frame and refreshed the same way after a scrub - no knobChanged hook: dragging a 3k-node selection fires it 123k times; onDestroy (once per deleted node) keeps a reused name from inheriting the old label Measured in one Nuke 17 session with the implementations interleaved (ms per pointer event, 10 002 nodes): slider drag 13-20 vs stock 13-23 (was 154-266); timeline scrub 22-32 vs 139-186; whole-script pass 171 ms vs 700 ms; node drags unchanged. Config and preference saves now invalidate the cache and refresh every label, so changes show immediately. The temporary profiling instrumentation is removed; the harness lives in .profiling (ignored). --- .gitignore | 3 + README.md | 7 +- docs/user-guide.pdf | Bin 324308 -> 327009 bytes labelmaker.py | 203 ++++++++++++++++++++++++++----- labelmaker_config_editor.py | 1 + labelmaker_prefs_dialog.py | 2 + tests/conftest.py | 10 +- tests/stubs.py | 9 ++ tests/test_label_cache.py | 234 ++++++++++++++++++++++++++++++++++++ 9 files changed, 435 insertions(+), 34 deletions(-) create mode 100644 tests/test_label_cache.py diff --git a/.gitignore b/.gitignore index 09e2608..9970823 100644 --- a/.gitignore +++ b/.gitignore @@ -137,3 +137,6 @@ dmypy.json # Intermediate Markdown generated from the README for the User Guide PDF docs/.build/ + +# Local profiling harness (see .profiling/README.md) +.profiling/ diff --git a/README.md b/README.md index e22db67..b22be84 100644 --- a/README.md +++ b/README.md @@ -172,4 +172,9 @@ If this bothers you, enable **Always Show All Labels** in the preferences. Nodes ## Performance -Labelmaker has been used on production scripts of substantial size without issue. The autolabel routine runs as a low-priority idle process. If you do encounter performance problems, please open a GitHub issue and include the approximate node count and any node class that seems to be the culprit. +Labelmaker has been used on production scripts of substantial size without issue. Two things keep it responsive on large scripts: + +- Nuke periodically asks for every node's label again even though nothing changed (after connecting a Viewer, for example). Labelmaker answers those passes from a cache instead of rebuilding each label. +- Every time a node's label text changes, Nuke re-lays out the whole Node Graph, which on a 10 000-node script takes a noticeable fraction of a second. Labelmaker therefore never updates label text *while* you are dragging a slider or scrubbing the timeline: the readouts catch up once you pause for about half a second. Nodes you edit indirectly (through an expression link, or by wiring a mask input) update the next time Nuke asks for their label. + +If you do encounter performance problems, please open a GitHub issue and include the approximate node count and any node class that seems to be the culprit. diff --git a/docs/user-guide.pdf b/docs/user-guide.pdf index f5c2c5bf1a36c6986ffddb0ad7bed783e0e92967..74dd32ed94068476d74aff1f9ec77da3d67d0d3e 100644 GIT binary patch delta 13668 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 delta 11154 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 diff --git a/labelmaker.py b/labelmaker.py index c7648a2..92fd3fd 100644 --- a/labelmaker.py +++ b/labelmaker.py @@ -1,6 +1,7 @@ import contextlib import os import re +import time import nuke @@ -8,8 +9,20 @@ import labelmaker_deoverlap import labelmaker_prefs -# how long a built autolabel is reused before the node is rebuilt (milliseconds) -AUTOLABEL_DEBOUNCE_MS = 100 +# Nuke only asks for a node's label again when a real knob on it changes, or +# in a whole-script pass (after a viewer input change, or any knob change +# followed by a frame step) that requests every node in one burst. Handing +# Nuke a *changed* label string costs it a main-loop stall proportional to +# the script size (~70 ms at 3k nodes, ~200 ms at 10k), which is what makes +# slider drags and scrubbing sluggish. So: answer bursts from a cache, never +# return a changed string while the user is interacting, and release changed +# strings once label traffic has gone quiet (see .profiling results). +LABEL_BURST_GAP_S = 0.005 # requests closer together than this are one pass +LABEL_BURST_MIN = 8 # requests before a pass counts as a burst +LABEL_BURST_GENUINE_MAX = 200 # a burst this small is a real multi-node edit +LABEL_REFRESH_MIN_S = 0.4 # quiet time before stale labels are released +LABEL_REFRESH_MAX_S = 1.5 +LABEL_STALL_FACTOR = 5.0 # wait at least this many measured stalls # from https://gist.github.com/anonymous/a802f51391163a2bf0e3 @@ -77,7 +90,7 @@ def node_mask_input_plugged(n): class AutolabelReplacement(object): def __init__(self, config): super(AutolabelReplacement, self).__init__() - self.config = config + self._config = config self.class_mappings = { "Merge2": "Merge", "Camera2": "Camera", @@ -91,15 +104,34 @@ def __init__(self, config): self._line_counts = {} # {node_name: int} last known line count per node self._pending_deoverlap = set() # node names whose height increased since last timer fire self._deoverlap_timer = None # created lazily; PySide6 is not imported at module level - self._label_cache = {} # {node_name: str} last built autolabel per node - self._label_fresh = set() # node names already rebuilt in the current debounce window - self._label_timer = None # created lazily; PySide6 is not imported at module level + self._content = {} # {full_name: (frame or None, text)} from the last real build + self._shown = {} # {full_name: text} the string Nuke was last given + self._forced = set() # full names whose next request must build and show the result + self._stale = set() # full names shown with a string known to be out of date + self._burst = {"t": 0.0, "n": 0, "names": []} + self._stall_t = None # when a changed string was last handed to Nuke + self._stall_ema = 0.05 # running estimate of Nuke's stall after a change + self._refresh_timer = None # created lazily; PySide6 is not imported at module level + + @property + def config(self): + return self._config + + @config.setter + def config(self, config): + self._config = config + self.invalidate_labels() def register_autolabel(self): nuke.addAutolabel(self.create_autolabel) + # fires once per deleted node, so a new node reusing the name never + # inherits the old one's cached label + nuke.addOnDestroy(self._on_node_destroyed) def unregister_autolabel(self): nuke.removeAutolabel(self.create_autolabel) + nuke.removeOnDestroy(self._on_node_destroyed) + self.invalidate_labels() def set_enabled(self, enabled): if enabled: @@ -116,15 +148,6 @@ def _get_deoverlap_timer(self): self._deoverlap_timer.timeout.connect(self._run_deoverlap) return self._deoverlap_timer - def _get_label_timer(self): - if self._label_timer is None: - from PySide6 import QtCore - self._label_timer = QtCore.QTimer() - self._label_timer.setSingleShot(True) - self._label_timer.setInterval(AUTOLABEL_DEBOUNCE_MS) - self._label_timer.timeout.connect(self._label_fresh.clear) - return self._label_timer - def _run_deoverlap(self): pending = self._pending_deoverlap.copy() self._pending_deoverlap.clear() @@ -132,14 +155,33 @@ def _run_deoverlap(self): labelmaker_deoverlap.deoverlap_from_nodes(pending) def create_autolabel(self): - # Nuke calls the autolabel for every visible node on every DAG redraw, - # so building the label is throttled: a node is rebuilt at most once - # per AUTOLABEL_DEBOUNCE_MS, and redraws in between reuse the cached - # string. The single-shot timer is not restarted while it is running, - # so it acts as a refresh tick rather than a trailing-edge delay. - node_name = nuke.thisNode()["name"].getValue() - if node_name in self._label_fresh and node_name in self._label_cache: - return self._label_cache[node_name] + now = time.perf_counter() + self._note_stall(now) + in_burst = self._track_burst(now) + full_name = nuke.thisNode().fullName() + cached = self._content.get(full_name) + if in_burst and cached is not None and full_name not in self._forced: + # a whole-script pass: nothing about this node changed + self._burst["names"].append(full_name) + frame, text = cached + if frame is not None and frame != nuke.frame(): + self._mark_stale(full_name) + return self._shown.get(full_name, text) + was_forced = full_name in self._forced + self._forced.discard(full_name) + text = self._build_label() + self._content[full_name] = (nuke.frame() if self._frame_dependent() else None, text) + previous = self._shown.get(full_name) + if previous is not None and text != previous and not was_forced: + # keep showing the old string; the idle refresh releases the new one + self._mark_stale(full_name) + return previous + if text != previous: + self._stall_t = now + self._shown[full_name] = text + return text + + def _build_label(self): self.update() self.set_indicators() self.name_line_creator() @@ -159,13 +201,111 @@ def create_autolabel(self): ): self._pending_deoverlap.add(self.node_name) self._get_deoverlap_timer().start() # restarts timer if already running - self._label_cache[self.node_name] = autolabel - self._label_fresh.add(self.node_name) - label_timer = self._get_label_timer() - if not label_timer.isActive(): - label_timer.start() return autolabel + def _frame_dependent(self): + # keys or an expression (indicator bits 1 and 2), or TCL in the label + # knob: Nuke re-requests these on frame changes, so cache them per frame + return bool(self.indicators & 3) or "[" in self.node_label_value + + def _note_stall(self, now): + # the gap from handing Nuke a changed string to its next request is, + # during a drag, one pointer interval plus Nuke's stall + if self._stall_t is None: + return + gap = now - self._stall_t + self._stall_t = None + if gap < 1.0: + self._stall_ema = 0.7 * self._stall_ema + 0.3 * gap + + def _refresh_window(self): + window = LABEL_STALL_FACTOR * self._stall_ema + return min(LABEL_REFRESH_MAX_S, max(LABEL_REFRESH_MIN_S, window)) + + def _track_burst(self, now): + burst = self._burst + if now - burst["t"] > LABEL_BURST_GAP_S: + self._close_burst() + burst["t"] = now + burst["n"] += 1 + if burst["n"] == LABEL_BURST_MIN + 1: + self._arm_refresh() + return burst["n"] > LABEL_BURST_MIN + + def _close_burst(self): + burst = self._burst + if LABEL_BURST_MIN < burst["n"] <= LABEL_BURST_GENUINE_MAX: + # too small for a whole-script pass: a real multi-node edit that + # was answered from the cache, so refresh those nodes + self._stale.update(burst["names"]) + self._arm_refresh() + burst["n"] = 0 + burst["names"] = [] + + def _mark_stale(self, full_name): + self._stale.add(full_name) + self._arm_refresh() + + def _get_refresh_timer(self): + if self._refresh_timer is None: + from PySide6 import QtCore + self._refresh_timer = QtCore.QTimer() + self._refresh_timer.setSingleShot(True) + self._refresh_timer.timeout.connect(self._refresh_stale_labels) + return self._refresh_timer + + def _arm_refresh(self): + self._get_refresh_timer().start(int(self._refresh_window() * 1000)) + + def _refresh_stale_labels(self): + if time.perf_counter() - self._burst["t"] < self._refresh_window() * 0.9: + self._arm_refresh() # still busy: wait for the traffic to end + return + self._close_burst() + names = list(self._stale) + self._stale.clear() + self._poke_nodes(names) + + def _poke_nodes(self, full_names): + # Nothing in the API re-requests one node's label; a real knob change + # does. Flipping dope_sheet and flipping it back in the same callback + # yields exactly one relabel, no undo entry and no visible change. + nuke.Undo.disable() + try: + for full_name in full_names: + node = nuke.toNode(full_name) + if node is None: + continue + knob = node.knob("dope_sheet") + if knob is None: + continue + self._forced.add(full_name) + value = knob.value() + knob.setValue(not value) + knob.setValue(value) + finally: + nuke.Undo.enable() + + def _on_node_destroyed(self): + full_name = nuke.thisNode().fullName() + self._content.pop(full_name, None) + self._shown.pop(full_name, None) + self._stale.discard(full_name) + self._forced.discard(full_name) + + def invalidate_labels(self): + """Forget every cached label; nodes rebuild when Nuke next asks.""" + self._content.clear() + self._shown.clear() + self._stale.clear() + self._forced.clear() + self._burst = {"t": 0.0, "n": 0, "names": []} + + def refresh_all_labels(self): + """Rebuild and redraw every label now (after a config change).""" + self.invalidate_labels() + self._poke_nodes([node.fullName() for node in nuke.allNodes(recurseGroups=True)]) + def update(self): self.lines = [] self.n = nuke.thisNode() @@ -182,14 +322,16 @@ def set_indicators(self): # is copyright Foundry, all rights reserved # seemingly more or less need to use this TCL code, as there doesn't # seem to be python equivalents for these functions - ind = nuke.expression( + # nuke.expression returns a float + ind = int(nuke.expression( "(keys?1:0)+(has_expression?2:0)+(clones?8:0)+(viewsplit?32:0)" - ) + )) if int(nuke.numvalue("maskChannelInput", 0)): ind += 4 if int(nuke.numvalue("this.mix", 1)) < 1: ind += 16 nuke.knob("this.indicators", str(ind)) + self.indicators = ind def name_line_creator(self): # specialcase a few nodes which should not have names @@ -376,6 +518,7 @@ def label_readout_creator(self): node_label_value = nuke.value("this.label", "") with contextlib.suppress(RuntimeError): node_label_value = nuke.tcl("subst", node_label_value) + self.node_label_value = node_label_value or "" if node_label_value != "" and node_label_value is not None: self.lines.append(node_label_value) diff --git a/labelmaker_config_editor.py b/labelmaker_config_editor.py index 2b353c6..fd02da2 100644 --- a/labelmaker_config_editor.py +++ b/labelmaker_config_editor.py @@ -735,6 +735,7 @@ def _on_save(self): labelmaker.autolabeller_singleton.config = ( labelmaker_config.composed_config_singleton ) + labelmaker.autolabeller_singleton.refresh_all_labels() saved_layer_name = self._layer_name saved_class = self._current_class diff --git a/labelmaker_prefs_dialog.py b/labelmaker_prefs_dialog.py index 6952d34..806d89f 100644 --- a/labelmaker_prefs_dialog.py +++ b/labelmaker_prefs_dialog.py @@ -143,6 +143,8 @@ def _on_accept(self): labelmaker_config.reload_composed_config() labelmaker.autolabeller_singleton.config = labelmaker_config.composed_config_singleton labelmaker.autolabeller_singleton.set_enabled(self.labelmaker_enabled_checkbox.isChecked()) + if self.labelmaker_enabled_checkbox.isChecked(): + labelmaker.autolabeller_singleton.refresh_all_labels() self.accept() diff --git a/tests/conftest.py b/tests/conftest.py index 564444e..619ff66 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -30,9 +30,13 @@ class _StubMenuItem: _nuke_stub.warning = lambda msg: None _nuke_stub.addAutolabel = lambda fn: None _nuke_stub.removeAutolabel = lambda fn: None -_nuke_stub.allNodes = lambda: [] +_nuke_stub.allNodes = lambda recurseGroups=False: [] _nuke_stub.thisNode = lambda: None -_nuke_stub.expression = lambda expr: 0 +_nuke_stub.toNode = lambda name: None +_nuke_stub.frame = lambda: 1 +_nuke_stub.addOnDestroy = lambda fn: None +_nuke_stub.removeOnDestroy = lambda fn: None +_nuke_stub.expression = lambda expr: 0.0 _nuke_stub.numvalue = lambda knob, default=0: default _nuke_stub.knob = lambda path, value=None: None _nuke_stub.value = lambda path, default="": default @@ -99,7 +103,7 @@ def setSingleShot(self, value): def setInterval(self, value): pass - def start(self): + def start(self, interval=None): pass diff --git a/tests/stubs.py b/tests/stubs.py index 29fd6c1..abff0e1 100644 --- a/tests/stubs.py +++ b/tests/stubs.py @@ -16,6 +16,9 @@ def getValue(self): def Class(self): return self._class + def setValue(self, value): + self._value = value + class StubNode: def __init__(self, class_name, knobs=None, xpos=0, ypos=0, width=80, height=28): @@ -62,5 +65,11 @@ def name(self): return name_knob.value() return self._class + def fullName(self): + return self.name() + + def knob(self, knob_name): + return self._knobs.get(knob_name) + def setYpos(self, value): self._ypos = value diff --git a/tests/test_label_cache.py b/tests/test_label_cache.py new file mode 100644 index 0000000..42a3e63 --- /dev/null +++ b/tests/test_label_cache.py @@ -0,0 +1,234 @@ +"""The label cache in front of the autolabel build. + +Nuke asks for a label on a real knob change (one or two requests on their +own) or in a whole-script pass (every node, back to back). Every *changed* +string handed back costs Nuke a stall, so bursts are answered from the cache, +changed strings are held back while the user interacts, and stale nodes are +refreshed (by poking dope_sheet) once the traffic goes quiet. +""" +import nuke +import pytest +from stubs import StubKnob, StubNode + +import labelmaker +from labelmaker import AutolabelReplacement + + +class _EmptyConfig: + def get(self, key, default=None): + return default + + +class _Clock: + def __init__(self): + self.now = 100.0 + + def __call__(self): + return self.now + + +class _FakeTimer: + def __init__(self): + self.interval = None + self.callback = None + self.timeout = self + + def connect(self, fn): + self.callback = fn + + def setSingleShot(self, value): + pass + + def start(self, interval): + self.interval = interval + + def fire(self): + self.callback() + + +def _node(name): + return StubNode("Grade", knobs={"name": StubKnob("name", name), "dope_sheet": StubKnob("dope_sheet", False)}) + + +@pytest.fixture +def clock(monkeypatch): + clock = _Clock() + monkeypatch.setattr(labelmaker.time, "perf_counter", clock) + return clock + + +@pytest.fixture +def labeller(monkeypatch, clock): + labeller = AutolabelReplacement(_EmptyConfig()) + labeller._refresh_timer = _FakeTimer() + labeller._refresh_timer.connect(labeller._refresh_stale_labels) + labeller.texts = {} # {node name: text the build returns} + labeller.builds = [] # names built, in order + labeller.nodes = {} + + def build(): + name = nuke.thisNode().name() + labeller.builds.append(name) + labeller.indicators = 0 + labeller.node_label_value = "" + return labeller.texts[name] + + monkeypatch.setattr(labeller, "_build_label", build) + monkeypatch.setattr(nuke, "toNode", lambda name: labeller.nodes.get(name)) + return labeller + + +def request(labeller, clock, name, text=None, advance=1.0): + """Nuke asking for `name`'s label `advance` seconds after the last request.""" + clock.now += advance + if text is not None: + labeller.texts[name] = text + node = labeller.nodes.setdefault(name, _node(name)) + nuke.thisNode = lambda: node + return labeller.create_autolabel() + + +def whole_script_pass(labeller, clock, names): + """Every node requested back to back, as after a viewer input change.""" + return [request(labeller, clock, name, advance=1.0 if i == 0 else 0.0001) for i, name in enumerate(names)] + + +# --- lone requests --- + + +def test_first_request_builds_and_shows(labeller, clock): + assert request(labeller, clock, "Grade1", "gain 1.0") == "gain 1.0" + assert labeller.builds == ["Grade1"] + + +def test_unchanged_text_is_rebuilt_and_shown(labeller, clock): + request(labeller, clock, "Grade1", "gain 1.0") + assert request(labeller, clock, "Grade1", "gain 1.0") == "gain 1.0" + assert labeller.builds == ["Grade1", "Grade1"] + + +def test_changed_text_is_held_back_and_node_marked_stale(labeller, clock): + request(labeller, clock, "Grade1", "gain 1.0") + assert request(labeller, clock, "Grade1", "gain 1.5") == "gain 1.0" + assert "Grade1" in labeller._stale + assert labeller._refresh_timer.interval == 400 + + +def test_refresh_pokes_stale_node_and_releases_new_text(labeller, clock): + request(labeller, clock, "Grade1", "gain 1.0") + request(labeller, clock, "Grade1", "gain 1.5") + knob = labeller.nodes["Grade1"]["dope_sheet"] + clock.now += 1.0 + labeller._refresh_timer.fire() + assert knob.value() is False # flipped and flipped back + assert "Grade1" in labeller._forced + assert labeller._stale == set() + # the poke makes Nuke ask again; the forced build is shown + assert request(labeller, clock, "Grade1", "gain 1.5", advance=0.01) == "gain 1.5" + assert "Grade1" not in labeller._forced + + +def test_refresh_waits_while_traffic_continues(labeller, clock): + request(labeller, clock, "Grade1", "gain 1.0") + request(labeller, clock, "Grade1", "gain 1.5") + labeller._refresh_timer.interval = None + clock.now += 0.05 # fired too soon after the last request + labeller._refresh_timer.fire() + assert labeller.nodes["Grade1"]["dope_sheet"].value() is False + assert "Grade1" in labeller._stale + assert labeller._refresh_timer.interval == 400 # re-armed + + +def test_slider_drag_changes_text_once_at_the_end(labeller, clock): + request(labeller, clock, "Grade1", "gain 1.0") + shown = {request(labeller, clock, "Grade1", "gain {}".format(i), advance=0.016) for i in range(40)} + assert shown == {"gain 1.0"} # never a changed string mid-drag + clock.now += 1.0 + labeller._refresh_timer.fire() + assert request(labeller, clock, "Grade1", "gain 39", advance=0.01) == "gain 39" + + +# --- bursts --- + + +def test_whole_script_pass_is_served_from_cache(labeller, clock): + names = ["Grade{}".format(i) for i in range(30)] + for name in names: + request(labeller, clock, name, "text " + name) + labeller.builds = [] + assert whole_script_pass(labeller, clock, names) == ["text " + name for name in names] + # only the first few requests of a pass are built before it counts as a burst + assert len(labeller.builds) == labelmaker.LABEL_BURST_MIN + + +def test_uncached_nodes_in_a_pass_are_built(labeller, clock): + names = ["Grade{}".format(i) for i in range(30)] + for name in names[:20]: + request(labeller, clock, name, "old") + for name in names[20:]: + labeller.texts[name] = "new" + labeller.builds = [] + whole_script_pass(labeller, clock, names) + assert set(names[20:]) <= set(labeller.builds) + + +def test_small_burst_is_a_real_edit_and_gets_refreshed(labeller, clock): + names = ["Grade{}".format(i) for i in range(20)] + for name in names: + request(labeller, clock, name, "old") + whole_script_pass(labeller, clock, names) # 20 nodes changed by a script + request(labeller, clock, "Other", "x") # next lone request closes the burst + assert set(names[labelmaker.LABEL_BURST_MIN:]) <= labeller._stale + + +def test_large_burst_is_not_marked_stale(labeller, clock): + names = ["Grade{}".format(i) for i in range(labelmaker.LABEL_BURST_GENUINE_MAX + 50)] + for name in names: + request(labeller, clock, name, "old") + whole_script_pass(labeller, clock, names) + request(labeller, clock, "Other", "x") + assert labeller._stale == set() + + +def test_frame_dependent_node_on_new_frame_is_held_then_refreshed(labeller, clock, monkeypatch): + names = ["Grade{}".format(i) for i in range(20)] + for name in names: + request(labeller, clock, name, "old") + labeller._content["Grade15"] = (1, "old") # built on frame 1, animated + monkeypatch.setattr(nuke, "frame", lambda: 2) + labeller.builds = [] + whole_script_pass(labeller, clock, names) + assert "Grade15" not in labeller.builds + assert "Grade15" in labeller._stale + + +# --- invalidation --- + + +def test_destroyed_node_forgets_its_label(labeller, clock): + request(labeller, clock, "Grade1", "gain 1.0") + nuke.thisNode = lambda: labeller.nodes["Grade1"] + labeller._on_node_destroyed() + assert "Grade1" not in labeller._content and "Grade1" not in labeller._shown + # a new node reusing the name shows its own label immediately + assert request(labeller, clock, "Grade1", "gain 2.0") == "gain 2.0" + + +def test_setting_config_invalidates_cache(labeller, clock): + request(labeller, clock, "Grade1", "gain 1.0") + labeller.config = _EmptyConfig() + assert labeller._content == {} and labeller._shown == {} + + +def test_refresh_all_labels_pokes_every_node(labeller, clock, monkeypatch): + request(labeller, clock, "Grade1", "gain 1.0") + monkeypatch.setattr(nuke, "allNodes", lambda recurseGroups=False: list(labeller.nodes.values())) + labeller.refresh_all_labels() + assert labeller._content == {} + assert "Grade1" in labeller._forced + + +def test_poke_skips_nodes_without_dope_sheet(labeller, clock): + labeller.nodes["Viewer1"] = StubNode("Viewer", knobs={"name": StubKnob("name", "Viewer1")}) + labeller._poke_nodes(["Viewer1", "Missing"]) + assert labeller._forced == set() From 1527d28ba7073703676febe718fc1fb77a4a592c Mon Sep 17 00:00:00 2001 From: charlesangus Date: Sun, 13 Sep 2026 20:54:59 -0400 Subject: [PATCH 03/15] Address review: frame-dependence on raw label, un-pokeable nodes, name reuse, disable path - _frame_dependent tested the label knob after TCL substitution, so a [frame] label was cached as frame-independent; use the raw knob value - nodes without a dope_sheet knob (Viewer, Backdrop) cannot be poked, so a held-back string would never be released: rebuild and show them on every request instead of caching or holding - onCreate evicts cache entries under a created node's name, so a name freed by a rename or delete cannot hand a new node the old label - set_enabled(False) pokes every node so Nuke rebuilds them with its own autolabel instead of leaving Labelmaker's strings on screen; set_enabled(True) refreshes every label - stronger burst test, narrating test comments removed, dangling .profiling references point at the profiling-harness branch --- .gitignore | 2 +- labelmaker.py | 52 ++++++++++++---- labelmaker_prefs_dialog.py | 2 - tests/conftest.py | 2 + tests/test_label_cache.py | 121 ++++++++++++++++++++++++++++++++----- 5 files changed, 148 insertions(+), 31 deletions(-) diff --git a/.gitignore b/.gitignore index 9970823..566b14e 100644 --- a/.gitignore +++ b/.gitignore @@ -138,5 +138,5 @@ dmypy.json # Intermediate Markdown generated from the README for the User Guide PDF docs/.build/ -# Local profiling harness (see .profiling/README.md) +# Local profiling harness (lives on the profiling-harness branch) .profiling/ diff --git a/labelmaker.py b/labelmaker.py index 92fd3fd..282c315 100644 --- a/labelmaker.py +++ b/labelmaker.py @@ -16,7 +16,8 @@ # the script size (~70 ms at 3k nodes, ~200 ms at 10k), which is what makes # slider drags and scrubbing sluggish. So: answer bursts from a cache, never # return a changed string while the user is interacting, and release changed -# strings once label traffic has gone quiet (see .profiling results). +# strings once label traffic has gone quiet (measured with the harness on +# the profiling-harness branch). LABEL_BURST_GAP_S = 0.005 # requests closer together than this are one pass LABEL_BURST_MIN = 8 # requests before a pass counts as a burst LABEL_BURST_GENUINE_MAX = 200 # a burst this small is a real multi-node edit @@ -124,20 +125,29 @@ def config(self, config): def register_autolabel(self): nuke.addAutolabel(self.create_autolabel) - # fires once per deleted node, so a new node reusing the name never - # inherits the old one's cached label + # the cache is keyed by name, and a delete or a rename frees a name + # for a new node; both callbacks fire once per node (knobChanged + # would fire per pointer move while dragging a selection) + nuke.addOnCreate(self._on_node_created) nuke.addOnDestroy(self._on_node_destroyed) def unregister_autolabel(self): nuke.removeAutolabel(self.create_autolabel) + nuke.removeOnCreate(self._on_node_created) nuke.removeOnDestroy(self._on_node_destroyed) self.invalidate_labels() def set_enabled(self, enabled): if enabled: self.register_autolabel() + self.refresh_all_labels() else: self.unregister_autolabel() + # Nuke never re-requests a label on redraw, so without a poke + # every node keeps showing Labelmaker's string + self._poke_nodes( + [node.fullName() for node in nuke.allNodes(recurseGroups=True)], force=False + ) def _get_deoverlap_timer(self): if self._deoverlap_timer is None: @@ -158,10 +168,13 @@ def create_autolabel(self): now = time.perf_counter() self._note_stall(now) in_burst = self._track_burst(now) - full_name = nuke.thisNode().fullName() - cached = self._content.get(full_name) + node = nuke.thisNode() + full_name = node.fullName() + cached = self._content.get(full_name) if self._pokeable(node) else None if in_burst and cached is not None and full_name not in self._forced: - # a whole-script pass: nothing about this node changed + # served from the cache whether this is a whole-script pass or a + # genuine multi-node edit; the two are only told apart when the + # burst closes (_close_burst) self._burst["names"].append(full_name) frame, text = cached if frame is not None and frame != nuke.frame(): @@ -172,7 +185,7 @@ def create_autolabel(self): text = self._build_label() self._content[full_name] = (nuke.frame() if self._frame_dependent() else None, text) previous = self._shown.get(full_name) - if previous is not None and text != previous and not was_forced: + if previous is not None and text != previous and not was_forced and self._pokeable(node): # keep showing the old string; the idle refresh releases the new one self._mark_stale(full_name) return previous @@ -206,7 +219,12 @@ def _build_label(self): def _frame_dependent(self): # keys or an expression (indicator bits 1 and 2), or TCL in the label # knob: Nuke re-requests these on frame changes, so cache them per frame - return bool(self.indicators & 3) or "[" in self.node_label_value + return bool(self.indicators & 3) or "[" in self.node_label_raw + + def _pokeable(self, node): + # a held-back or cached string is only ever refreshed by a poke, so a + # node that cannot be poked must be rebuilt and shown on every request + return node.knob("dope_sheet") is not None def _note_stall(self, now): # the gap from handing Nuke a changed string to its next request is, @@ -266,7 +284,7 @@ def _refresh_stale_labels(self): self._stale.clear() self._poke_nodes(names) - def _poke_nodes(self, full_names): + def _poke_nodes(self, full_names, force=True): # Nothing in the API re-requests one node's label; a real knob change # does. Flipping dope_sheet and flipping it back in the same callback # yields exactly one relabel, no undo entry and no visible change. @@ -279,15 +297,21 @@ def _poke_nodes(self, full_names): knob = node.knob("dope_sheet") if knob is None: continue - self._forced.add(full_name) + if force: + self._forced.add(full_name) value = knob.value() knob.setValue(not value) knob.setValue(value) finally: nuke.Undo.enable() + def _on_node_created(self): + self._forget(nuke.thisNode().fullName()) + def _on_node_destroyed(self): - full_name = nuke.thisNode().fullName() + self._forget(nuke.thisNode().fullName()) + + def _forget(self, full_name): self._content.pop(full_name, None) self._shown.pop(full_name, None) self._stale.discard(full_name) @@ -515,10 +539,12 @@ def mix_line_creator(self): self.lines.append(mix_line) def label_readout_creator(self): - node_label_value = nuke.value("this.label", "") + # the raw knob is what tells TCL apart; once substituted, "[frame]" + # is just a number + self.node_label_raw = nuke.value("this.label", "") or "" + node_label_value = self.node_label_raw with contextlib.suppress(RuntimeError): node_label_value = nuke.tcl("subst", node_label_value) - self.node_label_value = node_label_value or "" if node_label_value != "" and node_label_value is not None: self.lines.append(node_label_value) diff --git a/labelmaker_prefs_dialog.py b/labelmaker_prefs_dialog.py index 806d89f..6952d34 100644 --- a/labelmaker_prefs_dialog.py +++ b/labelmaker_prefs_dialog.py @@ -143,8 +143,6 @@ def _on_accept(self): labelmaker_config.reload_composed_config() labelmaker.autolabeller_singleton.config = labelmaker_config.composed_config_singleton labelmaker.autolabeller_singleton.set_enabled(self.labelmaker_enabled_checkbox.isChecked()) - if self.labelmaker_enabled_checkbox.isChecked(): - labelmaker.autolabeller_singleton.refresh_all_labels() self.accept() diff --git a/tests/conftest.py b/tests/conftest.py index 619ff66..f31921d 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -34,6 +34,8 @@ class _StubMenuItem: _nuke_stub.thisNode = lambda: None _nuke_stub.toNode = lambda name: None _nuke_stub.frame = lambda: 1 +_nuke_stub.addOnCreate = lambda fn: None +_nuke_stub.removeOnCreate = lambda fn: None _nuke_stub.addOnDestroy = lambda fn: None _nuke_stub.removeOnDestroy = lambda fn: None _nuke_stub.expression = lambda expr: 0.0 diff --git a/tests/test_label_cache.py b/tests/test_label_cache.py index 42a3e63..5a5d2b9 100644 --- a/tests/test_label_cache.py +++ b/tests/test_label_cache.py @@ -46,8 +46,22 @@ def fire(self): self.callback() +class _RecordingKnob(StubKnob): + def __init__(self, name, value=None): + super().__init__(name, value) + self.sets = [] + + def setValue(self, value): + self.sets.append(value) + super().setValue(value) + + def _node(name): - return StubNode("Grade", knobs={"name": StubKnob("name", name), "dope_sheet": StubKnob("dope_sheet", False)}) + return StubNode("Grade", knobs={"name": StubKnob("name", name), "dope_sheet": _RecordingKnob("dope_sheet", False)}) + + +def _unpokeable_node(name): + return StubNode("Viewer", knobs={"name": StubKnob("name", name)}) @pytest.fixture @@ -70,7 +84,7 @@ def build(): name = nuke.thisNode().name() labeller.builds.append(name) labeller.indicators = 0 - labeller.node_label_value = "" + labeller.node_label_raw = "" return labeller.texts[name] monkeypatch.setattr(labeller, "_build_label", build) @@ -120,10 +134,11 @@ def test_refresh_pokes_stale_node_and_releases_new_text(labeller, clock): knob = labeller.nodes["Grade1"]["dope_sheet"] clock.now += 1.0 labeller._refresh_timer.fire() - assert knob.value() is False # flipped and flipped back + assert knob.sets == [True, False] assert "Grade1" in labeller._forced assert labeller._stale == set() - # the poke makes Nuke ask again; the forced build is shown + # the stub cannot emit the relabel request a poke causes in Nuke, so the + # tests simulate it with a lone request assert request(labeller, clock, "Grade1", "gain 1.5", advance=0.01) == "gain 1.5" assert "Grade1" not in labeller._forced @@ -132,17 +147,17 @@ def test_refresh_waits_while_traffic_continues(labeller, clock): request(labeller, clock, "Grade1", "gain 1.0") request(labeller, clock, "Grade1", "gain 1.5") labeller._refresh_timer.interval = None - clock.now += 0.05 # fired too soon after the last request + clock.now += 0.05 labeller._refresh_timer.fire() - assert labeller.nodes["Grade1"]["dope_sheet"].value() is False + assert labeller.nodes["Grade1"]["dope_sheet"].sets == [] assert "Grade1" in labeller._stale - assert labeller._refresh_timer.interval == 400 # re-armed + assert labeller._refresh_timer.interval == 400 def test_slider_drag_changes_text_once_at_the_end(labeller, clock): request(labeller, clock, "Grade1", "gain 1.0") shown = {request(labeller, clock, "Grade1", "gain {}".format(i), advance=0.016) for i in range(40)} - assert shown == {"gain 1.0"} # never a changed string mid-drag + assert shown == {"gain 1.0"} clock.now += 1.0 labeller._refresh_timer.fire() assert request(labeller, clock, "Grade1", "gain 39", advance=0.01) == "gain 39" @@ -157,7 +172,6 @@ def test_whole_script_pass_is_served_from_cache(labeller, clock): request(labeller, clock, name, "text " + name) labeller.builds = [] assert whole_script_pass(labeller, clock, names) == ["text " + name for name in names] - # only the first few requests of a pass are built before it counts as a burst assert len(labeller.builds) == labelmaker.LABEL_BURST_MIN @@ -176,9 +190,14 @@ def test_small_burst_is_a_real_edit_and_gets_refreshed(labeller, clock): names = ["Grade{}".format(i) for i in range(20)] for name in names: request(labeller, clock, name, "old") - whole_script_pass(labeller, clock, names) # 20 nodes changed by a script - request(labeller, clock, "Other", "x") # next lone request closes the burst - assert set(names[labelmaker.LABEL_BURST_MIN:]) <= labeller._stale + for name in names: + labeller.texts[name] = "new" + assert set(whole_script_pass(labeller, clock, names)) == {"old"} + request(labeller, clock, "Other", "x") + assert set(names) <= labeller._stale + clock.now += 1.0 + labeller._refresh_timer.fire() + assert all(request(labeller, clock, name, advance=0.01) == "new" for name in names) def test_large_burst_is_not_marked_stale(labeller, clock): @@ -194,7 +213,7 @@ def test_frame_dependent_node_on_new_frame_is_held_then_refreshed(labeller, cloc names = ["Grade{}".format(i) for i in range(20)] for name in names: request(labeller, clock, name, "old") - labeller._content["Grade15"] = (1, "old") # built on frame 1, animated + labeller._content["Grade15"] = (1, "old") monkeypatch.setattr(nuke, "frame", lambda: 2) labeller.builds = [] whole_script_pass(labeller, clock, names) @@ -202,6 +221,40 @@ def test_frame_dependent_node_on_new_frame_is_held_then_refreshed(labeller, cloc assert "Grade15" in labeller._stale +def test_tcl_in_the_label_knob_is_cached_per_frame(clock, monkeypatch): + labeller = AutolabelReplacement(_EmptyConfig()) + monkeypatch.setattr(nuke, "value", lambda path, default="": "[frame]" if path == "this.label" else default) + monkeypatch.setattr(nuke, "tcl", lambda *args: "1001") + monkeypatch.setattr(nuke, "frame", lambda: 1001) + nuke.thisNode = lambda: _node("Grade1") + assert labeller.create_autolabel() == "Grade1\n1001" + assert labeller._content["Grade1"] == (1001, "Grade1\n1001") + assert labeller.node_label_raw == "[frame]" + + +# --- nodes that cannot be poked --- + + +def test_node_without_dope_sheet_shows_changed_text_immediately(labeller, clock): + labeller.nodes["Viewer1"] = _unpokeable_node("Viewer1") + request(labeller, clock, "Viewer1", "input 1") + assert request(labeller, clock, "Viewer1", "input 2") == "input 2" + assert labeller._stale == set() + + +def test_node_without_dope_sheet_is_rebuilt_and_shown_in_a_burst(labeller, clock): + names = ["Grade{}".format(i) for i in range(20)] + ["Viewer1"] + labeller.nodes["Viewer1"] = _unpokeable_node("Viewer1") + for name in names: + request(labeller, clock, name, "old") + for name in names: + labeller.texts[name] = "new" + assert whole_script_pass(labeller, clock, names)[-1] == "new" + request(labeller, clock, "Other", "x") + assert "Viewer1" not in labeller._stale + assert set(names[:-1]) <= labeller._stale + + # --- invalidation --- @@ -210,10 +263,31 @@ def test_destroyed_node_forgets_its_label(labeller, clock): nuke.thisNode = lambda: labeller.nodes["Grade1"] labeller._on_node_destroyed() assert "Grade1" not in labeller._content and "Grade1" not in labeller._shown - # a new node reusing the name shows its own label immediately assert request(labeller, clock, "Grade1", "gain 2.0") == "gain 2.0" +def test_created_node_forgets_entries_left_under_its_name(labeller, clock): + request(labeller, clock, "Grade1", "gain 1.0") + labeller._stale.add("Grade1") + nuke.thisNode = lambda: _node("Grade1") + labeller._on_node_created() + assert "Grade1" not in labeller._content and "Grade1" not in labeller._shown + assert "Grade1" not in labeller._stale + assert request(labeller, clock, "Grade1", "gain 2.0") == "gain 2.0" + + +def test_register_hooks_node_creation_and_destruction(labeller, monkeypatch): + hooks = {} + for name in ("addOnCreate", "addOnDestroy", "removeOnCreate", "removeOnDestroy"): + monkeypatch.setattr(nuke, name, lambda fn, name=name: hooks.__setitem__(name, fn)) + labeller.register_autolabel() + assert hooks["addOnCreate"] == labeller._on_node_created + assert hooks["addOnDestroy"] == labeller._on_node_destroyed + labeller.unregister_autolabel() + assert hooks["removeOnCreate"] == labeller._on_node_created + assert hooks["removeOnDestroy"] == labeller._on_node_destroyed + + def test_setting_config_invalidates_cache(labeller, clock): request(labeller, clock, "Grade1", "gain 1.0") labeller.config = _EmptyConfig() @@ -229,6 +303,23 @@ def test_refresh_all_labels_pokes_every_node(labeller, clock, monkeypatch): def test_poke_skips_nodes_without_dope_sheet(labeller, clock): - labeller.nodes["Viewer1"] = StubNode("Viewer", knobs={"name": StubKnob("name", "Viewer1")}) + labeller.nodes["Viewer1"] = _unpokeable_node("Viewer1") labeller._poke_nodes(["Viewer1", "Missing"]) assert labeller._forced == set() + + +def test_disabling_pokes_every_node_without_forcing(labeller, clock, monkeypatch): + request(labeller, clock, "Grade1", "gain 1.0") + monkeypatch.setattr(nuke, "allNodes", lambda recurseGroups=False: list(labeller.nodes.values())) + labeller.set_enabled(False) + assert labeller.nodes["Grade1"]["dope_sheet"].sets == [True, False] + assert labeller._forced == set() + assert labeller._content == {} + + +def test_enabling_invalidates_and_forces_every_node(labeller, clock, monkeypatch): + request(labeller, clock, "Grade1", "gain 1.0") + monkeypatch.setattr(nuke, "allNodes", lambda recurseGroups=False: list(labeller.nodes.values())) + labeller.set_enabled(True) + assert labeller._content == {} + assert "Grade1" in labeller._forced From e661f423d8342a9f2b913befa941976ab2bac612 Mon Sep 17 00:00:00 2001 From: charlesangus Date: Mon, 14 Sep 2026 01:17:28 -0400 Subject: [PATCH 04/15] Verify cache-answered labels in the background instead of classifying bursts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A burst of label requests served from the cache was classified by size (> 200 = whole-script pass, left as served); a tool setting a knob on 1000 selected nodes produces the same burst, so 784 of them kept the old label indefinitely. Any classifier has that failure mode (a frame/viewer-change heuristic was tried and dropped: a bulk edit that also steps the frame is misclassified the same way). Now every label answered from the cache during a burst is re-composed in the background once label traffic is quiet: nuke.runIn() gives the label code its node context, the compose is read-only (the indicators knob write from Foundry's set_indicators only happens on a real request), 15 ms slices on a 0 ms timer back off while traffic resumes, and only labels whose text differs are poked. Labels that were frame-dependent at their last build are verified first and released as soon as that phase ends, since a frame step is what changes them. LABEL_BURST_GENUINE_MAX and the per-frame cache entries are gone. Measured with the harness on profiling-harness (5400f88), §11 of RESULTS-2026-09-13.md: correct on bulk edits of 100/1000/all nodes, with a frame change or viewer connect in the same callback, with deletes, renames, undo, set_enabled(False) or scriptClear before the refresh; slider drags stay at stock latency (16–20 ms/tick at 10k). Cost: ~0.4 s (3k) / 1.6 s (10k) of background compute after a whole-script pass on the profiling box, and frame-dependent readouts refresh ~1–2.5 s after a frame step instead of inside stock's UI freeze. --- README.md | 2 +- docs/user-guide.pdf | Bin 327009 -> 327283 bytes labelmaker.py | 164 ++++++++++++++++++++++++------ tests/conftest.py | 1 + tests/test_label_cache.py | 208 +++++++++++++++++++++++++++++++++----- 5 files changed, 316 insertions(+), 59 deletions(-) diff --git a/README.md b/README.md index b22be84..d8c75bd 100644 --- a/README.md +++ b/README.md @@ -175,6 +175,6 @@ If this bothers you, enable **Always Show All Labels** in the preferences. Nodes Labelmaker has been used on production scripts of substantial size without issue. Two things keep it responsive on large scripts: - Nuke periodically asks for every node's label again even though nothing changed (after connecting a Viewer, for example). Labelmaker answers those passes from a cache instead of rebuilding each label. -- Every time a node's label text changes, Nuke re-lays out the whole Node Graph, which on a 10 000-node script takes a noticeable fraction of a second. Labelmaker therefore never updates label text *while* you are dragging a slider or scrubbing the timeline: the readouts catch up once you pause for about half a second. Nodes you edit indirectly (through an expression link, or by wiring a mask input) update the next time Nuke asks for their label. +- Every time a node's label text changes, Nuke re-lays out the whole Node Graph, which on a 10 000-node script takes a noticeable fraction of a second. Labelmaker therefore never updates label text *while* you are dragging a slider or scrubbing the timeline: the readouts catch up once you pause (about half a second; a little longer on very large scripts). Labels that were answered from the cache are re-checked in the background afterwards, so anything else that changes many labels at once — a script or tool editing every selected node, say — is caught up the same way. Nodes you edit indirectly (through an expression link, or by wiring a mask input) update the next time Nuke asks for their label. If you do encounter performance problems, please open a GitHub issue and include the approximate node count and any node class that seems to be the culprit. diff --git a/docs/user-guide.pdf b/docs/user-guide.pdf index 74dd32ed94068476d74aff1f9ec77da3d67d0d3e..d2aba888eb5c95fd035c7f0b2bc2bc4a3634cae1 100644 GIT binary patch delta 10383 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 diff --git a/labelmaker.py b/labelmaker.py index 282c315..9562903 100644 --- a/labelmaker.py +++ b/labelmaker.py @@ -14,16 +14,29 @@ # followed by a frame step) that requests every node in one burst. Handing # Nuke a *changed* label string costs it a main-loop stall proportional to # the script size (~70 ms at 3k nodes, ~200 ms at 10k), which is what makes -# slider drags and scrubbing sluggish. So: answer bursts from a cache, never -# return a changed string while the user is interacting, and release changed -# strings once label traffic has gone quiet (measured with the harness on -# the profiling-harness branch). +# slider drags and scrubbing sluggish. So: answer bursts from the cache, never +# return a changed string while the user is interacting, and once label +# traffic has gone quiet verify every label that was answered from the cache +# (rebuild it in the background, in slices) and release the ones that +# differ. Nothing is inferred from a burst's size or cause: a bulk edit by a +# tool and a whole-script pass are served the same way and both converge +# (measured with the harness on the profiling-harness branch). LABEL_BURST_GAP_S = 0.005 # requests closer together than this are one pass LABEL_BURST_MIN = 8 # requests before a pass counts as a burst -LABEL_BURST_GENUINE_MAX = 200 # a burst this small is a real multi-node edit LABEL_REFRESH_MIN_S = 0.4 # quiet time before stale labels are released LABEL_REFRESH_MAX_S = 1.5 LABEL_STALL_FACTOR = 5.0 # wait at least this many measured stalls +LABEL_VERIFY_SLICE_S = 0.015 # background verification runs in slices this long, +LABEL_VERIFY_GAP_MS = 0 # returning to the event loop between them + +# nuke.runIn() evaluates one expression and returns nothing: the verified +# label comes back through this slot ([labeller] in, [labeller, text] out) +_verify_slot = [] +_VERIFY_CODE = "__import__('labelmaker')._verify_run()" + + +def _verify_run(): + _verify_slot.append(_verify_slot[0]._compose_label()) # from https://gist.github.com/anonymous/a802f51391163a2bf0e3 @@ -109,7 +122,11 @@ def __init__(self, config): self._shown = {} # {full_name: text} the string Nuke was last given self._forced = set() # full names whose next request must build and show the result self._stale = set() # full names shown with a string known to be out of date + self._verify = set() # full names answered from the cache, to be checked when idle + self._verify_first = set() # ... of which the frame-dependent ones, checked first + self._frame_dep = set() # full names whose last build read keys, expressions or [tcl] self._burst = {"t": 0.0, "n": 0, "names": []} + self._verify_timer = None # created lazily; PySide6 is not imported at module level self._stall_t = None # when a changed string was last handed to Nuke self._stall_ema = 0.05 # running estimate of Nuke's stall after a change self._refresh_timer = None # created lazily; PySide6 is not imported at module level @@ -172,18 +189,17 @@ def create_autolabel(self): full_name = node.fullName() cached = self._content.get(full_name) if self._pokeable(node) else None if in_burst and cached is not None and full_name not in self._forced: - # served from the cache whether this is a whole-script pass or a - # genuine multi-node edit; the two are only told apart when the - # burst closes (_close_burst) + # answered from the cache whatever the burst is (a whole-script + # pass or a bulk edit): the idle verification finds out whether + # the string is still right self._burst["names"].append(full_name) - frame, text = cached - if frame is not None and frame != nuke.frame(): - self._mark_stale(full_name) - return self._shown.get(full_name, text) + return self._shown.get(full_name, cached) was_forced = full_name in self._forced self._forced.discard(full_name) + self._verify.discard(full_name) text = self._build_label() - self._content[full_name] = (nuke.frame() if self._frame_dependent() else None, text) + self._content[full_name] = text + self._note_frame_dependence(full_name) previous = self._shown.get(full_name) if previous is not None and text != previous and not was_forced and self._pokeable(node): # keep showing the old string; the idle refresh releases the new one @@ -195,15 +211,7 @@ def create_autolabel(self): return text def _build_label(self): - self.update() - self.set_indicators() - self.name_line_creator() - self.file_line_creator() - self.channels_line_creator() - self.knob_readout_creator() - self.mix_line_creator() - self.label_readout_creator() - autolabel = "\n".join(self.lines) + autolabel = self._compose_label(write_indicators=True) new_line_count = autolabel.count('\n') + 1 old_line_count = self._line_counts.get(self.node_name) self._line_counts[self.node_name] = new_line_count @@ -216,10 +224,31 @@ def _build_label(self): self._get_deoverlap_timer().start() # restarts timer if already running return autolabel - def _frame_dependent(self): + def _compose_label(self, write_indicators=False): + """The label text for nuke.thisNode(); read-only unless asked to + update the indicators knob as Nuke's own autolabel does.""" + self.update() + if write_indicators: + self.set_indicators() + else: + self.compute_indicators() + self.name_line_creator() + self.file_line_creator() + self.channels_line_creator() + self.knob_readout_creator() + self.mix_line_creator() + self.label_readout_creator() + return "\n".join(self.lines) + + def _note_frame_dependence(self, full_name): # keys or an expression (indicator bits 1 and 2), or TCL in the label - # knob: Nuke re-requests these on frame changes, so cache them per frame - return bool(self.indicators & 3) or "[" in self.node_label_raw + # knob: these are the labels a frame change alters, so they are + # verified first after a pass (an ordering hint, not a gate) + indicators = getattr(self, "indicators", 0) + if bool(indicators & 3) or "[" in getattr(self, "node_label_raw", ""): + self._frame_dep.add(full_name) + else: + self._frame_dep.discard(full_name) def _pokeable(self, node): # a held-back or cached string is only ever refreshed by a poke, so a @@ -252,10 +281,9 @@ def _track_burst(self, now): def _close_burst(self): burst = self._burst - if LABEL_BURST_MIN < burst["n"] <= LABEL_BURST_GENUINE_MAX: - # too small for a whole-script pass: a real multi-node edit that - # was answered from the cache, so refresh those nodes - self._stale.update(burst["names"]) + if burst["names"]: + self._verify.update(burst["names"]) + self._verify_first.update(self._frame_dep.intersection(burst["names"])) self._arm_refresh() burst["n"] = 0 burst["names"] = [] @@ -276,14 +304,77 @@ def _arm_refresh(self): self._get_refresh_timer().start(int(self._refresh_window() * 1000)) def _refresh_stale_labels(self): - if time.perf_counter() - self._burst["t"] < self._refresh_window() * 0.9: + if self._busy(): self._arm_refresh() # still busy: wait for the traffic to end return self._close_burst() + if self._verify: + self._verify_slice() + return + self._release_stale() + + def _busy(self): + return time.perf_counter() - self._burst["t"] < self._refresh_window() * 0.9 + + def _release_stale(self): names = list(self._stale) self._stale.clear() self._poke_nodes(names) + def _get_verify_timer(self): + if self._verify_timer is None: + from PySide6 import QtCore + self._verify_timer = QtCore.QTimer() + self._verify_timer.setSingleShot(True) + self._verify_timer.timeout.connect(self._verify_slice) + return self._verify_timer + + def _verify_slice(self): + """Rebuild a slice of the cache-answered labels; queue the ones that + differ from what Nuke is showing. Yields to the event loop between + slices and backs off while label traffic resumes.""" + if self._busy(): + self._arm_refresh() + return + deadline = time.perf_counter() + LABEL_VERIFY_SLICE_S + had_first = bool(self._verify_first) + while self._verify and time.perf_counter() < deadline: + full_name = self._pop_verify() + text = self._compose_in_context(full_name) + if text is None: + continue + self._content[full_name] = text + self._note_frame_dependence(full_name) + if text != self._shown.get(full_name): + self._stale.add(full_name) + if self._verify: + if had_first and not self._verify_first: + # the likely-changed labels are done: release them now rather + # than after the whole script has been checked + self._release_stale() + self._get_verify_timer().start(LABEL_VERIFY_GAP_MS) + else: + self._release_stale() + + def _pop_verify(self): + if self._verify_first: + full_name = self._verify_first.pop() + self._verify.discard(full_name) + else: + full_name = self._verify.pop() + self._verify_first.discard(full_name) + return full_name + + def _compose_in_context(self, full_name): + """The label the build would produce for `full_name` right now, or + None if the node is gone. The label code reads nuke.thisNode() and + 'this.*' paths, so it has to run with the node as Nuke's context.""" + if nuke.toNode(full_name) is None: + return None + _verify_slot[:] = [self] + nuke.runIn(full_name, _VERIFY_CODE) + return _verify_slot[1] if len(_verify_slot) > 1 else None + def _poke_nodes(self, full_names, force=True): # Nothing in the API re-requests one node's label; a real knob change # does. Flipping dope_sheet and flipping it back in the same callback @@ -316,6 +407,9 @@ def _forget(self, full_name): self._shown.pop(full_name, None) self._stale.discard(full_name) self._forced.discard(full_name) + self._verify.discard(full_name) + self._verify_first.discard(full_name) + self._frame_dep.discard(full_name) def invalidate_labels(self): """Forget every cached label; nodes rebuild when Nuke next asks.""" @@ -323,6 +417,9 @@ def invalidate_labels(self): self._shown.clear() self._stale.clear() self._forced.clear() + self._verify.clear() + self._verify_first.clear() + self._frame_dep.clear() self._burst = {"t": 0.0, "n": 0, "names": []} def refresh_all_labels(self): @@ -342,6 +439,12 @@ def update(self): self.node_class = self.class_mappings.get(self.n.Class()) or self.n.Class() def set_indicators(self): + self.compute_indicators() + # a knob write: it dirties the node, so only a real label request + # does it (background verification must not touch the DAG) + nuke.knob("this.indicators", str(self.indicators)) + + def compute_indicators(self): # this function is copied from Foundry's autolabel.py and # is copyright Foundry, all rights reserved # seemingly more or less need to use this TCL code, as there doesn't @@ -354,7 +457,6 @@ def set_indicators(self): ind += 4 if int(nuke.numvalue("this.mix", 1)) < 1: ind += 16 - nuke.knob("this.indicators", str(ind)) self.indicators = ind def name_line_creator(self): diff --git a/tests/conftest.py b/tests/conftest.py index f31921d..687b2c3 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -34,6 +34,7 @@ class _StubMenuItem: _nuke_stub.thisNode = lambda: None _nuke_stub.toNode = lambda name: None _nuke_stub.frame = lambda: 1 +_nuke_stub.activeViewer = lambda: None _nuke_stub.addOnCreate = lambda fn: None _nuke_stub.removeOnCreate = lambda fn: None _nuke_stub.addOnDestroy = lambda fn: None diff --git a/tests/test_label_cache.py b/tests/test_label_cache.py index 5a5d2b9..1e3f1d4 100644 --- a/tests/test_label_cache.py +++ b/tests/test_label_cache.py @@ -80,15 +80,29 @@ def labeller(monkeypatch, clock): labeller.builds = [] # names built, in order labeller.nodes = {} + labeller.verified = [] # names verified in the background, in order + def build(): name = nuke.thisNode().name() labeller.builds.append(name) - labeller.indicators = 0 - labeller.node_label_raw = "" return labeller.texts[name] + def compose(): + name = nuke.thisNode().name() + labeller.verified.append(name) + return labeller.texts[name] + + def run_in(name, code): + # nuke.runIn: evaluate `code` with `name` as the current node + nuke.thisNode = lambda: labeller.nodes[name] + eval(code) + monkeypatch.setattr(labeller, "_build_label", build) + monkeypatch.setattr(labeller, "_compose_label", compose) monkeypatch.setattr(nuke, "toNode", lambda name: labeller.nodes.get(name)) + monkeypatch.setattr(nuke, "runIn", run_in, raising=False) + labeller._verify_timer = _FakeTimer() + labeller._verify_timer.connect(labeller._verify_slice) return labeller @@ -103,10 +117,24 @@ def request(labeller, clock, name, text=None, advance=1.0): def whole_script_pass(labeller, clock, names): - """Every node requested back to back, as after a viewer input change.""" + """Every node requested back to back (a viewer change, a frame step after + an edit, or a tool editing many nodes: the cache cannot tell).""" return [request(labeller, clock, name, advance=1.0 if i == 0 else 0.0001) for i, name in enumerate(names)] +def go_idle(labeller, clock): + """Label traffic stops: the refresh fires, verification runs to the end + (the frozen clock never exhausts a slice) and stale labels are poked.""" + clock.now += 1.0 + labeller._refresh_timer.fire() + while labeller._verify: + labeller._verify_timer.fire() + + +def pokes(labeller): + return {name for name, node in labeller.nodes.items() if node["dope_sheet"].sets} + + # --- lone requests --- @@ -186,53 +214,179 @@ def test_uncached_nodes_in_a_pass_are_built(labeller, clock): assert set(names[20:]) <= set(labeller.builds) -def test_small_burst_is_a_real_edit_and_gets_refreshed(labeller, clock): - names = ["Grade{}".format(i) for i in range(20)] +def test_cache_answered_labels_are_verified_when_idle(labeller, clock): + names = ["Grade{}".format(i) for i in range(30)] + for name in names: + request(labeller, clock, name, "text " + name) + whole_script_pass(labeller, clock, names) + request(labeller, clock, "Other", "x") + assert labeller._verify == set(names[labelmaker.LABEL_BURST_MIN:]) + go_idle(labeller, clock) + assert set(labeller.verified) == set(names[labelmaker.LABEL_BURST_MIN:]) + assert labeller._verify == set() + + +def test_unchanged_labels_are_not_poked_after_verification(labeller, clock): + names = ["Grade{}".format(i) for i in range(30)] + for name in names: + request(labeller, clock, name, "same") + whole_script_pass(labeller, clock, names) + go_idle(labeller, clock) + assert labeller._stale == set() + assert pokes(labeller) == set() + + +@pytest.mark.parametrize("count", [20, 250, 1000]) +def test_bulk_edit_of_any_size_is_served_old_then_verified_and_released(labeller, clock, count): + """A tool setting a knob on every selected node relabels them in one + burst that looks exactly like a whole-script pass.""" + names = ["Grade{}".format(i) for i in range(count)] for name in names: request(labeller, clock, name, "old") for name in names: labeller.texts[name] = "new" - assert set(whole_script_pass(labeller, clock, names)) == {"old"} - request(labeller, clock, "Other", "x") - assert set(names) <= labeller._stale + served = whole_script_pass(labeller, clock, names) + assert set(served[labelmaker.LABEL_BURST_MIN:]) == {"old"} + go_idle(labeller, clock) + assert pokes(labeller) >= set(names[labelmaker.LABEL_BURST_MIN:]) + assert all(request(labeller, clock, name, advance=0.01) == "new" for name in names) + assert labeller._stale == set() and labeller._verify == set() + + +def test_only_the_labels_that_changed_are_poked(labeller, clock): + names = ["Grade{}".format(i) for i in range(40)] + for name in names: + request(labeller, clock, name, "old") + labeller.texts["Grade20"] = "new" + labeller.texts["Grade30"] = "new" + whole_script_pass(labeller, clock, names) + go_idle(labeller, clock) + assert pokes(labeller) == {"Grade20", "Grade30"} + assert request(labeller, clock, "Grade20") == "new" + + +def test_verification_runs_in_slices_and_yields_between_them(labeller, clock, monkeypatch): + names = ["Grade{}".format(i) for i in range(30)] + for name in names: + request(labeller, clock, name, "old") + whole_script_pass(labeller, clock, names) + compose = labeller._compose_label + + def slow_compose(): + clock.now += labelmaker.LABEL_VERIFY_SLICE_S # each label exhausts the slice + return compose() + + monkeypatch.setattr(labeller, "_compose_label", slow_compose) clock.now += 1.0 labeller._refresh_timer.fire() - assert all(request(labeller, clock, name, advance=0.01) == "new" for name in names) + assert len(labeller.verified) == 1 + assert labeller._verify_timer.interval == labelmaker.LABEL_VERIFY_GAP_MS + labeller._verify_timer.fire() + assert len(labeller.verified) == 2 -def test_large_burst_is_not_marked_stale(labeller, clock): - names = ["Grade{}".format(i) for i in range(labelmaker.LABEL_BURST_GENUINE_MAX + 50)] +def test_verification_backs_off_while_label_traffic_resumes(labeller, clock): + names = ["Grade{}".format(i) for i in range(30)] for name in names: request(labeller, clock, name, "old") whole_script_pass(labeller, clock, names) - request(labeller, clock, "Other", "x") - assert labeller._stale == set() + clock.now += 1.0 + labeller._refresh_timer.fire() # verification done (frozen clock, one slice) + assert labeller._verify == set() + whole_script_pass(labeller, clock, names) + request(labeller, clock, "Other", "x", advance=0.1) # traffic 0.1 s ago + labeller.verified = [] + labeller._verify_timer.fire() + assert labeller.verified == [] # nothing verified while busy + assert labeller._refresh_timer.interval # waits for the traffic to end + go_idle(labeller, clock) + assert labeller._verify == set() and len(labeller.verified) == 30 - labelmaker.LABEL_BURST_MIN -def test_frame_dependent_node_on_new_frame_is_held_then_refreshed(labeller, clock, monkeypatch): - names = ["Grade{}".format(i) for i in range(20)] +def test_frame_dependent_labels_are_verified_first_and_released_early(labeller, clock, monkeypatch): + names = ["Grade{}".format(i) for i in range(40)] for name in names: request(labeller, clock, name, "old") - labeller._content["Grade15"] = (1, "old") - monkeypatch.setattr(nuke, "frame", lambda: 2) - labeller.builds = [] + labeller._frame_dep.update({"Grade20", "Grade30"}) # keys/expressions/[tcl] last time + labeller.texts["Grade20"] = "new" + labeller.texts["Grade9"] = "new" whole_script_pass(labeller, clock, names) - assert "Grade15" not in labeller.builds - assert "Grade15" in labeller._stale + request(labeller, clock, "Other", "x") # closes the burst + assert labeller._verify_first == {"Grade20", "Grade30"} + compose = labeller._compose_label + + def slow_compose(): + clock.now += labelmaker.LABEL_VERIFY_SLICE_S # one label per slice + return compose() + monkeypatch.setattr(labeller, "_compose_label", slow_compose) + clock.now += 1.0 + labeller._refresh_timer.fire() + labeller._verify_timer.fire() + assert set(labeller.verified) == {"Grade20", "Grade30"} # first two slices + assert pokes(labeller) == {"Grade20"} # released before the rest + while labeller._verify: + labeller._verify_timer.fire() + assert pokes(labeller) == {"Grade20", "Grade9"} -def test_tcl_in_the_label_knob_is_cached_per_frame(clock, monkeypatch): + +def test_frame_dependence_is_noted_from_the_build(clock, monkeypatch): + labeller = AutolabelReplacement(_EmptyConfig()) + monkeypatch.setattr(nuke, "expression", lambda expr: 1.0) # "keys" bit + nuke.thisNode = lambda: _node("Grade1") + labeller.create_autolabel() + assert "Grade1" in labeller._frame_dep + monkeypatch.setattr(nuke, "expression", lambda expr: 0.0) + clock.now += 1.0 + labeller.create_autolabel() + assert "Grade1" not in labeller._frame_dep + + +def test_node_deleted_before_verification_is_skipped(labeller, clock): + names = ["Grade{}".format(i) for i in range(30)] + for name in names: + request(labeller, clock, name, "old") + whole_script_pass(labeller, clock, names) + del labeller.nodes["Grade20"] + go_idle(labeller, clock) + assert "Grade20" not in labeller.verified + assert "Grade20" not in labeller._stale + + +def test_real_request_during_verification_drops_the_node_from_the_queue(labeller, clock): + names = ["Grade{}".format(i) for i in range(30)] + for name in names: + request(labeller, clock, name, "old") + whole_script_pass(labeller, clock, names) + request(labeller, clock, "Grade20", "new") # lone edit: built and held back + assert "Grade20" not in labeller._verify + assert "Grade20" in labeller._stale + + +def test_tcl_in_the_label_knob_is_composed_in_node_context(clock, monkeypatch): labeller = AutolabelReplacement(_EmptyConfig()) monkeypatch.setattr(nuke, "value", lambda path, default="": "[frame]" if path == "this.label" else default) monkeypatch.setattr(nuke, "tcl", lambda *args: "1001") - monkeypatch.setattr(nuke, "frame", lambda: 1001) nuke.thisNode = lambda: _node("Grade1") assert labeller.create_autolabel() == "Grade1\n1001" - assert labeller._content["Grade1"] == (1001, "Grade1\n1001") - assert labeller.node_label_raw == "[frame]" + assert labeller._content["Grade1"] == "Grade1\n1001" + + +def test_compose_in_context_runs_the_label_code_with_the_node_as_context(clock, monkeypatch): + labeller = AutolabelReplacement(_EmptyConfig()) + node = _node("Grade1") + seen = [] + def run_in(name, code): + seen.append(name) + nuke.thisNode = lambda: node + eval(code) -# --- nodes that cannot be poked --- + monkeypatch.setattr(nuke, "runIn", run_in, raising=False) + monkeypatch.setattr(nuke, "toNode", lambda name: node if name == "Grade1" else None) + assert labeller._compose_in_context("Grade1") == "Grade1" + assert seen == ["Grade1"] + assert labeller._compose_in_context("Gone") is None def test_node_without_dope_sheet_shows_changed_text_immediately(labeller, clock): @@ -251,8 +405,8 @@ def test_node_without_dope_sheet_is_rebuilt_and_shown_in_a_burst(labeller, clock labeller.texts[name] = "new" assert whole_script_pass(labeller, clock, names)[-1] == "new" request(labeller, clock, "Other", "x") - assert "Viewer1" not in labeller._stale - assert set(names[:-1]) <= labeller._stale + assert "Viewer1" not in labeller._verify + assert set(names[labelmaker.LABEL_BURST_MIN:-1]) <= labeller._verify # --- invalidation --- From 44b13df8a4ae903d891a7a25433f01314eb6f312 Mon Sep 17 00:00:00 2001 From: charlesangus Date: Tue, 15 Sep 2026 09:31:18 -0400 Subject: [PATCH 05/15] Drop a really-requested node from the priority verify set too A real request for a node removed it from _verify but left it in _verify_first, breaking the first-is-a-subset invariant and letting the idle verifier recompose a label Nuke had just built. --- labelmaker.py | 1 + tests/test_label_cache.py | 13 +++++++++++++ 2 files changed, 14 insertions(+) diff --git a/labelmaker.py b/labelmaker.py index 9562903..848a727 100644 --- a/labelmaker.py +++ b/labelmaker.py @@ -197,6 +197,7 @@ def create_autolabel(self): was_forced = full_name in self._forced self._forced.discard(full_name) self._verify.discard(full_name) + self._verify_first.discard(full_name) text = self._build_label() self._content[full_name] = text self._note_frame_dependence(full_name) diff --git a/tests/test_label_cache.py b/tests/test_label_cache.py index 1e3f1d4..5c69aff 100644 --- a/tests/test_label_cache.py +++ b/tests/test_label_cache.py @@ -363,6 +363,19 @@ def test_real_request_during_verification_drops_the_node_from_the_queue(labeller assert "Grade20" in labeller._stale +def test_real_request_during_verification_drops_the_node_from_verify_first(labeller, clock): + names = ["Grade{}".format(i) for i in range(30)] + for name in names: + request(labeller, clock, name, "old") + labeller._frame_dep.add("Grade20") + whole_script_pass(labeller, clock, names) + request(labeller, clock, "Grade20", "new") # lone edit: built and held back + assert "Grade20" not in labeller._verify + assert "Grade20" not in labeller._verify_first + go_idle(labeller, clock) + assert "Grade20" not in labeller.verified + + def test_tcl_in_the_label_knob_is_composed_in_node_context(clock, monkeypatch): labeller = AutolabelReplacement(_EmptyConfig()) monkeypatch.setattr(nuke, "value", lambda path, default="": "[frame]" if path == "this.label" else default) From a3995c1cb2c46b58250c2fe8688eb8bc00529e4a Mon Sep 17 00:00:00 2001 From: charlesangus Date: Tue, 15 Sep 2026 09:34:14 -0400 Subject: [PATCH 06/15] Keep verifying after one label fails to compose A label whose code raised during background verification took the rest of the queue with it: the exception escaped the slice before the next timer was scheduled. Failures are now logged per node and quarantined, and the remaining queue is always rescheduled. --- labelmaker.py | 43 ++++++++++++++++++++++++--------------- tests/test_label_cache.py | 28 +++++++++++++++++++++++++ 2 files changed, 55 insertions(+), 16 deletions(-) diff --git a/labelmaker.py b/labelmaker.py index 848a727..fddcc88 100644 --- a/labelmaker.py +++ b/labelmaker.py @@ -339,23 +339,34 @@ def _verify_slice(self): return deadline = time.perf_counter() + LABEL_VERIFY_SLICE_S had_first = bool(self._verify_first) - while self._verify and time.perf_counter() < deadline: - full_name = self._pop_verify() - text = self._compose_in_context(full_name) - if text is None: - continue - self._content[full_name] = text - self._note_frame_dependence(full_name) - if text != self._shown.get(full_name): - self._stale.add(full_name) - if self._verify: - if had_first and not self._verify_first: - # the likely-changed labels are done: release them now rather - # than after the whole script has been checked + try: + while self._verify and time.perf_counter() < deadline: + full_name = self._pop_verify() + try: + text = self._compose_in_context(full_name) + except Exception as exc: + # _pop_verify already dropped full_name from the queue, + # so it is quarantined rather than retried; it rebuilds + # normally on Nuke's next real request for it + nuke.warning( + "Labelmaker: could not verify {}: {}".format(full_name, exc) + ) + continue + if text is None: + continue + self._content[full_name] = text + self._note_frame_dependence(full_name) + if text != self._shown.get(full_name): + self._stale.add(full_name) + finally: + if self._verify: + if had_first and not self._verify_first: + # the likely-changed labels are done: release them now rather + # than after the whole script has been checked + self._release_stale() + self._get_verify_timer().start(LABEL_VERIFY_GAP_MS) + else: self._release_stale() - self._get_verify_timer().start(LABEL_VERIFY_GAP_MS) - else: - self._release_stale() def _pop_verify(self): if self._verify_first: diff --git a/tests/test_label_cache.py b/tests/test_label_cache.py index 5c69aff..df99b6f 100644 --- a/tests/test_label_cache.py +++ b/tests/test_label_cache.py @@ -353,6 +353,34 @@ def test_node_deleted_before_verification_is_skipped(labeller, clock): assert "Grade20" not in labeller._stale +def test_one_failing_composition_does_not_abandon_the_rest_of_the_queue(labeller, clock, monkeypatch): + names = ["Grade{}".format(i) for i in range(30)] + for name in names: + request(labeller, clock, name, "old") + failing = "Grade15" + for name in names[labelmaker.LABEL_BURST_MIN:]: + labeller.texts[name] = "new" + whole_script_pass(labeller, clock, names) + compose = labeller._compose_label + + def flaky_compose(): + if nuke.thisNode().name() == failing: + raise RuntimeError("boom") + return compose() + + monkeypatch.setattr(labeller, "_compose_label", flaky_compose) + warnings = [] + monkeypatch.setattr(nuke, "warning", lambda msg: warnings.append(msg)) + go_idle(labeller, clock) + expected = set(names[labelmaker.LABEL_BURST_MIN:]) - {failing} + assert set(labeller.verified) == expected + assert labeller._verify == set() + assert failing not in labeller._stale + assert pokes(labeller) == expected + assert len(warnings) == 1 + assert failing in warnings[0] + + def test_real_request_during_verification_drops_the_node_from_the_queue(labeller, clock): names = ["Grade{}".format(i) for i in range(30)] for name in names: From 70f719fdf1d0306922910534e2b4d6afd3ee1234 Mon Sep 17 00:00:00 2001 From: charlesangus Date: Tue, 15 Sep 2026 09:37:05 -0400 Subject: [PATCH 07/15] Release stale labels one node at a time _release_stale emptied _stale before poking anything, so a knob failure part-way through forgot every label still waiting. Each name now leaves _stale only once its own poke has been attempted, and a failing node is logged and dropped instead of aborting the batch. --- labelmaker.py | 38 +++++++++++++++++++++++--------------- tests/test_label_cache.py | 26 ++++++++++++++++++++++++++ 2 files changed, 49 insertions(+), 15 deletions(-) diff --git a/labelmaker.py b/labelmaker.py index fddcc88..87e7765 100644 --- a/labelmaker.py +++ b/labelmaker.py @@ -318,9 +318,7 @@ def _busy(self): return time.perf_counter() - self._burst["t"] < self._refresh_window() * 0.9 def _release_stale(self): - names = list(self._stale) - self._stale.clear() - self._poke_nodes(names) + self._poke_nodes(list(self._stale), on_attempted=self._stale.discard) def _get_verify_timer(self): if self._verify_timer is None: @@ -387,24 +385,34 @@ def _compose_in_context(self, full_name): nuke.runIn(full_name, _VERIFY_CODE) return _verify_slot[1] if len(_verify_slot) > 1 else None - def _poke_nodes(self, full_names, force=True): + def _poke_nodes(self, full_names, force=True, on_attempted=None): # Nothing in the API re-requests one node's label; a real knob change # does. Flipping dope_sheet and flipping it back in the same callback # yields exactly one relabel, no undo entry and no visible change. nuke.Undo.disable() try: for full_name in full_names: - node = nuke.toNode(full_name) - if node is None: - continue - knob = node.knob("dope_sheet") - if knob is None: - continue - if force: - self._forced.add(full_name) - value = knob.value() - knob.setValue(not value) - knob.setValue(value) + try: + node = nuke.toNode(full_name) + if node is None: + continue + knob = node.knob("dope_sheet") + if knob is None: + continue + if force: + self._forced.add(full_name) + value = knob.value() + knob.setValue(not value) + knob.setValue(value) + except Exception as exc: + # a node stuck this way must not block the rest of the + # batch, so it is dropped here rather than re-queued + nuke.warning( + "Labelmaker: could not refresh {}: {}".format(full_name, exc) + ) + finally: + if on_attempted is not None: + on_attempted(full_name) finally: nuke.Undo.enable() diff --git a/tests/test_label_cache.py b/tests/test_label_cache.py index df99b6f..dd34aa1 100644 --- a/tests/test_label_cache.py +++ b/tests/test_label_cache.py @@ -56,6 +56,11 @@ def setValue(self, value): super().setValue(value) +class _FailingKnob(_RecordingKnob): + def setValue(self, value): + raise RuntimeError("boom") + + def _node(name): return StubNode("Grade", knobs={"name": StubKnob("name", name), "dope_sheet": _RecordingKnob("dope_sheet", False)}) @@ -171,6 +176,27 @@ def test_refresh_pokes_stale_node_and_releases_new_text(labeller, clock): assert "Grade1" not in labeller._forced +def test_a_poke_that_fails_does_not_lose_the_other_stale_nodes(labeller, clock, monkeypatch): + names = ["Grade1", "Grade2", "Grade3"] + failing = "Grade2" + labeller.nodes[failing] = StubNode( + "Grade", + knobs={"name": StubKnob("name", failing), "dope_sheet": _FailingKnob("dope_sheet", False)}, + ) + for name in names: + request(labeller, clock, name, "old") + for name in names: + request(labeller, clock, name, "new") + warnings = [] + monkeypatch.setattr(nuke, "warning", lambda msg: warnings.append(msg)) + clock.now += 1.0 + labeller._refresh_timer.fire() + assert pokes(labeller) == {"Grade1", "Grade3"} + assert labeller._stale == set() + assert len(warnings) == 1 + assert failing in warnings[0] + + def test_refresh_waits_while_traffic_continues(labeller, clock): request(labeller, clock, "Grade1", "gain 1.0") request(labeller, clock, "Grade1", "gain 1.5") From 2a5215adceeef26d194f8127cb387a7cff444ed6 Mon Sep 17 00:00:00 2001 From: charlesangus Date: Tue, 15 Sep 2026 09:39:16 -0400 Subject: [PATCH 08/15] Restore the caller's Undo state after a poke batch The poke unconditionally re-enabled Undo, so a tool that had disabled it found it switched back on. On Nuke 17+ the prior state is read with nuke.Undo.disabled() and only re-enabled if it was enabled on entry; older Nukes keep the always-enable behaviour. --- labelmaker.py | 7 ++++++- tests/conftest.py | 18 ++++++++++++++++-- tests/test_label_cache.py | 29 +++++++++++++++++++++++++++++ 3 files changed, 51 insertions(+), 3 deletions(-) diff --git a/labelmaker.py b/labelmaker.py index 87e7765..50af103 100644 --- a/labelmaker.py +++ b/labelmaker.py @@ -389,6 +389,10 @@ def _poke_nodes(self, full_names, force=True, on_attempted=None): # Nothing in the API re-requests one node's label; a real knob change # does. Flipping dope_sheet and flipping it back in the same callback # yields exactly one relabel, no undo entry and no visible change. + # nuke.Undo.disabled() only exists from Nuke 17; without it there is + # no way to see a caller's prior state, so this always re-enables. + disabled = getattr(nuke.Undo, "disabled", None) + was_enabled = disabled() is False if disabled is not None else True nuke.Undo.disable() try: for full_name in full_names: @@ -414,7 +418,8 @@ def _poke_nodes(self, full_names, force=True, on_attempted=None): if on_attempted is not None: on_attempted(full_name) finally: - nuke.Undo.enable() + if was_enabled: + nuke.Undo.enable() def _on_node_created(self): self._forget(nuke.thisNode().fullName()) diff --git a/tests/conftest.py b/tests/conftest.py index 687b2c3..c6e615e 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -7,11 +7,20 @@ class _StubUndoManager: + def __init__(self): + self._disabled = False + self.calls = [] + def disable(self): - pass + self.calls.append("disable") + self._disabled = True def enable(self): - pass + self.calls.append("enable") + self._disabled = False + + def disabled(self): + return self._disabled class _StubMenu: @@ -138,6 +147,11 @@ def start(self, interval=None): sys.modules["PySide6.QtGui"] = _qtgui +@pytest.fixture(autouse=True) +def _reset_undo_stub(): + _nuke_stub.Undo = _StubUndoManager() + + @pytest.fixture def tmp_prefs_file(tmp_path): return str(tmp_path / "labelmaker_prefs.json") diff --git a/tests/test_label_cache.py b/tests/test_label_cache.py index dd34aa1..d79b5cb 100644 --- a/tests/test_label_cache.py +++ b/tests/test_label_cache.py @@ -197,6 +197,35 @@ def test_a_poke_that_fails_does_not_lose_the_other_stale_nodes(labeller, clock, assert failing in warnings[0] +def test_refresh_leaves_undo_enabled_when_it_was_enabled_on_entry(labeller, clock): + request(labeller, clock, "Grade1", "gain 1.0") + request(labeller, clock, "Grade1", "gain 1.5") + clock.now += 1.0 + labeller._refresh_timer.fire() + assert nuke.Undo.disabled() is False + assert nuke.Undo.calls == ["disable", "enable"] + + +def test_refresh_leaves_undo_disabled_when_it_was_already_disabled(labeller, clock): + request(labeller, clock, "Grade1", "gain 1.0") + request(labeller, clock, "Grade1", "gain 1.5") + nuke.Undo.disable() + nuke.Undo.calls = [] + clock.now += 1.0 + labeller._refresh_timer.fire() + assert nuke.Undo.disabled() is True + assert nuke.Undo.calls == ["disable"] + + +def test_refresh_still_enables_undo_on_nuke_versions_without_disabled_query(labeller, clock, monkeypatch): + request(labeller, clock, "Grade1", "gain 1.0") + request(labeller, clock, "Grade1", "gain 1.5") + monkeypatch.delattr(type(nuke.Undo), "disabled") + clock.now += 1.0 + labeller._refresh_timer.fire() + assert nuke.Undo.calls == ["disable", "enable"] + + def test_refresh_waits_while_traffic_continues(labeller, clock): request(labeller, clock, "Grade1", "gain 1.0") request(labeller, clock, "Grade1", "gain 1.5") From 566db2349c8a6fa0ed774a31f03d65335b12ffe5 Mon Sep 17 00:00:00 2001 From: charlesangus Date: Tue, 15 Sep 2026 09:43:32 -0400 Subject: [PATCH 09/15] Verify cached labels without running the label knob's Tcl MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Background verification recomposed each label in full, so [python] and Tcl in a label knob ran speculatively — and twice when the label had changed. Each real build now records a verify key (the composed lines plus the raw label-knob text) and the verifier compares keys instead: a difference pokes the node and the real build runs the Tcl once, as stock Nuke does; a change only in the Tcl's own result waits for the next real request. --- labelmaker.py | 71 ++++++++++++++++++++------------- tests/test_label_cache.py | 82 +++++++++++++++++++++++++++++++++------ 2 files changed, 114 insertions(+), 39 deletions(-) diff --git a/labelmaker.py b/labelmaker.py index 50af103..9703a5f 100644 --- a/labelmaker.py +++ b/labelmaker.py @@ -17,10 +17,11 @@ # slider drags and scrubbing sluggish. So: answer bursts from the cache, never # return a changed string while the user is interacting, and once label # traffic has gone quiet verify every label that was answered from the cache -# (rebuild it in the background, in slices) and release the ones that -# differ. Nothing is inferred from a burst's size or cause: a bulk edit by a -# tool and a whole-script pass are served the same way and both converge -# (measured with the harness on the profiling-harness branch). +# (recompose it in the background, in slices, without running any [tcl] in +# the label knob) and release the ones that differ. Nothing is inferred from +# a burst's size or cause: a bulk edit by a tool and a whole-script pass are +# served the same way and both converge (measured with the harness on the +# profiling-harness branch). LABEL_BURST_GAP_S = 0.005 # requests closer together than this are one pass LABEL_BURST_MIN = 8 # requests before a pass counts as a burst LABEL_REFRESH_MIN_S = 0.4 # quiet time before stale labels are released @@ -29,14 +30,16 @@ LABEL_VERIFY_SLICE_S = 0.015 # background verification runs in slices this long, LABEL_VERIFY_GAP_MS = 0 # returning to the event loop between them -# nuke.runIn() evaluates one expression and returns nothing: the verified -# label comes back through this slot ([labeller] in, [labeller, text] out) +# nuke.runIn() evaluates one expression and returns nothing: the verify key +# comes back through this slot ([labeller] in, [labeller, key] out) _verify_slot = [] _VERIFY_CODE = "__import__('labelmaker')._verify_run()" def _verify_run(): - _verify_slot.append(_verify_slot[0]._compose_label()) + labeller = _verify_slot[0] + labeller._compose_label(substitute_label=False) + _verify_slot.append(labeller.verify_key) # from https://gist.github.com/anonymous/a802f51391163a2bf0e3 @@ -118,13 +121,14 @@ def __init__(self, config): self._line_counts = {} # {node_name: int} last known line count per node self._pending_deoverlap = set() # node names whose height increased since last timer fire self._deoverlap_timer = None # created lazily; PySide6 is not imported at module level - self._content = {} # {full_name: (frame or None, text)} from the last real build + self._content = {} # {full_name: text} from the last real build + self._verify_key = {} # {full_name: key} of that build; what verification compares self._shown = {} # {full_name: text} the string Nuke was last given self._forced = set() # full names whose next request must build and show the result self._stale = set() # full names shown with a string known to be out of date self._verify = set() # full names answered from the cache, to be checked when idle self._verify_first = set() # ... of which the frame-dependent ones, checked first - self._frame_dep = set() # full names whose last build read keys, expressions or [tcl] + self._frame_dep = set() # full names whose last build read keys or expressions self._burst = {"t": 0.0, "n": 0, "names": []} self._verify_timer = None # created lazily; PySide6 is not imported at module level self._stall_t = None # when a changed string was last handed to Nuke @@ -200,6 +204,7 @@ def create_autolabel(self): self._verify_first.discard(full_name) text = self._build_label() self._content[full_name] = text + self._verify_key[full_name] = self.verify_key self._note_frame_dependence(full_name) previous = self._shown.get(full_name) if previous is not None and text != previous and not was_forced and self._pokeable(node): @@ -225,9 +230,11 @@ def _build_label(self): self._get_deoverlap_timer().start() # restarts timer if already running return autolabel - def _compose_label(self, write_indicators=False): + def _compose_label(self, write_indicators=False, substitute_label=True): """The label text for nuke.thisNode(); read-only unless asked to - update the indicators knob as Nuke's own autolabel does.""" + update the indicators knob as Nuke's own autolabel does. Either way + self.verify_key is left holding the label with the label knob's raw + text in place of its substitution.""" self.update() if write_indicators: self.set_indicators() @@ -238,15 +245,15 @@ def _compose_label(self, write_indicators=False): self.channels_line_creator() self.knob_readout_creator() self.mix_line_creator() - self.label_readout_creator() + self.label_readout_creator(substitute_label) return "\n".join(self.lines) def _note_frame_dependence(self, full_name): - # keys or an expression (indicator bits 1 and 2), or TCL in the label - # knob: these are the labels a frame change alters, so they are - # verified first after a pass (an ordering hint, not a gate) + # keys or an expression (indicator bits 1 and 2): these are the labels + # a frame change alters, so they are verified first after a pass (an + # ordering hint, not a gate) indicators = getattr(self, "indicators", 0) - if bool(indicators & 3) or "[" in getattr(self, "node_label_raw", ""): + if bool(indicators & 3): self._frame_dep.add(full_name) else: self._frame_dep.discard(full_name) @@ -329,9 +336,10 @@ def _get_verify_timer(self): return self._verify_timer def _verify_slice(self): - """Rebuild a slice of the cache-answered labels; queue the ones that - differ from what Nuke is showing. Yields to the event loop between - slices and backs off while label traffic resumes.""" + """Recompose a slice of the cache-answered labels; queue the ones + whose verify key differs from their last real build's. Yields to the + event loop between slices and backs off while label traffic + resumes.""" if self._busy(): self._arm_refresh() return @@ -341,7 +349,7 @@ def _verify_slice(self): while self._verify and time.perf_counter() < deadline: full_name = self._pop_verify() try: - text = self._compose_in_context(full_name) + key = self._compose_in_context(full_name) except Exception as exc: # _pop_verify already dropped full_name from the queue, # so it is quarantined rather than retried; it rebuilds @@ -350,11 +358,12 @@ def _verify_slice(self): "Labelmaker: could not verify {}: {}".format(full_name, exc) ) continue - if text is None: + if key is None: continue - self._content[full_name] = text self._note_frame_dependence(full_name) - if text != self._shown.get(full_name): + if key != self._verify_key.get(full_name): + # a changed [tcl] result alone is not seen here; that + # label waits for the next real request self._stale.add(full_name) finally: if self._verify: @@ -376,7 +385,7 @@ def _pop_verify(self): return full_name def _compose_in_context(self, full_name): - """The label the build would produce for `full_name` right now, or + """The verify key a build of `full_name` would record right now, or None if the node is gone. The label code reads nuke.thisNode() and 'this.*' paths, so it has to run with the node as Nuke's context.""" if nuke.toNode(full_name) is None: @@ -429,6 +438,7 @@ def _on_node_destroyed(self): def _forget(self, full_name): self._content.pop(full_name, None) + self._verify_key.pop(full_name, None) self._shown.pop(full_name, None) self._stale.discard(full_name) self._forced.discard(full_name) @@ -439,6 +449,7 @@ def _forget(self, full_name): def invalidate_labels(self): """Forget every cached label; nodes rebuild when Nuke next asks.""" self._content.clear() + self._verify_key.clear() self._shown.clear() self._stale.clear() self._forced.clear() @@ -665,10 +676,16 @@ def mix_line_creator(self): mix_line = "mix {:.3f}".format(float(mix)) self.lines.append(mix_line) - def label_readout_creator(self): - # the raw knob is what tells TCL apart; once substituted, "[frame]" - # is just a number + def label_readout_creator(self, substitute_label=True): self.node_label_raw = nuke.value("this.label", "") or "" + # the key keeps the knob's raw text so that comparing keys never has + # to run the [tcl] in it (that may have side effects, and stock Nuke + # runs it once per real label request) + self.verify_key = "\n".join( + self.lines + ([self.node_label_raw] if self.node_label_raw else []) + ) + if not substitute_label: + return node_label_value = self.node_label_raw with contextlib.suppress(RuntimeError): node_label_value = nuke.tcl("subst", node_label_value) diff --git a/tests/test_label_cache.py b/tests/test_label_cache.py index d79b5cb..ebd661f 100644 --- a/tests/test_label_cache.py +++ b/tests/test_label_cache.py @@ -81,7 +81,7 @@ def labeller(monkeypatch, clock): labeller = AutolabelReplacement(_EmptyConfig()) labeller._refresh_timer = _FakeTimer() labeller._refresh_timer.connect(labeller._refresh_stale_labels) - labeller.texts = {} # {node name: text the build returns} + labeller.texts = {} # {node name: text the build returns, and its verify key} labeller.builds = [] # names built, in order labeller.nodes = {} @@ -90,11 +90,13 @@ def labeller(monkeypatch, clock): def build(): name = nuke.thisNode().name() labeller.builds.append(name) + labeller.verify_key = labeller.texts[name] return labeller.texts[name] - def compose(): + def compose(substitute_label=True): name = nuke.thisNode().name() labeller.verified.append(name) + labeller.verify_key = labeller.texts[name] return labeller.texts[name] def run_in(name, code): @@ -327,9 +329,9 @@ def test_verification_runs_in_slices_and_yields_between_them(labeller, clock, mo whole_script_pass(labeller, clock, names) compose = labeller._compose_label - def slow_compose(): + def slow_compose(**kwargs): clock.now += labelmaker.LABEL_VERIFY_SLICE_S # each label exhausts the slice - return compose() + return compose(**kwargs) monkeypatch.setattr(labeller, "_compose_label", slow_compose) clock.now += 1.0 @@ -362,7 +364,7 @@ def test_frame_dependent_labels_are_verified_first_and_released_early(labeller, names = ["Grade{}".format(i) for i in range(40)] for name in names: request(labeller, clock, name, "old") - labeller._frame_dep.update({"Grade20", "Grade30"}) # keys/expressions/[tcl] last time + labeller._frame_dep.update({"Grade20", "Grade30"}) # keys/expressions last time labeller.texts["Grade20"] = "new" labeller.texts["Grade9"] = "new" whole_script_pass(labeller, clock, names) @@ -370,9 +372,9 @@ def test_frame_dependent_labels_are_verified_first_and_released_early(labeller, assert labeller._verify_first == {"Grade20", "Grade30"} compose = labeller._compose_label - def slow_compose(): + def slow_compose(**kwargs): clock.now += labelmaker.LABEL_VERIFY_SLICE_S # one label per slice - return compose() + return compose(**kwargs) monkeypatch.setattr(labeller, "_compose_label", slow_compose) clock.now += 1.0 @@ -395,6 +397,11 @@ def test_frame_dependence_is_noted_from_the_build(clock, monkeypatch): clock.now += 1.0 labeller.create_autolabel() assert "Grade1" not in labeller._frame_dep + # [tcl] in the label knob is not verified, so it is not a hint either + monkeypatch.setattr(nuke, "value", lambda path, default="": "[frame]" if path == "this.label" else default) + clock.now += 1.0 + labeller.create_autolabel() + assert "Grade1" not in labeller._frame_dep def test_node_deleted_before_verification_is_skipped(labeller, clock): @@ -418,10 +425,10 @@ def test_one_failing_composition_does_not_abandon_the_rest_of_the_queue(labeller whole_script_pass(labeller, clock, names) compose = labeller._compose_label - def flaky_compose(): + def flaky_compose(**kwargs): if nuke.thisNode().name() == failing: raise RuntimeError("boom") - return compose() + return compose(**kwargs) monkeypatch.setattr(labeller, "_compose_label", flaky_compose) warnings = [] @@ -459,13 +466,64 @@ def test_real_request_during_verification_drops_the_node_from_verify_first(label assert "Grade20" not in labeller.verified -def test_tcl_in_the_label_knob_is_composed_in_node_context(clock, monkeypatch): +def test_tcl_in_the_label_knob_runs_once_in_the_real_build_only(clock, monkeypatch): labeller = AutolabelReplacement(_EmptyConfig()) + node = _node("Grade1") monkeypatch.setattr(nuke, "value", lambda path, default="": "[frame]" if path == "this.label" else default) - monkeypatch.setattr(nuke, "tcl", lambda *args: "1001") - nuke.thisNode = lambda: _node("Grade1") + substitutions = [] + monkeypatch.setattr(nuke, "tcl", lambda *args: substitutions.append(args) or "1001") + monkeypatch.setattr(nuke, "runIn", lambda name, code: eval(code), raising=False) + monkeypatch.setattr(nuke, "toNode", lambda name: node) + nuke.thisNode = lambda: node assert labeller.create_autolabel() == "Grade1\n1001" assert labeller._content["Grade1"] == "Grade1\n1001" + assert labeller._verify_key["Grade1"] == "Grade1\n[frame]" + assert substitutions == [("subst", "[frame]")] + assert labeller._compose_in_context("Grade1") == "Grade1\n[frame]" + assert substitutions == [("subst", "[frame]")] # verification never substitutes + assert labeller._content["Grade1"] == "Grade1\n1001" + + +def test_verification_pokes_a_changed_label_knob_but_not_a_changed_tcl_result(clock, monkeypatch): + """The [tcl] output is not part of the verify key: a label whose only + change is what its [tcl] now yields waits for the next real request.""" + labeller = AutolabelReplacement(_EmptyConfig()) + labeller._refresh_timer = _FakeTimer() + labeller._refresh_timer.connect(labeller._refresh_stale_labels) + labeller._verify_timer = _FakeTimer() + labeller._verify_timer.connect(labeller._verify_slice) + names = ["Grade{}".format(i) for i in range(30)] + nodes = {name: _node(name) for name in names} + labels = {name: "[frame]" for name in names} + monkeypatch.setattr( + nuke, "value", + lambda path, default="": labels[nuke.thisNode().name()] if path == "this.label" else default, + ) + frame = ["1001"] + monkeypatch.setattr(nuke, "tcl", lambda *args: frame[0]) + + def run_in(name, code): + nuke.thisNode = lambda: nodes[name] + eval(code) + + monkeypatch.setattr(nuke, "runIn", run_in, raising=False) + monkeypatch.setattr(nuke, "toNode", lambda name: nodes.get(name)) + for name in names: + clock.now += 1.0 + nuke.thisNode = lambda name=name: nodes[name] + assert labeller.create_autolabel() == name + "\n1001" + frame[0] = "1002" + labels["Grade20"] = "[frame] v2" + for i, name in enumerate(names): + clock.now += 1.0 if i == 0 else 0.0001 + nuke.thisNode = lambda name=name: nodes[name] + labeller.create_autolabel() + go_idle(labeller, clock) + # the pass's first LABEL_BURST_MIN requests are real builds, which do run + # the [tcl] and see 1002; only Grade20 of the cache-answered rest is poked + built_in_pass = set(names[:labelmaker.LABEL_BURST_MIN]) + assert {name for name, node in nodes.items() if node["dope_sheet"].sets} == built_in_pass | {"Grade20"} + assert labeller._content["Grade20"] == "Grade20\n1001" # the poke's build replaces it def test_compose_in_context_runs_the_label_code_with_the_node_as_context(clock, monkeypatch): From bead3a6c4275ca60aedf332ccf367e74be596c5a Mon Sep 17 00:00:00 2001 From: charlesangus Date: Tue, 15 Sep 2026 09:50:02 -0400 Subject: [PATCH 10/15] Pin the contract that verification never runs label-knob Tcl Real builds substitute once each; a cache-served pass plus idle verification calls nuke.tcl zero times and pokes nothing when only the Tcl result would differ; a changed label knob pokes the node and its forced rebuild substitutes exactly once more. --- tests/test_label_cache.py | 97 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 97 insertions(+) diff --git a/tests/test_label_cache.py b/tests/test_label_cache.py index ebd661f..6239042 100644 --- a/tests/test_label_cache.py +++ b/tests/test_label_cache.py @@ -484,6 +484,20 @@ def test_tcl_in_the_label_knob_runs_once_in_the_real_build_only(clock, monkeypat assert labeller._content["Grade1"] == "Grade1\n1001" +def test_two_consecutive_real_builds_each_call_tcl_exactly_once(clock, monkeypatch): + labeller = AutolabelReplacement(_EmptyConfig()) + node = _node("Grade1") + monkeypatch.setattr(nuke, "value", lambda path, default="": "[frame]" if path == "this.label" else default) + calls = [] + monkeypatch.setattr(nuke, "tcl", lambda *args: calls.append(args) or "1001") + nuke.thisNode = lambda: node + assert labeller.create_autolabel() == "Grade1\n1001" + assert len(calls) == 1 + clock.now += 1.0 + assert labeller.create_autolabel() == "Grade1\n1001" + assert len(calls) == 2 + + def test_verification_pokes_a_changed_label_knob_but_not_a_changed_tcl_result(clock, monkeypatch): """The [tcl] output is not part of the verify key: a label whose only change is what its [tcl] now yields waits for the next real request.""" @@ -526,6 +540,89 @@ def run_in(name, code): assert labeller._content["Grade20"] == "Grade20\n1001" # the poke's build replaces it +def test_idle_verification_of_a_cache_served_pass_never_calls_tcl(clock, monkeypatch): + """Only the pass's LABEL_BURST_MIN burst-establishing requests are real + builds; the rest is cache-served, and go_idle's verification of those + must not run [tcl] at all -- so even a since-changed [tcl] result never + causes a poke.""" + labeller = AutolabelReplacement(_EmptyConfig()) + labeller._refresh_timer = _FakeTimer() + labeller._refresh_timer.connect(labeller._refresh_stale_labels) + labeller._verify_timer = _FakeTimer() + labeller._verify_timer.connect(labeller._verify_slice) + names = ["Grade{}".format(i) for i in range(30)] + nodes = {name: _node(name) for name in names} + monkeypatch.setattr(nuke, "value", lambda path, default="": "[frame]" if path == "this.label" else default) + frame = ["1001"] + calls = [] + monkeypatch.setattr(nuke, "tcl", lambda *args: calls.append(args) or frame[0]) + + def run_in(name, code): + nuke.thisNode = lambda: nodes[name] + eval(code) + + monkeypatch.setattr(nuke, "runIn", run_in, raising=False) + monkeypatch.setattr(nuke, "toNode", lambda name: nodes.get(name)) + for name in names: + clock.now += 1.0 + nuke.thisNode = lambda name=name: nodes[name] + labeller.create_autolabel() + calls.clear() + for i, name in enumerate(names): + clock.now += 1.0 if i == 0 else 0.0001 + nuke.thisNode = lambda name=name: nodes[name] + labeller.create_autolabel() + assert len(calls) == labelmaker.LABEL_BURST_MIN + calls.clear() + frame[0] = "1002" + go_idle(labeller, clock) + assert calls == [] + assert {name for name, node in nodes.items() if node["dope_sheet"].sets} == set() + + +def test_a_changed_label_knob_pokes_and_its_forced_rebuild_calls_tcl_once(clock, monkeypatch): + """Verification catching a changed label knob pokes the node; the + resulting forced real build must run [tcl] exactly once, not twice.""" + labeller = AutolabelReplacement(_EmptyConfig()) + labeller._refresh_timer = _FakeTimer() + labeller._refresh_timer.connect(labeller._refresh_stale_labels) + labeller._verify_timer = _FakeTimer() + labeller._verify_timer.connect(labeller._verify_slice) + names = ["Grade{}".format(i) for i in range(30)] + nodes = {name: _node(name) for name in names} + labels = {name: "[frame]" for name in names} + monkeypatch.setattr( + nuke, "value", + lambda path, default="": labels[nuke.thisNode().name()] if path == "this.label" else default, + ) + calls = [] + monkeypatch.setattr(nuke, "tcl", lambda *args: calls.append(args) or "1001") + + def run_in(name, code): + nuke.thisNode = lambda: nodes[name] + eval(code) + + monkeypatch.setattr(nuke, "runIn", run_in, raising=False) + monkeypatch.setattr(nuke, "toNode", lambda name: nodes.get(name)) + for name in names: + clock.now += 1.0 + nuke.thisNode = lambda name=name: nodes[name] + labeller.create_autolabel() + labels["Grade20"] = "[frame] v2" + for i, name in enumerate(names): + clock.now += 1.0 if i == 0 else 0.0001 + nuke.thisNode = lambda name=name: nodes[name] + labeller.create_autolabel() + calls.clear() + go_idle(labeller, clock) + assert calls == [] + assert {name for name, node in nodes.items() if node["dope_sheet"].sets} == {"Grade20"} + clock.now += 0.01 + nuke.thisNode = lambda: nodes["Grade20"] + assert labeller.create_autolabel() == "Grade20\n1001" + assert len(calls) == 1 + + def test_compose_in_context_runs_the_label_code_with_the_node_as_context(clock, monkeypatch): labeller = AutolabelReplacement(_EmptyConfig()) node = _node("Grade1") From ca80f868fd164e0abdde6a92a4e265071abdaf77 Mon Sep 17 00:00:00 2001 From: charlesangus Date: Tue, 15 Sep 2026 09:50:26 -0400 Subject: [PATCH 11/15] docs: label-knob Tcl runs only on real label requests Describe in the Performance section that the background re-check never executes Tcl or [python] in a node's label knob, so such labels catch up on the next real request; rebuild the User Guide. --- README.md | 2 +- docs/user-guide.pdf | Bin 327283 -> 327880 bytes 2 files changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index d8c75bd..6b8d63e 100644 --- a/README.md +++ b/README.md @@ -175,6 +175,6 @@ If this bothers you, enable **Always Show All Labels** in the preferences. Nodes Labelmaker has been used on production scripts of substantial size without issue. Two things keep it responsive on large scripts: - Nuke periodically asks for every node's label again even though nothing changed (after connecting a Viewer, for example). Labelmaker answers those passes from a cache instead of rebuilding each label. -- Every time a node's label text changes, Nuke re-lays out the whole Node Graph, which on a 10 000-node script takes a noticeable fraction of a second. Labelmaker therefore never updates label text *while* you are dragging a slider or scrubbing the timeline: the readouts catch up once you pause (about half a second; a little longer on very large scripts). Labels that were answered from the cache are re-checked in the background afterwards, so anything else that changes many labels at once — a script or tool editing every selected node, say — is caught up the same way. Nodes you edit indirectly (through an expression link, or by wiring a mask input) update the next time Nuke asks for their label. +- Every time a node's label text changes, Nuke re-lays out the whole Node Graph, which on a 10 000-node script takes a noticeable fraction of a second. Labelmaker therefore never updates label text *while* you are dragging a slider or scrubbing the timeline: the readouts catch up once you pause (about half a second; a little longer on very large scripts). Labels that were answered from the cache are re-checked in the background afterwards, so anything else that changes many labels at once — a script or tool editing every selected node, say — is caught up the same way. Nodes you edit indirectly (through an expression link, or by wiring a mask input) update the next time Nuke asks for their label, as does any TCL or `[python …]` in a node's *label* knob: that code runs only when Nuke asks for the label for real (once, as it does without Labelmaker), never during the background re-check, so a label whose only change is what its TCL now evaluates to catches up on the next real request. If you do encounter performance problems, please open a GitHub issue and include the approximate node count and any node class that seems to be the culprit. diff --git a/docs/user-guide.pdf b/docs/user-guide.pdf index d2aba888eb5c95fd035c7f0b2bc2bc4a3634cae1..1d42c980af3d740e07fe5a1fe845f21f0cec5408 100644 GIT binary patch delta 17125 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 From e7c6bf3f6f3411d7141bbd03820d51b08db6f195 Mon Sep 17 00:00:00 2001 From: charlesangus Date: Tue, 15 Sep 2026 10:28:30 -0400 Subject: [PATCH 12/15] Balance the poke's Undo disable/enable pair; Nuke counts them The real-Nuke harness showed nuke.Undo.disable()/enable() nest as a depth counter, so skipping enable() when Undo was already disabled (2a5215a) leaked one disable() per poke batch and left Undo dead after the caller's own enable(). One balanced pair is what preserves the caller's state; the unit stub now models the counter. --- labelmaker.py | 9 +++------ tests/conftest.py | 8 ++++---- tests/test_label_cache.py | 17 ++++------------- 3 files changed, 11 insertions(+), 23 deletions(-) diff --git a/labelmaker.py b/labelmaker.py index 9703a5f..673baf3 100644 --- a/labelmaker.py +++ b/labelmaker.py @@ -398,10 +398,8 @@ def _poke_nodes(self, full_names, force=True, on_attempted=None): # Nothing in the API re-requests one node's label; a real knob change # does. Flipping dope_sheet and flipping it back in the same callback # yields exactly one relabel, no undo entry and no visible change. - # nuke.Undo.disabled() only exists from Nuke 17; without it there is - # no way to see a caller's prior state, so this always re-enables. - disabled = getattr(nuke.Undo, "disabled", None) - was_enabled = disabled() is False if disabled is not None else True + # disable()/enable() nest as a depth counter, so one balanced pair + # restores the caller's state regardless of what it was on entry. nuke.Undo.disable() try: for full_name in full_names: @@ -427,8 +425,7 @@ def _poke_nodes(self, full_names, force=True, on_attempted=None): if on_attempted is not None: on_attempted(full_name) finally: - if was_enabled: - nuke.Undo.enable() + nuke.Undo.enable() def _on_node_created(self): self._forget(nuke.thisNode().fullName()) diff --git a/tests/conftest.py b/tests/conftest.py index c6e615e..71728ee 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -8,19 +8,19 @@ class _StubUndoManager: def __init__(self): - self._disabled = False + self.depth = 0 self.calls = [] def disable(self): self.calls.append("disable") - self._disabled = True + self.depth += 1 def enable(self): self.calls.append("enable") - self._disabled = False + self.depth = max(0, self.depth - 1) def disabled(self): - return self._disabled + return self.depth > 0 class _StubMenu: diff --git a/tests/test_label_cache.py b/tests/test_label_cache.py index 6239042..b7180de 100644 --- a/tests/test_label_cache.py +++ b/tests/test_label_cache.py @@ -199,32 +199,23 @@ def test_a_poke_that_fails_does_not_lose_the_other_stale_nodes(labeller, clock, assert failing in warnings[0] -def test_refresh_leaves_undo_enabled_when_it_was_enabled_on_entry(labeller, clock): +def test_refresh_balances_undo_depth_when_it_was_zero_on_entry(labeller, clock): request(labeller, clock, "Grade1", "gain 1.0") request(labeller, clock, "Grade1", "gain 1.5") clock.now += 1.0 labeller._refresh_timer.fire() - assert nuke.Undo.disabled() is False + assert nuke.Undo.depth == 0 assert nuke.Undo.calls == ["disable", "enable"] -def test_refresh_leaves_undo_disabled_when_it_was_already_disabled(labeller, clock): +def test_refresh_restores_callers_undo_depth_when_already_disabled(labeller, clock): request(labeller, clock, "Grade1", "gain 1.0") request(labeller, clock, "Grade1", "gain 1.5") nuke.Undo.disable() nuke.Undo.calls = [] clock.now += 1.0 labeller._refresh_timer.fire() - assert nuke.Undo.disabled() is True - assert nuke.Undo.calls == ["disable"] - - -def test_refresh_still_enables_undo_on_nuke_versions_without_disabled_query(labeller, clock, monkeypatch): - request(labeller, clock, "Grade1", "gain 1.0") - request(labeller, clock, "Grade1", "gain 1.5") - monkeypatch.delattr(type(nuke.Undo), "disabled") - clock.now += 1.0 - labeller._refresh_timer.fire() + assert nuke.Undo.depth == 1 assert nuke.Undo.calls == ["disable", "enable"] From bcdc0a3decadca7646dac01e7bf04e8b759a0ebb Mon Sep 17 00:00:00 2001 From: charlesangus Date: Tue, 15 Sep 2026 10:35:40 -0400 Subject: [PATCH 13/15] Release a held-back build as it is instead of rebuilding it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The first LABEL_BURST_MIN requests of a burst are built for real, held while the user interacts, then force-rebuilt at release — so their label-knob Tcl ran twice per edit (the harness measured 208 executions for a 200-node bulk edit; stock runs 200). A held build's _content is now served at the forced re-request; a node answered from the cache in the meantime is demoted and rebuilt as before, so verification-flagged labels are unaffected. Tcl now runs once per request for held labels too. --- labelmaker.py | 34 ++++++--- tests/test_label_cache.py | 147 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 172 insertions(+), 9 deletions(-) diff --git a/labelmaker.py b/labelmaker.py index 673baf3..a9ba686 100644 --- a/labelmaker.py +++ b/labelmaker.py @@ -18,10 +18,12 @@ # return a changed string while the user is interacting, and once label # traffic has gone quiet verify every label that was answered from the cache # (recompose it in the background, in slices, without running any [tcl] in -# the label knob) and release the ones that differ. Nothing is inferred from -# a burst's size or cause: a bulk edit by a tool and a whole-script pass are -# served the same way and both converge (measured with the harness on the -# profiling-harness branch). +# the label knob) and release the ones that differ. A build that was held +# back is shown as it is when released; only a label that was answered from +# the cache is rebuilt, so the label knob's [tcl] runs once per request, as +# it does in stock Nuke. Nothing is inferred from a burst's size or cause: a +# bulk edit by a tool and a whole-script pass are served the same way and +# both converge (measured with the harness on the profiling-harness branch). LABEL_BURST_GAP_S = 0.005 # requests closer together than this are one pass LABEL_BURST_MIN = 8 # requests before a pass counts as a burst LABEL_REFRESH_MIN_S = 0.4 # quiet time before stale labels are released @@ -124,8 +126,9 @@ def __init__(self, config): self._content = {} # {full_name: text} from the last real build self._verify_key = {} # {full_name: key} of that build; what verification compares self._shown = {} # {full_name: text} the string Nuke was last given - self._forced = set() # full names whose next request must build and show the result + self._forced = set() # full names whose next request must show its held build or rebuild self._stale = set() # full names shown with a string known to be out of date + self._fresh = set() # ... of which the ones whose _content is the build that was held back self._verify = set() # full names answered from the cache, to be checked when idle self._verify_first = set() # ... of which the frame-dependent ones, checked first self._frame_dep = set() # full names whose last build read keys or expressions @@ -197,18 +200,29 @@ def create_autolabel(self): # pass or a bulk edit): the idle verification finds out whether # the string is still right self._burst["names"].append(full_name) + # asked again without a build: a held build is not known to be + # current any more, so its release has to rebuild + self._fresh.discard(full_name) return self._shown.get(full_name, cached) was_forced = full_name in self._forced self._forced.discard(full_name) self._verify.discard(full_name) self._verify_first.discard(full_name) - text = self._build_label() - self._content[full_name] = text - self._verify_key[full_name] = self.verify_key - self._note_frame_dependence(full_name) + if was_forced and cached is not None and full_name in self._fresh: + # the poke only releases a string already built for real and held + # back; showing it needs no second build (stock runs the label + # knob's [tcl] once per request, and so does this) + text = cached + else: + text = self._build_label() + self._content[full_name] = text + self._verify_key[full_name] = self.verify_key + self._note_frame_dependence(full_name) + self._fresh.discard(full_name) previous = self._shown.get(full_name) if previous is not None and text != previous and not was_forced and self._pokeable(node): # keep showing the old string; the idle refresh releases the new one + self._fresh.add(full_name) self._mark_stale(full_name) return previous if text != previous: @@ -438,6 +452,7 @@ def _forget(self, full_name): self._verify_key.pop(full_name, None) self._shown.pop(full_name, None) self._stale.discard(full_name) + self._fresh.discard(full_name) self._forced.discard(full_name) self._verify.discard(full_name) self._verify_first.discard(full_name) @@ -449,6 +464,7 @@ def invalidate_labels(self): self._verify_key.clear() self._shown.clear() self._stale.clear() + self._fresh.clear() self._forced.clear() self._verify.clear() self._verify_first.clear() diff --git a/tests/test_label_cache.py b/tests/test_label_cache.py index b7180de..4703f67 100644 --- a/tests/test_label_cache.py +++ b/tests/test_label_cache.py @@ -239,6 +239,37 @@ def test_slider_drag_changes_text_once_at_the_end(labeller, clock): assert request(labeller, clock, "Grade1", "gain 39", advance=0.01) == "gain 39" + +def test_held_label_is_released_without_a_second_build(labeller, clock): + request(labeller, clock, "Grade1", "gain 1.0") + assert request(labeller, clock, "Grade1", "gain 1.5") == "gain 1.0" + assert "Grade1" in labeller._fresh + clock.now += 1.0 + labeller._refresh_timer.fire() + assert request(labeller, clock, "Grade1", advance=0.01) == "gain 1.5" + assert labeller.builds == ["Grade1", "Grade1"] + assert labeller._fresh == set() and labeller._forced == set() + + +def test_slider_drag_builds_once_per_request_and_not_again_on_release(labeller, clock): + request(labeller, clock, "Grade1", "gain 1.0") + for i in range(40): + request(labeller, clock, "Grade1", "gain {}".format(i), advance=0.016) + clock.now += 1.0 + labeller._refresh_timer.fire() + assert request(labeller, clock, "Grade1", advance=0.01) == "gain 39" + assert len(labeller.builds) == 41 + + +def test_held_label_edited_again_before_release_shows_the_latest_build(labeller, clock): + request(labeller, clock, "Grade1", "gain 1.0") + assert request(labeller, clock, "Grade1", "gain 1.5") == "gain 1.0" + assert request(labeller, clock, "Grade1", "gain 2.0") == "gain 1.0" + clock.now += 1.0 + labeller._refresh_timer.fire() + assert request(labeller, clock, "Grade1", advance=0.01) == "gain 2.0" + assert labeller.builds == ["Grade1"] * 3 + # --- bursts --- @@ -651,6 +682,101 @@ def test_node_without_dope_sheet_is_rebuilt_and_shown_in_a_burst(labeller, clock assert set(names[labelmaker.LABEL_BURST_MIN:-1]) <= labeller._verify + +def test_bulk_edit_builds_each_node_exactly_once(labeller, clock): + names = ["Grade{}".format(i) for i in range(30)] + for name in names: + request(labeller, clock, name, "old") + for name in names: + labeller.texts[name] = "new" + labeller.builds = [] + whole_script_pass(labeller, clock, names) + assert set(labeller._fresh) == set(names[:labelmaker.LABEL_BURST_MIN]) + go_idle(labeller, clock) + assert pokes(labeller) == set(names) + assert all(request(labeller, clock, name, advance=0.01) == "new" for name in names) + assert sorted(labeller.builds) == sorted(names) + assert labeller._fresh == set() + + +def test_held_label_served_from_cache_in_a_later_pass_is_rebuilt_on_release(labeller, clock): + names = ["Grade{}".format(i) for i in range(30)] + for name in names: + request(labeller, clock, name, "old") + assert request(labeller, clock, "Grade20", "new") == "old" # lone edit: held back + labeller.texts["Grade20"] = "newest" # a further edit the cache has not built + whole_script_pass(labeller, clock, names) + assert "Grade20" not in labeller._fresh + go_idle(labeller, clock) + assert "Grade20" in pokes(labeller) + labeller.builds = [] + assert request(labeller, clock, "Grade20", advance=0.01) == "newest" + assert labeller.builds == ["Grade20"] + + +def test_verification_flagged_label_is_rebuilt_on_release(labeller, clock): + names = ["Grade{}".format(i) for i in range(30)] + for name in names: + request(labeller, clock, name, "old") + labeller.texts["Grade20"] = "new" + whole_script_pass(labeller, clock, names) + go_idle(labeller, clock) + assert pokes(labeller) == {"Grade20"} + labeller.builds = [] + assert request(labeller, clock, "Grade20", advance=0.01) == "new" + assert labeller.builds == ["Grade20"] + + +def test_bulk_label_edit_runs_tcl_once_per_node_including_the_held_ones(clock, monkeypatch): + """A tool editing every node's label knob: the pass's first + LABEL_BURST_MIN requests are built for real and held back, the rest are + cache-served and caught by verification. Releasing them all must run + the [tcl] once per node in total, never a second time for the held + builds.""" + labeller = AutolabelReplacement(_EmptyConfig()) + labeller._refresh_timer = _FakeTimer() + labeller._refresh_timer.connect(labeller._refresh_stale_labels) + labeller._verify_timer = _FakeTimer() + labeller._verify_timer.connect(labeller._verify_slice) + names = ["Grade{}".format(i) for i in range(30)] + nodes = {name: _node(name) for name in names} + labels = {name: "[frame]" for name in names} + monkeypatch.setattr( + nuke, "value", + lambda path, default="": labels[nuke.thisNode().name()] if path == "this.label" else default, + ) + tcl_calls = [] + monkeypatch.setattr( + nuke, "tcl", + lambda *args: tcl_calls.append(nuke.thisNode().name()) or args[1].replace("[frame]", "1001"), + ) + + def run_in(name, code): + nuke.thisNode = lambda: nodes[name] + eval(code) + + monkeypatch.setattr(nuke, "runIn", run_in, raising=False) + monkeypatch.setattr(nuke, "toNode", lambda name: nodes.get(name)) + for name in names: + clock.now += 1.0 + nuke.thisNode = lambda name=name: nodes[name] + assert labeller.create_autolabel() == name + "\n1001" + tcl_calls.clear() + for name in names: + labels[name] = "[frame] v2" + for i, name in enumerate(names): + clock.now += 1.0 if i == 0 else 0.0001 + nuke.thisNode = lambda name=name: nodes[name] + assert labeller.create_autolabel() == name + "\n1001" + assert sorted(tcl_calls) == sorted(names[:labelmaker.LABEL_BURST_MIN]) + go_idle(labeller, clock) + assert {name for name, node in nodes.items() if node["dope_sheet"].sets} == set(names) + for name in names: + clock.now += 0.01 + nuke.thisNode = lambda name=name: nodes[name] + assert labeller.create_autolabel() == name + "\n1001 v2" + assert sorted(tcl_calls) == sorted(names) + # --- invalidation --- @@ -662,6 +788,27 @@ def test_destroyed_node_forgets_its_label(labeller, clock): assert request(labeller, clock, "Grade1", "gain 2.0") == "gain 2.0" +def test_destroyed_node_forgets_its_held_build(labeller, clock): + request(labeller, clock, "Grade1", "gain 1.0") + request(labeller, clock, "Grade1", "gain 1.5") + assert "Grade1" in labeller._fresh + nuke.thisNode = lambda: labeller.nodes["Grade1"] + labeller._on_node_destroyed() + assert labeller._fresh == set() + + +def test_invalidation_while_held_forces_a_real_build(labeller, clock, monkeypatch): + request(labeller, clock, "Grade1", "gain 1.0") + request(labeller, clock, "Grade1", "gain 1.5") + monkeypatch.setattr(nuke, "allNodes", lambda recurseGroups=False: list(labeller.nodes.values())) + labeller.refresh_all_labels() + assert labeller._fresh == set() + assert "Grade1" in labeller._forced + labeller.builds = [] + assert request(labeller, clock, "Grade1", advance=0.01) == "gain 1.5" + assert labeller.builds == ["Grade1"] + + def test_created_node_forgets_entries_left_under_its_name(labeller, clock): request(labeller, clock, "Grade1", "gain 1.0") labeller._stale.add("Grade1") From da3b88f373c80f9a821e66e2372933531be3bb40 Mon Sep 17 00:00:00 2001 From: charlesangus Date: Tue, 15 Sep 2026 12:34:58 -0400 Subject: [PATCH 14/15] Queue de-overlap when a taller label is shown, not when it is built De-overlap was queued from the build, so for a label held back during interaction the 150 ms timer fired while the node still showed its old, shorter text and found nothing to move; the release never re-queued it (the harness left 16 of 300 grown nodes overlapping). The line count is now tracked per shown string and the node queued when the shown text gains lines, which covers held builds and served-from-cache releases. --- labelmaker.py | 27 +++++++----- tests/test_label_cache.py | 86 ++++++++++++++++++++++++++++++++++++++- 2 files changed, 101 insertions(+), 12 deletions(-) diff --git a/labelmaker.py b/labelmaker.py index a9ba686..39e7fce 100644 --- a/labelmaker.py +++ b/labelmaker.py @@ -120,8 +120,8 @@ def __init__(self, config): } self.update_class_mappings_with_ofx_nodes() self.NAMELESS_NODES = ("Dot", "BackdropNode", "PostageStamp", "StickyNote") - self._line_counts = {} # {node_name: int} last known line count per node - self._pending_deoverlap = set() # node names whose height increased since last timer fire + self._line_counts = {} # {full_name: int} lines in the string Nuke was last given + self._pending_deoverlap = set() # node.name()s shown taller since the last timer fire self._deoverlap_timer = None # created lazily; PySide6 is not imported at module level self._content = {} # {full_name: text} from the last real build self._verify_key = {} # {full_name: key} of that build; what verification compares @@ -227,22 +227,26 @@ def create_autolabel(self): return previous if text != previous: self._stall_t = now + self._note_shown_height(node, full_name, text) self._shown[full_name] = text return text - def _build_label(self): - autolabel = self._compose_label(write_indicators=True) - new_line_count = autolabel.count('\n') + 1 - old_line_count = self._line_counts.get(self.node_name) - self._line_counts[self.node_name] = new_line_count + def _note_shown_height(self, node, full_name, text): + # queued from the show, not the build: de-overlap reads + # screenHeight(), which a held-back build has not changed yet + line_count = text.count('\n') + 1 + shown_count = self._line_counts.get(full_name) + self._line_counts[full_name] = line_count if ( - old_line_count is not None - and new_line_count > old_line_count + shown_count is not None + and line_count > shown_count and labelmaker_prefs.prefs_singleton.get("deoverlap_enabled") ): - self._pending_deoverlap.add(self.node_name) + self._pending_deoverlap.add(node.name()) self._get_deoverlap_timer().start() # restarts timer if already running - return autolabel + + def _build_label(self): + return self._compose_label(write_indicators=True) def _compose_label(self, write_indicators=False, substitute_label=True): """The label text for nuke.thisNode(); read-only unless asked to @@ -451,6 +455,7 @@ def _forget(self, full_name): self._content.pop(full_name, None) self._verify_key.pop(full_name, None) self._shown.pop(full_name, None) + self._line_counts.pop(full_name, None) self._stale.discard(full_name) self._fresh.discard(full_name) self._forced.discard(full_name) diff --git a/tests/test_label_cache.py b/tests/test_label_cache.py index 4703f67..9b4c6ee 100644 --- a/tests/test_label_cache.py +++ b/tests/test_label_cache.py @@ -30,6 +30,7 @@ def __call__(self): class _FakeTimer: def __init__(self): self.interval = None + self.started = False self.callback = None self.timeout = self @@ -39,8 +40,9 @@ def connect(self, fn): def setSingleShot(self, value): pass - def start(self, interval): + def start(self, interval=None): self.interval = interval + self.started = True def fire(self): self.callback() @@ -142,6 +144,26 @@ def pokes(labeller): return {name for name, node in labeller.nodes.items() if node["dope_sheet"].sets} +@pytest.fixture +def deoverlap(labeller, monkeypatch): + """The de-overlap pref on, its timer faked, and the real _build_label + back in place over a stubbed compose (the labeller fixture's build stub + skips it); returns the timer.""" + def compose(write_indicators=False, substitute_label=True): + name = nuke.thisNode().name() + labeller.node_name = name + labeller.builds.append(name) + labeller.verify_key = labeller.texts[name] + return labeller.texts[name] + + monkeypatch.delattr(labeller, "_build_label") + monkeypatch.setattr(labeller, "_compose_label", compose) + monkeypatch.setattr(labelmaker.labelmaker_prefs.prefs_singleton, "get", lambda key: True) + labeller._deoverlap_timer = _FakeTimer() + labeller._deoverlap_timer.connect(labeller._run_deoverlap) + return labeller._deoverlap_timer + + # --- lone requests --- @@ -777,6 +799,68 @@ def run_in(name, code): assert labeller.create_autolabel() == name + "\n1001 v2" assert sorted(tcl_calls) == sorted(names) +# --- de-overlap --- + + +def test_label_shown_taller_queues_deoverlap(labeller, clock, deoverlap): + labeller.nodes["Viewer1"] = _unpokeable_node("Viewer1") + request(labeller, clock, "Viewer1", "input 1") + assert labeller._pending_deoverlap == set() and not deoverlap.started + assert request(labeller, clock, "Viewer1", "input 1\nrgba\n1920x1080") == "input 1\nrgba\n1920x1080" + assert labeller._pending_deoverlap == {"Viewer1"} + assert deoverlap.started + + +def test_held_label_queues_deoverlap_when_shown_not_when_built(labeller, clock, deoverlap): + request(labeller, clock, "Grade1", "gain 1.0") + assert request(labeller, clock, "Grade1", "gain 1.0\ngamma 2\nlift 3\nmix 0.5") == "gain 1.0" + # the old, shorter string is still on screen: de-overlap would measure + # that and find nothing to move + assert labeller._pending_deoverlap == set() and not deoverlap.started + clock.now += 1.0 + labeller._refresh_timer.fire() + assert request(labeller, clock, "Grade1", advance=0.01) == "gain 1.0\ngamma 2\nlift 3\nmix 0.5" + assert labeller._pending_deoverlap == {"Grade1"} + assert deoverlap.started + + +def test_deoverlap_timer_fires_with_the_nodes_shown_taller(labeller, clock, deoverlap, monkeypatch): + seen = [] + monkeypatch.setattr(labelmaker.labelmaker_deoverlap, "deoverlap_from_nodes", seen.append) + labeller.nodes["Viewer1"] = _unpokeable_node("Viewer1") + request(labeller, clock, "Viewer1", "input 1") + request(labeller, clock, "Viewer1", "input 1\nrgba") + deoverlap.fire() + assert seen == [{"Viewer1"}] + assert labeller._pending_deoverlap == set() + + +def test_text_change_without_new_lines_queues_no_deoverlap(labeller, clock, deoverlap): + labeller.nodes["Viewer1"] = _unpokeable_node("Viewer1") + request(labeller, clock, "Viewer1", "input 1\nrgba") + request(labeller, clock, "Viewer1", "input 2\nrgb") + request(labeller, clock, "Viewer1", "input 2") + assert labeller._pending_deoverlap == set() and not deoverlap.started + + +def test_deoverlap_pref_off_queues_nothing(labeller, clock, deoverlap, monkeypatch): + monkeypatch.setattr(labelmaker.labelmaker_prefs.prefs_singleton, "get", lambda key: False) + labeller.nodes["Viewer1"] = _unpokeable_node("Viewer1") + request(labeller, clock, "Viewer1", "input 1") + request(labeller, clock, "Viewer1", "input 1\nrgba\n1920x1080") + assert labeller._pending_deoverlap == set() and not deoverlap.started + + +def test_destroyed_node_forgets_its_shown_height(labeller, clock, deoverlap): + labeller.nodes["Viewer1"] = _unpokeable_node("Viewer1") + request(labeller, clock, "Viewer1", "input 1") + nuke.thisNode = lambda: labeller.nodes["Viewer1"] + labeller._on_node_destroyed() + assert "Viewer1" not in labeller._line_counts + request(labeller, clock, "Viewer1", "input 1\nrgba") + assert labeller._pending_deoverlap == set() and not deoverlap.started + + # --- invalidation --- From 94fff9482eef1cb97365a6a1f93d5d501be3e3e4 Mon Sep 17 00:00:00 2001 From: charlesangus Date: Tue, 15 Sep 2026 13:42:42 -0400 Subject: [PATCH 15/15] De-overlap: resolve pushers against where a node will land MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A pusher already pushed entirely below a node's original slot was skipped, so the last, shortest node of a grown column (a Dot under a Merge) was pushed to the previous pusher's bottom — exactly the skipped pusher's new top — and landed on it. The harness left 15 such pairs after a 300-node bulk edit. Pushers are now walked top-down against the node's landing position; a pusher that jumped past a bystander still leaves it alone. --- labelmaker_deoverlap.py | 24 ++++---- tests/test_deoverlap.py | 118 +++++++++++++++++++++++++++++++++++++++- 2 files changed, 130 insertions(+), 12 deletions(-) diff --git a/labelmaker_deoverlap.py b/labelmaker_deoverlap.py index 3553ed9..d4dcf9d 100644 --- a/labelmaker_deoverlap.py +++ b/labelmaker_deoverlap.py @@ -62,7 +62,7 @@ def deoverlap_from_nodes(source_node_names): for node_name in sorted_node_names: node_bbox = position_cache[node_name] - max_pusher_bottom = None + pushers = [] for pusher_name in dirty_names: if pusher_name == node_name: continue @@ -76,17 +76,19 @@ def deoverlap_from_nodes(source_node_names): # No actual overlap: the pusher's bottom is above this node. if pusher_bbox[3] < node_bbox[1]: continue - # No actual overlap: the pusher was pushed entirely below - # this node earlier in the sweep. - if pusher_bbox[1] > node_bbox[3]: - continue - if max_pusher_bottom is None or pusher_bbox[3] > max_pusher_bottom: - max_pusher_bottom = pusher_bbox[3] - - if max_pusher_bottom is None: - continue + pushers.append((pusher_bbox[1], pusher_bbox[3])) + + # Pushers are taken top-down against where the node will land, + # not where it started: a pusher already pushed entirely below + # the node's original slot still pushes it if the other pushers + # would land the node on top of it. + height = node_bbox[3] - node_bbox[1] + required_top = node_bbox[1] + for pusher_top, pusher_bottom in sorted(pushers): + if pusher_top > required_top + height: + break + required_top = max(required_top, pusher_bottom + MINIMUM_GAP) - required_top = max_pusher_bottom + MINIMUM_GAP if required_top > node_bbox[1]: push_amount = required_top - node_bbox[1] node_bbox[1] += push_amount diff --git a/tests/test_deoverlap.py b/tests/test_deoverlap.py index d4efe70..b745d70 100644 --- a/tests/test_deoverlap.py +++ b/tests/test_deoverlap.py @@ -1,5 +1,12 @@ -from labelmaker_deoverlap import MINIMUM_GAP, _bboxes_overlap, _bboxes_overlap_horizontally +import nuke + +from labelmaker_deoverlap import ( + MINIMUM_GAP, + _bboxes_overlap, + _bboxes_overlap_horizontally, + deoverlap_from_nodes, +) # --- _bboxes_overlap --- # bbox format: [left, top, right, bottom] @@ -86,3 +93,112 @@ def test_horizontal_overlap_ignores_y(): def test_minimum_gap_is_positive(): assert MINIMUM_GAP > 0 + + +# --- deoverlap_from_nodes --- + + +class _FakeNode: + def __init__(self, name, x, y, width, height, node_class="Blur"): + self._name = name + self._x = x + self._y = y + self._width = width + self._height = height + self._class = node_class + + def name(self): + return self._name + + def Class(self): + return self._class + + def xpos(self): + return self._x + + def ypos(self): + return self._y + + def screenWidth(self): + return self._width + + def screenHeight(self): + return self._height + + def setYpos(self, y): + self._y = y + + def set_height(self, height): + self._height = height + + +def _bbox(node): + return [ + node.xpos(), + node.ypos(), + node.xpos() + node.screenWidth(), + node.ypos() + node.screenHeight(), + ] + + +def _no_overlaps(nodes): + return all( + not _bboxes_overlap(_bbox(a), _bbox(b)) + for i, a in enumerate(nodes) + for b in nodes[i + 1:] + ) + + +def test_cascade_does_not_land_a_short_node_on_the_last_pusher(monkeypatch): + grown = [_FakeNode(f"grown_{i}", 0, i * 90, 100, 110) for i in range(8)] + dot = _FakeNode("dot", 0, 720, 100, 12, node_class="Dot") + nodes = grown + [dot] + monkeypatch.setattr(nuke, "allNodes", lambda: nodes) + + deoverlap_from_nodes([n.name() for n in grown]) + + assert _no_overlaps(nodes) + last = grown[-1] + assert dot.ypos() == last.ypos() + last.screenHeight() + MINIMUM_GAP + + +def test_two_fires_leave_no_overlap(monkeypatch): + nodes = [_FakeNode(f"n{i}", 0, i * 90, 100, 30) for i in range(8)] + monkeypatch.setattr(nuke, "allNodes", lambda: nodes) + + for node in nodes[:4]: + node.set_height(110) + deoverlap_from_nodes([n.name() for n in nodes[:4]]) + + for node in nodes[4:]: + node.set_height(110) + deoverlap_from_nodes([n.name() for n in nodes[4:]]) + + assert _no_overlaps(nodes) + + +def test_pusher_that_jumped_past_a_bystander_leaves_it_alone(monkeypatch): + pusher = _FakeNode("P", 0, 0, 50, 200) + wide = _FakeNode("X", 0, 10, 300, 20) + dot = _FakeNode("Dot", 100, 50, 50, 10) + nodes = [pusher, wide, dot] + monkeypatch.setattr(nuke, "allNodes", lambda: nodes) + + deoverlap_from_nodes(["P"]) + + assert dot.ypos() == 50 + + +def test_side_by_side_and_preexisting_overlaps_untouched(monkeypatch): + grown = _FakeNode("G", 0, 0, 50, 110) + side = _FakeNode("Side", 200, 20, 50, 50) + a = _FakeNode("A", 500, 1000, 50, 50) + b = _FakeNode("B", 500, 1020, 50, 50) + nodes = [grown, side, a, b] + monkeypatch.setattr(nuke, "allNodes", lambda: nodes) + + deoverlap_from_nodes(["G"]) + + assert side.ypos() == 20 + assert a.ypos() == 1000 + assert b.ypos() == 1020