From 94e313b458ac71582b27c0aea9d4cc182f5af253 Mon Sep 17 00:00:00 2001 From: charlesangus Date: Tue, 1 Sep 2026 17:52:54 -0400 Subject: [PATCH 1/4] 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 2/4] 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 3/4] 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 4/4] 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 zcmaiaMN}QovTSg7cXxMpcX#*T?s9Ms!NLK86Wrb1or4As5L^!K@^k;4y!R)6(rXWT zuvb-AckNz#_XX?r4J$MfA`&tZsu?;+4Zy+4#Vaa`=;`HQW8sYGx1Os%F}uNw-h0Gw z2dUex1&vIG$PE*~^v5Vg%6dfPe*Up9y~$m+@r@2Xo~$K$$e_<4^Q3P=t@iV%jIh*j zy}SA3+Li7N^!Y*X`FSB4{E0+4gt z@A`hAYIa0T-tD=VfdQ%KZ@3vhd_xCO@Aw0MhzDA7h3trA;vA=q! zqSkYT6mmHLTsQxE<}!?C!ca{tz#D8OoCcHJ{e;G4z}_UTOZLi+cg5UPp=`$yq|ErF4h`T~Iyv!BgIj=$ zQZHtX2WKJNd$^}GX=4u?a%v+RGW-55o|$hhdAk6c4L1`KBdVCmMoU=cq2awM1ztI- zw1D9`{;WKN7wh-j6n+#iajaoe87ggX&L61A`jMlyps@5E2)9cZVUgDIMRnnI z)QPQXunAL+bRFXMIKCr@Js{52Yh-sb^G|a?Z05L%j^drLsa=U2sUlNB<4WB0mjJ$- z`RvM{q!(PQ_0lHwf<|dP{LSO$!<@r)ATb&)waz%2weX8Vz<3{&tCCbcYw2*AIkxf^ zZoyDqvZRpN@8No{l?+g3aC?o?f>hcCLe+yZqRd}dR+rz1R*zW;5EEvc9~d;MZDu(& zox-`LQ;8DvPSe=JD%jRf=qLDkcl-V*r?aSf9Ej=Hz$G+7E!ZEf1rx%2xBTzsT!hR% zgK6UzSCX+OfO8wkZo4`NFC~=&!BSs+5!hjuNc%_IzztkKVw1nY%WaMLsnp?m>mRd% z1A1N;j0w?K!v$N?ul3lmYrn28;g9f@Y=f6c?Xs~LG2|i2T+Y0#gzJMWRR!+R7Q+;h z#4?k41tiOp<#w8NM!=2x6ZV|tqw(;J5_HKo@m#-C9Qkg~Z`kViz;!QQw15ORe=>#Y z^31QclFWmGuVm~U>Yf{)h|UXnFw8|7>|C zAT`GbcI+=#UHOz_NgCWMr?8aGihPA8a?WT%qkm5#vm?u6LSD0kPb?s!%(ercAK$u} z71zEEfAYrbv8R@%4fYq%4uvhv`z#bwV33gEb%=qp9^T%D@9@sTp7M|Kt*);FgiW;P zs8v?*zLp7bodGuP#jPj{2F}ll>4r8m9e|hpe_@(7<*+Y~F7x;jZ)%Glet)jo4{y0f8_v)5d)C&cQTqYDDC6rC z(r56mUf)LiFlafG??yT%2}Q=hR+8ntwzIQ{#M907(TSG64#On{)*6%|eI93#)VH?F zN(z1KE(gClL5+m>vr9Gqx98T4M-v-B@sm3naKr9^R4i#jzDjnsUUVBygQLTk>~dA3 zb0IIV#TeEho(rZg>9Us8H2Z^E>=4jrOjX;0cYmcp{FXoDj4xV(B~-~i?n%ZP-rec? zS5Uj9ebSU#KYB5(>cA)9M%?Lk$6vN>vE#$2V@=pl<99<~S2&j~T0 zmbQ|&2iY8EgSbA3nJ&#gCC)J`KMoWQ<7Va=@BcS7lwL$4GQ%d3V-4}s48`r?mmSf6 zh(weprtdnzgmbEdH)6Mxsh2986L6^$AoQs6k$b5}aMCNak<%|y(<`wTBa8eb7B0y{ zMliG-mA)V-tw*=HWEL$F!t9E|aChoR*-Hik@|x9!ORX9HcK7u@KdEWEYcS ziM7upc}8X$97gW?^{GO;ZjQ^eUnc~<54t}3_HO$7<#4lEG^FO;a#m=`@Ok^Kkj$3t zGX$cA>&@=JET8NC6As7!A~}7_VV@m63jB`Ud9JDGG?hvSAH9^c#=NmrqDt1^@MB5+ zvw$DZ|Fy*%^`tQjT2!G}z>Yghr*-ZzSSW6pjBlwVcevt$$c}Ajd0ChEqT?ch9#_Rk zjr!BbcNxFBan<2X$6L3$@n-B%>iOyR-uW0%DT88S2)r~6;wtLtWGpeE21#ZEISmL48NmBuWLi{2jD zKAT0;{!>cY04Ih4DKEY-a_V(;3VS6~H1ib&#$g@aLrPb(Er5Js_+Xpoi*L?#6D6DS z7SiWNYk*o0|7vNYmm^p=jUQCPYlz%teGJN}$aW&~O!bz0(C!3%(-8i!1r5O2noqcNEh@)mS8us7MXd6u|SALc5pq>C`Kr z>#36iW(7R4;bNyGay(H}3vl2CtmqT$>N(tDX2ZM2z~r0q;h38ewGziln4y+(@d!AS zNfD>eB zvWxaFPTQ6L zAqN|%Vg$_#P;p%sM~?=7-~=k`$UIqzVIojq5NrQh(51L@Igc_#Y;DPe`t0?~*FbH6 zUf9pwddwx5o7Mg4vDi|a{f-dab6YIfYJ&Av(igp66(QDQl6p{Sf2KkIv3lenZ(HTe z^Hw=)IummKnQ^f3n7`q?b8qnSbf~uaol*F5XEjR)@J$T!Erh6hGK%lu(ud&Pe~~1R zC!bDn=5k|w3g6|_V(kv@(>dT#h+DLIOF`sZuj|Rymn0eu@UT_BVBHWqX478vI%*bO z`A~SODwIfA}uFB?G~xs+uzCu;?pgf^2=g7pd-hHx~~%TO9;*H-3Y| zPk~J=(s;L0KNkl{;#njR;-3q3Lj^^%{0oSys|Ml_w|4f)_;4 zEFc(VS2XE5BQ`>G#(LvJiI?%1zz;gH_z&aantzUS)bP8T)J=qQU=Z&b9 zNoEk6ZzQ#=BM+#2dMd||}8PTCeC@h)OBk|LumBXtZFNJv~tsLONC6LQe@lKEPA(>|q&iwqc0e~o&bfP|jYg`ju;m05sSsJgd=c&X@R zFrXGEL|=*mAEH>!#9!WyB&=l&H-}v{L<2*I9i$NihfsE?v74nIZA(D<&}Xo!=XeD2 zWE(`e=LF;y_Q`Rf?rR?Ont6yU&;OSwU9*4clyDegNAEuSCr*Ze28I$iD|r$~R&SF% zYF`6Gp>Tan$LBvgdYa0pCduYmx>qn~lG!JKlGxByX0RSG8_mW^xx=Qsg@iFIz=+*>?n-*^aDUP%&R)4!iXuoi3^X zQeDcZV~UTkH#rd0rlquUNKOw6UQd|y(Qn!%aKDWwc_}ZPE=c5-P<^WD_uB6)H2sj$ z4j|*sAPjnMb;LvNbl8_}c3vw_1KjujuD!OY&CNF6Q8nrbA4{IwIdEV-@9eu+)|yM$ z2i49I5@}z*K{QnOkRjT*SpRoT0W>mzMg*pt{|5=j$p09Sk9YwWh6K>1AtEKVjYRo{ zre5o6W+HDrEyd{2$7{Cq!+aQY@5J=f{)mmqvZj9QoNNOw3$mm~M3W)1)Ui7@r=sSc z@U?GmGqjb(?M~$%zpx}X^8lTWw{IYB#mdNYM@h#iO_v?TQ=Ws$u7ov7s0Lk zfMpN$;j~ld)y1-19~SQU#3jpfeZ5OE|n~ zD!f+wrkA^uXXt-T=o+J5cis8<7ET%pGT`ff%VOK&pMLrOmpMz`aQKH!H241bC}W&oTRhVZMDjw_ z_7jX+Kl$6{bh{~Pw=zV)#7opm@(!Llq9CKo5Uv>jV1K$Dc|1U;u99Gbt5dgmb z2~v--k>|!ZD0s&#tSwdnBj@#i30P^()cQl831}0@XYnK+mpsiZQz<{``1uOHn+}DR z55;umU|sxjxUl8|o2F>DX*2m}!fw4nxj+~Z zF;8HEi-NO`N3pmSAj9&*eL$a9U5QddJWrDsE4kv^ICL|9j0dTvz?6KPoJHzz`W$to z8xz+F#oCLqM833tl-G=PX~FM;ZrT(>TiAQy1r%v^8qO(-$w5hBr9-aRWW{x4A0=ny z!+FYLze9~#$qG4vGM#@1s+1w05rG!jUD#{{Y?{Jw&oClD4D1(@W`wA)?+b$51n>&Y z1Q&W4{o+zM2(XA*H->*?ZLbDkM(aj30ZV`LS5cO43D=%;h#w~! zoJAQS;=)V-82Z9?NRZ%(C-2KkLWl7NQ%XM~_E;vh}{ zwZRy(%ebK57=0ruH+y__1U*(P?Atd)d8r86v(PwD{j5|X?Y_9Il{qn7MV`dk`OJE*aQV9 zieGJ8(tQd(;AxGsnG72aiia^QzW@($e;}N&9fAM_A{5pTZrkIHID$PXWVgN0l&(T; zfVtotn+Ge;C;A*|lZJ2r@<3Ky-`*7`ddcPLJwgd5F^fPa=zy;}YJGJOv8n7lmp6nJv&z z0RG_BwKo_3lODkdKk#0#VxJ!%X>2OZNdQ0}06ZERA*1JK2_Wm+!RzS*81;4-qXK2X z0kM`%uli?CPrwlHZTnM`uOahj+u{J*{Jn!IGO+1+?asC}L{N}mrZ)cEiPK-7`6xY{ zJ+Da>e6#DIK&W~+C1!UkmUJBxnWF*#j(Ejg$J&qu^r3>|i+zW{Bf|T+U*|`v_mJ1YKQ!HDX2TR3~ zsRAA1mBwEK;g=nhcosXve_^d0!jkAAWms8y!*2y>@r_326qQdfEU^Ey$`OpTY%2L( zBm^4&+wVQBen8WHiB`Dba>5Hh>_q1P`NhE35l^8>akZ5g-sae6ZwwxBH*wRh=7@Q6 zr!0Rhg4UitF0r{}k7uG8k34d%-Atm5pUMFZLTtm%SRk386$LV7n-k_&@e@fx4^2l? z#XH6Kd zI>azttnDLyrG){<@9W8yIbz23gTJ!D*}DBwbV&8$XPdfHG%X-w%?>iu$Po1-wA^>U z>)Hb(CYWpD+wdiaO-ODi=Ac@Lv9qaeB#5&cj6C>KtvecZ*{ib<_244kOBynQ=nfL- z%u}&NyS?0-+r4=~f?QH`F&KnNB{l&NsFsabgL;MtaXxWxH!MLUs&ZRh*KJ9+4fE0L zbJ^N+rF{DvE&vWfD-lGfdhXmUZ{TX$rQbTuKHU%#eWku;)gKv6nK(54B}=b!KLV@` zYyV~Qal+zDH_j7PJF3he?mX6`CXf`q_bM-U#y!MZG0~qp_Ohe?pPkTPLT%$kjMO_*iI*jI&_8M;6#$WQaI6nxQ!%Y-0NY;alMJ7?5C0gNi6iU~UD+HTOI#LUT zglA1B7n;4-C>Ll`LZp6O%(o6IMxR~%cAQ#hKIlN(?)4(Qa8Bv_x-V#Kg^swfg2Ib? zOSFzZ2>4r|^^?B3rY61Mebd~kgxq<=Whxi14J~cA`*qCAN2jw*OEv$~&_>QmVSSrc z#^%t8^R%>9`n4`yCq;&6euK99(VLO!*N=5?R%b{(XiSU9ijq*S!OQtC>rPJYP8ARl z($@2%vd9-3t~5i?6J)n58I#8$14UgjMPc&X$$+8?nES1i8RrOZuG53#<-rWl*P^A3 ziDTvk;nH<4UcLF5O3?DJjg_!jYNC0WGEJv82ZCb_GfSR|Qja@Z3n@4j@e`3D*+#^Y zWtZjVB0)pcI$D`;?DvqusySK$sSZqY>EOISD=iu07z9wl-?5UfAQ?@oOax@or0Mc1 z<=F`80$`lPEH_`@%|1){AQmhJoSRODtT~@b$l@9&wOChebbGQ(My!>19P9P3)qsOw zXgLTNAmSA?GlUwD?+Tg~A_{1G1O2*` z!`eq*^3-yc66G%>?31{bZ=)UEB_*2PM7LS%Pe$R5eskcdpv2*==p6knhjj(4sQRj_ z{&PXs+mActro2{X?0b)<;aX``>l&`S^+KQtNMl_*`L}gdJURj1MdQs=!>SU1$fr9W z&unPwIbNf7tK3}v1dG!xJyDax%w)_iEr>AmFOB>U*=zVpg?=!vaVwEaRrD*#*Un$f z@_j!n@v{g#2=rL>73qyh*QTpKc*0zk_+QP~M}l zdI9@V8Roq8Lff%fld`cDL&`M{XePv#U_`-WfpxxOFDNSFuuMnhz~KYNHtvsYm+%mc zRLM@@7Ux;_cTz<)r>2bv>Wgl_nsgO9hi0{uRU66`+L|msKGcofj(FhcZlmBaK?l^? zqOlj8@kV%CW>i|1lEx)1g`|alQ^`Dzj4=~hkKZ3N1^pP!%nn&xxc^NEfYZ7Ms`5^Z zy;Gcl_)kaEV85SS@>- z;V0QUzqSIu>Ei2-LGEt^x7`34o-yDWf0#aJnt}@Wo?XWN5WF?5m94*|RKASs1pm$d zQ|z$HAXHq5*nmS3uUqL3Kw@TEZM3(eKAv3YRoL{K+@saP<;!Sn24eEXH?|9@}xW~LmchNAF4ZR0y)vjwFwcn^LY_kWN zYD#~iEVGopmaVu(&sNZ__<8`2Mv~HHG_p1jNbT(5rb|^gfBN(~turTR!uH%+a+CJv z1S7HSwXAxTPFnB)PA9EYPS~vps23yU$|R(^_}PDZ)bF48TP={l5<)K+_B&^SYs3Go zuoW5c)jX|pY44X$DjAPfdx@vf>&Y_AS`)Wx=(}x*MU3%oi(QNB_Fq9WTW#lJa`Us- zV;^OUar)$U`KZ7V(8(vo6uVlfJ)I43qL2Lns2X{(*P98*i*%=SChQ?)gBy@JYSI9` zHq*0`W~5zXlOhF{?TYjMU`ksZg6Z%ciTrb-H+0~}*;O)5YiwpOmfWc(*O-oiH#5ID zBQE4Nikx_YGy{tpmn166Zs4~R>^Rc@vvLK97ycKmZ(&ySG>Rz%_Q(@SP6U3dhR3z2JSMH80X?BqS;5Q9$v{h^3Bvac-&DO1djOhWF~tr>lPP4=_n8 zvf)XUB-far&hdhan_XlJKY-19q+l|qO*xTa{#*QRW0Q*KRxI@wy}ptE6iQV%TiJ|@ z{VgIKfFa!Zo!|?GYHUrJmZV>_-N_;*XJ(|I8ZO-FfJJC*oVk_c#(agR3bFPTCxr@g zvC$UL8198C-|d0c`YQpa*EHQB#t1bIRAt5std06kfis5(VNOEtF1t2t@Cqt1nxzGH z97-FjHq|tH1|vfS6oUkMoI0;K%}Fr~X+?=YpdcgOj3^R~4^ns%^GJP-&QCrT{g|8C zV>Ux1SsAtF=*);DeM09@D+F52zpD5Pi6$&8Mk zznGA1U#70vA6Rc>rEd#^JQbLC)1j!Rr6t9l@kp*lp9cTS@+%c20WQ3Bz7&Whpk;*u zkoMbZNY3hv)B(yn88SAu02-sr%8!*D{&|>udK@|U7J{};5;s@^s)QV;0e&w|rjY5j zpHf=}UN<}m1e!vLA0V&7ZkSk*T^Hx!S;faOu)=htZ^Tg2l;&LGB)#NGxXO+!xzuxG zMmnCy9rtJIQ78?ZW@uBZFrvnzAuNa;90Th z%V;f^F0#E>SH_U%;0ET)g-0BDGPaCc@HJF|DmYvfGO4Uh&W=hsU~~$67dP_${;R!M z09^@=O)X*MJ}5{n-{Q)TvCQ{{2$#M3(dY@j>_b7OM;2`DuF^=n*t|+|*|iBc+oq|0 zNj$~Z1n)$rArwwetZwX#79ZdVnvzQQ{9@3=qY_#1yyHpwh1Db;GA}fwF5D}cwXGVT zcyFo}-IggKb?H0pX=;9zH;X%hr27G?emLT9`BwVn;z=WJbzgzP;;SJJB*;<$4n&mQ z@!0a@@w7@36ume)W%0BM6RJTlJ%nC`Xw8F1k%OXiCWhBl^CP!} zCf%D6tj;%&r3ss*2^;yWb*!J)5Bmp`#6I$!qVIP3 zS*$frIwwVEUJZfsB`P1=UZ4%H>(|ADt&IeywxchO; zcb&Pfu#H|FlLRX$rh>VCR%>V&WiS(r&OtwT!>>iYE;0w3Ey#Dt5RN_2&1|UUtEq|g zXWp)P`HZ7e?J0m59hU%U`VkXH**uc5rRY*Nj9B=(Hs1~n^rF4FgNFuHJR=)~V)r(V z*jSEhvM4jFxn#gx!S=X)Y&z+`OA&YS8IOQv`8zo}gIk4=iRQLxjF)zhiM$uZDODOH zC7{?3q=bu(%UhUCUizx;2Jh{2t_x?3J~r382kz0hcmLb(2~%csWUL<#F73o>_H=Hd zB4n(6#Ow=K-#Y64i19q?RDToI|EYUCbRx!mq8QE7VEIMSf!X3Qb2f6ul-5Y$7Cs5* z@@kz}|0?*fzpLe3rSPKSTiUDE@y$CpKSan3ZZHD4^9+q+9s^sAOJ21J^1eLhqHxLR zWwYDs`YUL+^pW#?bk?-^y~-YUv!4I>F4|Q!end?r-sB8%(#^msNx>?^_D+nL;~|Hs zJQrVAK`S3MA-Hs?%j;Z;WR-o9fzJ37?P5I?1t>B8?^A@`3pAmm6$hVy1rHye01vMK zr>(U$HzzNzg%yW2r+}rc02ddhsPO+U132^oZ3YOLL$jICT}usSVtB=*Y-FmSRw$8) zr&Pi$ITxD9;57oV;JM7SU!k>g7_*P&X1R2X`)}V~9P)sla|F1KrVSD~O8a)WXusC} zu@R1vJqJ5j9e+U#uvE^xobf{=+k*-U|2}Vt4!3Jg98Q5BFXfJpI);HCP34ZAG|FKa z&H@OavAS-~Om^)Q9xAg8!1O{GGd%C-O&o^j=t=o7`Pmy}rl7pqG`ee$@|U^|53uwu z;IqdRL}4nOO-l0Jdrzk1#m?V=%qu_iXk@C!>$Q$|i_Wsg55VbtGO&tS&ai5OX__o9 zRUIf;c-<*yQT+SLr>(4eF_@Frf~^7zOCp*iH`kn;3kR69K0zJzd?j30DD1GLgLdd98=^B4fJ zdazhOZrKm{`#w$eK$;)woH>*!_D_w2?W$=}JD>h6t+QzsuR|W8+o>Fwt=U1*;IYdD z7R8lb##Ty=t`V^Ag`*{rP%~MAGw1s1h)>9Z3(f{kS+Lak>(>jPzO!c6_;=!^%bPCk z*NoqFP-bgNeV-0HxUW6ZT^ryqwmZV4-(+UZJMr%X#lfqeWU?JyuX`8wS@5AD{~GbMuObptyUuS$=Uu@mb6HtnB=Y4YlV| z=MiH#RVwi3*QBAZp8Za6Zp2>YV9PSn`@gJvWgtN(K^*B>DJtn}C{%uEYXAfN?~B@? z+O?3%jmvB32NUQ`*W2F!!ra`2?G3)JRSbw=Z3G{zzKN44-l3W6j7iQ17m;vee2Q&g zH!M$^$4n%$@N_i?Fz!XeG@Z)W1UJN=v3FeExl|gFnFvZFsnDOo(pix3{M8o4 z4XT@!qnz2|Om&dO)J1x~hKv*ZS)mxXY$f_EeOF6oPNK^#Id~S4#z{$77j1DaIlW=a zWGx>hy;=(gfx71D91I$oz;LpCuRh~4 z-=Zqij82zTKcer2W@kWdvO<$E5JO>wg`ej!ZnZ|~M=G_2%WwjrYvnNc{mRPQ(YsXc zVgm?IpZkg>J$2!Hv31af!L|T*1BcvG1fb0~U~~q*wYmIWej6(=T>|6K*{W%95vOvb zIS65j&hKG6OE7$!W!;~7TLjC8mi#51LOuo@g15Y^mANTjhOcq_gkh^M&|520b`0YM zEtbD!uo?7iaxQL8Mf3E-)kM;q8JDrE{G`1}OfO1qpG7}#iu<)SOCOj`ZirYUT?uiA zqwVm(X;{pZPWw!;!aTnA#RIuo5>H2%Ptnq-vP)a46bY+>CMyQTFDd%@gQNeqz983#64fOZg`ey$ zd-F z$xrpRXhJ+z+G3T32U_3u{;@o*^|Wr|{Um=71a`~6d&Ha`x98{C_JavcWGk^C)s0k` zSBSE%v;Byg#-HULI5K#wZj&{GlGN&yvOyk54t)W%(z;B@H9?)6qT6$wB~Z{=@tsYP zahdAbCbB8XRH^=~`{(PLDu#%fnjYaBG%eWbz-PF&fsV$Ccec#hZ+)*y4>x@N0o#yt znJe+6<-^UH*XJ^TN8*Pe1gZm%198)Wr+vo*$<%_U1kysxHJ2i5jPhwHPYSkz zXjJ`l#daVM#&13K)n2K|aL9etzqwJ1lN5B1?riuI8Jjf_xM(DcM%VMW)l)Xi@P|LR#%|$in>=#bpv>0t z_w7-z&s-VzGE^%2r7>^**m-a<#PmtMK4jKbLSH(J932HKua@raRAfAD9QXDY^Ng77 zSjUzTT?;c?$>iS*yq7S;4&1rBwQ|a@pq_vJT=~AUjE!Z2Jaha@3$7a zeVEgSNeTu^XPvG|+Y~2zj2?X^*pk8V8sWH$!rF_hAXeZv02qC$ZISi7Q@?o0+wdjm zE&eE2`43)lmWb|7=Y1}%mi7rUYF()X&GHc|zi2Usn=N0Jw%39;osLx@W0l|PDI?)! z^I$}3db)QTLjVWDfGYTNZZdXrgeBryKX%F}-=sM5jNEs~W(XG(_xJu}j}MAZ8^P(p z6R1)81YmtJJ@_R?%+!tG^4OF;2jmbA<&Z|y7HhRq`7=UJHA3W`nPmzOwNN*@NgfKi z1rVJgdl89n7KxB$E-JD<_Ndf3e`!V1Kw={07#BAOfj+ib=~0;st;Ur2C&~C3`>BCM zHU9~^a$!pmEqgXmN#-cqbkaYljDy3dUBv*+$2#_y66i|KgJ19C&FCA8L!g(FK6&4A z5c?siUCoQr+bl*C1n7I`ulu6=ACd*tIR1y;6chVjY&auZuk^P+*%cfnlZoC(EXEJi zdl}3R8j~7~qu!C@98zutwa!p7e*j&9Jv@JObaqr#Pfhmb44*)VPeLI9y6~g`#4eyl zfblaBbohLI_nZKEoz)J+5xF@3^Q7J}_RvN(U)VsK9n=-~d3c{gbe#o!KJmsCHBEzQ z#vR!L8Q&NlY9)-n|J8WEz`fk^swdQKjnwDX9={iP?jZV&O->Uqg7>|f{W+<-w5dc(c_jkLd*nEdh*eBni2PdtcI}D$1t7h$HOxW{JaOwlTuUgUp#> z2K#s^{+al~=a`VMD8s=PVZsO!Udj?)f8gi&D#Q_KDqS1BIf{A}UX1(A`dTzQ!V;!(7cJXYW;gGv-`m+gA# z8w#5K^SeR$hS+3F#7$Y#Ku;$!xgfPC^){uF*0u*d0rWlWgTB8oAaRjQNU-t0Fb&X$ z7Xy^&2P8e(f+=)sv~1?*n{U^u!CK{24pY*by`6s(B@n`$4{tB|_71hxGrVx#vd-^v zWn3MU&5zejs%c#FHSN3Eu$8N1X&#o0gnfc~Ya)#{lCGMr|JXiw{%s7r>{_daEb0Dj z_WSckJwk)aBkXd_lLCufXXMR~tfFeF0UyYUqYe8jQX-OY&w!`5Kz`&T;@^U{x|}_{ z%emDBezhbBD*wb8-LI$#VF}~g;PoK0Q*Pyq+p^jB4-`+a0i!Djc}+g(O;oc!K)jQHo+%4ob+oYR)C5V2av&F(CV9 zK%Io57RGgkTP}o5{5zqf-U|%zN1bh^cK;Xn!8j1z0I3DCgh?_Cj-ZB8Ii@R#7VS}f znB55c5(;UG8fig@R2n>KHp_3JjC`en&}jEUYZQTxw@R)EG|S)685T8$Q*OTR@bBq%=7?ThL434I1M)W5o95OCCt{x4A(DellU(P`*#9zQVF=Fy zOh*3_!<2q_iZ)hxA9{B*hDs1hZf+I@#`Nb|9oP7U6-zyrhX3{~7Z|N4Wjdtpeb=L1 za1_Bkr(}~ z?j?ON>AQR|`ek{1;|z2FDLa2&+o!96%GZqPG1*IPmhN99WNiZ`-sc^fH@=074yg7{ zl0M~SmK!-QkmHRXYR|LH_;h%W;!Ie2zG}CgTPk26r7znk@k{Q4StD0w0-`oBL&5=^8A{^m zi=(fJu_nb<_eeCh4lBOfnL^pA#=Ju_mb#4NL)%%CkRHtpc3Tc}v0?`Kp-U7{JV?w0 zd@C-&ZeMo$%~(tsV8`%f{Bu#6YfVKMZu0tp5|m2nf{2yK8jr(g&LGO626G0b)7Is; zMpXQPg~e3{ICMad1;xkJEn3Six0m?Dj3Ne~HmtJ<_>ALXb-X<7N>GZG9n>ZkvZE(rW@ii0QZhS>hm|AxYBpaA96Kp9Q^iJUHC zn=IYszVB)mWm&`*^AhN|Q4oXTz?V6e%JNB+HDrNhvHv9Dn{L zMrFnlvvJO$p_5sPZ+n8g1E}<{7WI7V(%v9H4%+l@C{ayC2(W)_nOrME3J z?D2O!I_r)(v@#tmRrX5;nmLl3%2ItTP5`KG!P(EFyos6P9X|z(5YQCKK|F{qS{%zJ ze2O4UV7yrzV0!0>1vf%z_CYO-04${xf@7vsoi7$%;o$7{l$~!Fg-&db17vz$Go8qIArd z$*&($alw-Kpr3P7c2?lZ%@qqFkcoqK44I!~rfVZ7V;DM;B-OMS@xcnqrd^5(Nc+=9 zBnejXWn1GLGx=5t@1ky;Qrzv~IR^TT^!2@mQ1OrahCbVPoyf$RK%mFD4m{RSx+#(S z;VbC-)czloERA32e-EP`WC2KWA*#We_eU1MVBq?;g0_lPg@9x z_Vs*0v2?Qd?_v#!F@{HhRDAh|#71=JzftlsFuLQc5eI1Xq>oZdX_VMtUJ?;glHpYY zWA24J_t7L7pNYY4S^VC287yRA96$@>Wws4P^kYdMm7mF^^5Ap98b)SQnU=XI{iH6g zyC@Y1#-i-bbv}PRebryl_|CVc6CuREA-blR4Lh{?qcuiCj;D6QDg!khNIjyq(1XF; z)HxnMLh!Pt5e#VRA)i)rXYapI=c-+pQV@2P`a*$ny*@s&Ns@|6D@|udg^1c{Q$x}? zsqgzWlWs|u3`C9ceIs^%mZLA-qBb8KOVf|T+& zaQIWT`$5Z^${{aGcj5Q%2P)`9S;^~64y#TorYgt^UHnI!0@Q$}o~;QR3=)I7c4Z<; z;|_)s+xnk6joOK&@D%BmX7yGf)8DEPP14rec5K4VOx=IjLn>rz$bS|!V(=8cMNlDt zS$;jubFO}b(u)Ftu(*OFhz18>Q*l9JHnV%h`_yu%# zkiGE3_vu!eS!f=RDVsMzftj&F zRHFV1C`_AZH)Z-*PMt(Yp-P{H{C!cz0#-j(k{_cY+p>7Lh)v#X!WvtM{fsXz-KoagN`dZjvzpO23CTbXHE%u&K(KNpx z8XhiO$(PyX6X!JQjv41P?zn%!N>;LZp=wCen&w z07vyPK_7%C{`@e1Q?kqw(tv@IiDp?rzK`um7H~MeZq!A@b}?KTU$Rk#9+Z*>$avFY zfiGY2efZ)S*Y=Q|9y2-PiD^00E#Z;SZR)Ygk%DCi24k@kr^QJ>A?%DwntJ^P1kbPp+Iz^)WU>}>I&hNGA@6zc@a^(gE8J0!*I zc*bxFqYMlmT$k1Kq8buM#HsgSZIgDl0UBAI$VSs@=#?;%b{vvYd~HOuL^iE+LAxcq z@QVZC8b)+zFi~p1>gf|IcVfj@{-J9LM*uB80~2gxuX3o&Eoae^2e0BH+` z^b%ej*C-e(*o8V%?*t{54bvm^4f{oLdMB-&Vmp8%5*djvn0I~z&2UHeRE=#$~Arz=AM0sF{qiR3QoPtj>F9A#;Sas6mwiBXtyJipRhD z5zLj&_{|d!UcCTAbKQIxBLe$q{|c$RT%JjRGQrT|O6hHpOh%(l;!@HObQ24cNALBO zHNT$rGPFEIiOm96{U-<2HwqJphV2xPdK!^E8m7;EwYSHUNEeX`p7iA(K<{$eA&4{k z)|kYy`{7=wq)%9MXz#hmfxnN@@A5}POrM&I{;fzr7qg!zVALDXN5T@bd4M@$)z+}Q zv!nXDIpzxWzHHZxtt$)7%Re3ICSb9?SvX#Dc=d4M$~3}RJ+^Rb)19G5_#$MTv-#W= zLv72K$@({U`mew+IkuYsV1&0oJ^CrF=d_1#69=g-ax2<*RB$f8jn()*$ON_SL+ocd zY{u~HqGOL%!*7AYxo=DUB3TL1zIg>8;#=c86@S(zArX>-wdc*r)kwX|M^kk}ma!(= z8NtZN9&ewc&>w5#Lc=NtZqc%9&xS<&K8C41`fw*RFH<7Lu^vA9FVM%AIgTCBCj|99bJKi3QHmb z{+dX*T}w&ku_47gvb}p~d= zsDcSamkrTyr>+v<7u}J>u+$zYRks@xK$O|Ryp(yUw6KmRQkGG|-tqe-spwm^%tz|{ z5E)ix_ega^dT|<|Aol|=E_K7-v3TfLJT~YCM^B*6wnF4mXh(Dtr4-wg17BhJ=LFQ2 zN(pA>Q7(B-?3{&L1wWbpG%oQhVh3lIPUvzUG{^TZ3IGoiBHw63(+m(7yNKnZ@17@OOiT&+9q4M!VVLH167*_oXbHFBc5R150)+b$)L-Z$Kr>A z$Ii2yP1BzM7VD3_11?(z%e1>H4%;4xMJ<{@&p?VUkWfjEaO-7kh zl)1l`fvB!DtC&1M(C4vpJKJRuVDAhx@6$^=Z8-SiQ z#vp75tnw3VGFxw`&*cQt{|4_kT4OF-w{Wm{Jk%t!G4A6$L-92GM&aNxkvmF0~O zU_c;wSoxpbCb3x(_qVTaTm+bDVlL2@%OBg)P4HtbTm()wWBa_a6T*4Ie3y&;czTFT z0PmPNf>U*ZuO-K4rCr_G9%BnEgrbDTV(KHw`UtGa3<}*tg?L5gW0UDWX(p=>C6@_2 zBf;|C0>lIRp->P0yts?O@~+l`hH8xOWQOm`y7+~WHW#UVR`NS`SI|8B;$>rcKe*X| zxePtJ9}&eZWFOqwd*8YZJ;tS1qz||80Hr}COG|%E$~7sFf%W8%k#0fOh@w@}Y-NMg zlx(S4ncdd;^zme>j?Y?FvnXEF(jGQ9u6J~T%?PVhTGSa);>77340wO)PSx3%1Mk71 zDvpKqt*sg%_wJ!_sUgj{UCiFbhFi)JHy0)gtJ24tD*S3lGdDf*`2Ok30>=bmS` zuxcpITP33db}~Q`06U1`=O{F6R9fcC27X&A&YYOb3P&SthADYKB_3x;`P`+w5Pf3G z-<^)bJ-GPciqBQ$K(4YzNVC`ftHFvsm8=Eom5_?q;D>Ub8m|K!W$YZEN@6O@?i1Yl z*C^M^9{^Cu`)>ZrfMe6~h6TrC1*HE99_JkfWa$cC_8mNg6bjGu@1_8SXL%O}@rA-O z0%Id)9q%tsS6E9&C*`4MmF=BoO1`5?1L@k21+R#<5f|LG9Hqp0^D#rD&gC1!Mow0}8# z0C61mR3FhfQphj!80lcP=hF) zs1Pa@BTg)YT3&7wOF%8DG%`(Hcy`t6Z96RktyFIuDMuo#zmTJ>*|%swPkVR>T5+-= zHnlpZuUb|w*VDP{c^&uZ)vXnC> zb!9JW;Oeu**f~$2x!|ndNoKGIm{z{ckJI?}iCPE~(e{j!`}KL%y$q7`=}tr^6gJMw zmY#f?+bueIZ?z^SzX z;_Gf^B3J(Xp{3DE4nfPLMID!hy@O(0Vx5;}qxt@-xh^q>XhWr8tTXz}wYaq9^fJ~Hq1H!n^&u&Y(LXL+6QmrGdVq+ z)Q+5O*EFwwhc!@>?crqUbR(VRtRSPz-p$=VR%kf|T{2CaL)piJ zh^sjUE|eKbFgKT%X5&k~I7JP@dc>a#hVLd?WyK*-F4<*U4U?SOO1Gf%$pO@N&rW}Q zCyzFw2MtE&HZ0ji9|fK`dP$8o0;b>GVa`c_Ge96G3PIv8)J2bkWv|ypdn-& zV7pIH2|GV`WETcd_N3Oh1PED?2BfW<)aRZ9)w_EPR7N#@n9gz{;8wM2m5ed)_m^=$ zhLoA0LWaiAGO4m$0!v=SPDDt@HFylUBvpCg&ie^D*-BZth;=~%Pmlc;JL(TLxNy!- z8dbsG?T_!QYn{8N}FFQz4^M~(iibK7b=rru|;r)QR>6Asdk|b4w439H>{$6L) zz?*zY-?m0_!|kdzFd4?x5bNY=;zoLu5HPr!l~`c;eU?2hT^0wdg<7tE zLYyKV7}`4W-PVQWDPl;C@K#UIMJOs<9mKQVsTRjJq+3~Bj9v^c4w6`fNi9ym^C?dI z9SrC*1UX`gtiQjaQ_PmM%-g1ahuS-Ld#R^S_M-o#MV^;Ic7Y`c51&7%3~G z4d`x$3Kb|(B}Ny-y|>V%zzjr}ZaZ4ik5mH8641!wYW&O57R6C0s6w!%vg3C;`YHB5 zhO&L)yq!i$??%zPMLPHLS;kKGhd`7#;wkGHrUG15 zul!1mMf75VL0Y>OLi8Y1gfM zlS5u0<@ci#{5Ade-4hF}SYdSs_Cf``y>CpnojK2ljUF8ngv)5g0y#eBtKL{;2;&Tn z0jWG8P+^+|ra+Tq9;6m+Xpd6_I5&VPOJ`7Wt zGGaoDhOK&nXu7Eu_0;AmB0TH&dvkBhkv8lSv>t9vc(7;bk#PDvRgg&~U4t@iuc&r> z9`Ja#(8suQQ2`rkY6WJvkcExqIWJ18QXeUqi+cMIbKY^Whmg)gSJmC%xp~HU?ugaL z>U?|8HTvz<_vRO?scczZ{M(&hJBg|-t&4~-F^j0^m*O2_cas6Y=AufMIIy0lW;SvG zu$fOqwKp4qNmQ8GhPVvv%9rs5 zxN9&4*=U^U>!?Lc61gi8HE|XF-HB=#GZa4`u2;x>^3XlwCfzJBH#upK$WY)9s z{;dsG&+yC=oa|iY=H^x$oaXG7Uo1H|I5^qNIV||iEjZY$xOgmhM1=l-6_BN8coV=+ zBHRHtDG*tG6@r;WosyQPQBf@Qoh7W1TJMv840RzR=SI8d(AMlx2;l~sRi`KTru*>M zwEgA{zq{2;MiYVgRR33NrTU67Vjsk~pS@J(M-(`>!|5&?Lx(fYy`xPL|388Pt zFWbdq8YtmIyK(tcy6Z7DXxR>Hy{)t!Cf|;0y`!%*bk0ZYy{)1&#-ASC#rDzMmBneu zAv6FTwjiF z$;=Cv^_RFNx?g(h`%HgoFLl8`=Y7tz%oxpEvR-RfoG=Ym%3l{RMc3MfTLb5VC|iZ~ zyVM=L)4i&;aM#*Kg3echsP7HZ?nxVj(bk+_FBWEtU9&T&Uco5u)%0PmaRD#e+gkEv+hp@_*q#NKgO( 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 ---