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..d8c75bd 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. + +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..d2aba88 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..9562903 100644 --- a/labelmaker.py +++ b/labelmaker.py @@ -1,6 +1,7 @@ import contextlib import os import re +import time import nuke @@ -8,6 +9,35 @@ 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 +# (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_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 def node_has_mask(node): @@ -74,7 +104,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", @@ -88,18 +118,53 @@ def __init__(self, config): self._line_counts = {} # {node_name: int} last known line count per node self._pending_deoverlap = set() # node names whose height increased since last timer fire self._deoverlap_timer = None # created lazily; PySide6 is not imported at module level + self._content = {} # {full_name: (frame or None, text)} from the last real build + self._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 + + @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,15 +182,36 @@ def _run_deoverlap(self): labelmaker_deoverlap.deoverlap_from_nodes(pending) def create_autolabel(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) + 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) + 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] = 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 + 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): + 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 @@ -138,6 +224,209 @@ def create_autolabel(self): self._get_deoverlap_timer().start() # restarts timer if already running return autolabel + 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: 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 + # 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): + 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 + # yields exactly one relabel, no undo entry and no visible change. + nuke.Undo.disable() + try: + for full_name in full_names: + node = nuke.toNode(full_name) + if node is None: + continue + knob = node.knob("dope_sheet") + if knob is None: + continue + if force: + self._forced.add(full_name) + value = knob.value() + knob.setValue(not value) + knob.setValue(value) + 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._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.""" + self._content.clear() + 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): + """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() @@ -150,18 +439,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 @@ -345,7 +641,10 @@ 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) 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/tests/conftest.py b/tests/conftest.py index 564444e..687b2c3 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -30,9 +30,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 +106,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..1e3f1d4 --- /dev/null +++ b/tests/test_label_cache.py @@ -0,0 +1,479 @@ +"""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() + + +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": _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} + 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) + 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 + + +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} + + +# --- 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_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" + + +# --- 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(): + 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 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/[tcl] 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(): + 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_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") + nuke.thisNode = lambda: _node("Grade1") + assert labeller.create_autolabel() == "Grade1\n1001" + 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) + + 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 + + +# --- 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_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