diff --git a/.gitignore b/.gitignore index 09e2608..566b14e 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 (lives on the profiling-harness branch) +.profiling/ diff --git a/README.md b/README.md index e22db67..6b8d63e 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 (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 f5c2c5b..1d42c98 100644 Binary files a/docs/user-guide.pdf and b/docs/user-guide.pdf differ diff --git a/labelmaker.py b/labelmaker.py index 383ab6b..39e7fce 100644 --- a/labelmaker.py +++ b/labelmaker.py @@ -1,6 +1,7 @@ import contextlib import os import re +import time import nuke @@ -8,6 +9,40 @@ import labelmaker_deoverlap import labelmaker_prefs +# 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 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 +# (recompose it in the background, in slices, without running any [tcl] in +# 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 +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 verify key +# comes back through this slot ([labeller] in, [labeller, key] out) +_verify_slot = [] +_VERIFY_CODE = "__import__('labelmaker')._verify_run()" + + +def _verify_run(): + labeller = _verify_slot[0] + labeller._compose_label(substitute_label=False) + _verify_slot.append(labeller.verify_key) + # from https://gist.github.com/anonymous/a802f51391163a2bf0e3 def node_has_mask(node): @@ -74,7 +109,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", @@ -85,21 +120,58 @@ 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 + self._shown = {} # {full_name: text} the string Nuke was last given + 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 + 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 + + @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) + # 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: @@ -117,26 +189,297 @@ def _run_deoverlap(self): labelmaker_deoverlap.deoverlap_from_nodes(pending) def create_autolabel(self): + now = time.perf_counter() + self._note_stall(now) + in_burst = self._track_burst(now) + 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: + # 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) + # 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) + 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: + self._stall_t = now + self._note_shown_height(node, full_name, text) + self._shown[full_name] = text + return text + + 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 ( + shown_count is not None + and line_count > shown_count + and labelmaker_prefs.prefs_singleton.get("deoverlap_enabled") + ): + self._pending_deoverlap.add(node.name()) + self._get_deoverlap_timer().start() # restarts timer if already running + + 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 + 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() - self.set_indicators() + 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() - autolabel = "\n".join(self.lines) - 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 - if ( - old_line_count is not None - and new_line_count > old_line_count - and labelmaker_prefs.prefs_singleton.get("deoverlap_enabled") - ): - self._pending_deoverlap.add(self.node_name) - self._get_deoverlap_timer().start() # restarts timer if already running - return autolabel + 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): 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): + 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 + # 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, + # 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 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"] = [] + + 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 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): + self._poke_nodes(list(self._stale), on_attempted=self._stale.discard) + + 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): + """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 + deadline = time.perf_counter() + LABEL_VERIFY_SLICE_S + had_first = bool(self._verify_first) + try: + while self._verify and time.perf_counter() < deadline: + full_name = self._pop_verify() + try: + 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 + # normally on Nuke's next real request for it + nuke.warning( + "Labelmaker: could not verify {}: {}".format(full_name, exc) + ) + continue + if key is None: + continue + self._note_frame_dependence(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: + 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 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: + 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, 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. + # 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: + 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() + + def _on_node_created(self): + self._forget(nuke.thisNode().fullName()) + + def _on_node_destroyed(self): + self._forget(nuke.thisNode().fullName()) + + 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) + 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.""" + self._content.clear() + self._verify_key.clear() + self._shown.clear() + self._stale.clear() + self._fresh.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): + """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 = [] @@ -150,18 +493,25 @@ 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 # 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 @@ -344,8 +694,17 @@ def mix_line_creator(self): mix_line = "mix {:.3f}".format(float(mix)) self.lines.append(mix_line) - def label_readout_creator(self): - node_label_value = nuke.value("this.label", "") + 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) if node_label_value != "" and node_label_value is not None: 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_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/conftest.py b/tests/conftest.py index 564444e..71728ee 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -7,11 +7,20 @@ class _StubUndoManager: + def __init__(self): + self.depth = 0 + self.calls = [] + def disable(self): - pass + self.calls.append("disable") + self.depth += 1 def enable(self): - pass + self.calls.append("enable") + self.depth = max(0, self.depth - 1) + + def disabled(self): + return self.depth > 0 class _StubMenu: @@ -30,9 +39,16 @@ 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.activeViewer = lambda: None +_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 _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 +115,7 @@ def setSingleShot(self, value): def setInterval(self, value): pass - def start(self): + def start(self, interval=None): pass @@ -131,6 +147,11 @@ def start(self): 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/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_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 diff --git a/tests/test_label_cache.py b/tests/test_label_cache.py new file mode 100644 index 0000000..9b4c6ee --- /dev/null +++ b/tests/test_label_cache.py @@ -0,0 +1,952 @@ +"""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.started = False + self.callback = None + self.timeout = self + + def connect(self, fn): + self.callback = fn + + def setSingleShot(self, value): + pass + + def start(self, interval=None): + self.interval = interval + self.started = True + + 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) + + +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)}) + + +def _unpokeable_node(name): + return StubNode("Viewer", knobs={"name": StubKnob("name", name)}) + + +@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, and its verify key} + 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.verify_key = labeller.texts[name] + return labeller.texts[name] + + 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): + # 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 + + +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 (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} + + +@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 --- + + +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.sets == [True, False] + assert "Grade1" in labeller._forced + assert labeller._stale == set() + # 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 + + +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_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.depth == 0 + assert nuke.Undo.calls == ["disable", "enable"] + + +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.depth == 1 + 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") + labeller._refresh_timer.interval = None + clock.now += 0.05 + labeller._refresh_timer.fire() + assert labeller.nodes["Grade1"]["dope_sheet"].sets == [] + assert "Grade1" in labeller._stale + 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"} + clock.now += 1.0 + labeller._refresh_timer.fire() + 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 --- + + +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] + 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_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" + 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(**kwargs): + clock.now += labelmaker.LABEL_VERIFY_SLICE_S # each label exhausts the slice + return compose(**kwargs) + + monkeypatch.setattr(labeller, "_compose_label", slow_compose) + clock.now += 1.0 + labeller._refresh_timer.fire() + 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_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) + 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_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._frame_dep.update({"Grade20", "Grade30"}) # keys/expressions last time + labeller.texts["Grade20"] = "new" + labeller.texts["Grade9"] = "new" + whole_script_pass(labeller, clock, names) + request(labeller, clock, "Other", "x") # closes the burst + assert labeller._verify_first == {"Grade20", "Grade30"} + compose = labeller._compose_label + + def slow_compose(**kwargs): + clock.now += labelmaker.LABEL_VERIFY_SLICE_S # one label per slice + return compose(**kwargs) + + 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_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 + # [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): + 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_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(**kwargs): + if nuke.thisNode().name() == failing: + raise RuntimeError("boom") + return compose(**kwargs) + + 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: + 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_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_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) + 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_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.""" + 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_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") + seen = [] + + def run_in(name, code): + seen.append(name) + nuke.thisNode = lambda: node + eval(code) + + 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): + 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._verify + 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) + +# --- 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 --- + + +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 + 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") + 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() + 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"] = _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