Skip to content

Commit f0cfdd4

Browse files
author
Fabien Servant
committed
Enable output list attributes in the graph
1 parent 912a71d commit f0cfdd4

4 files changed

Lines changed: 232 additions & 10 deletions

File tree

meshroom/core/attribute.py

Lines changed: 108 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
import copy
55
import os
66
import re
7+
import threading
78
import weakref
89
import logging
910

@@ -164,8 +165,17 @@ def _getValue(self):
164165
raise RuntimeError(f"Cannot get value of {self._getFullName()}, the attribute is keyable.")
165166
if self.isLink:
166167
return self._getInputLink().value
168+
self._resolveValue()
167169
return self._value
168170

171+
def _resolveValue(self):
172+
"""
173+
Hook for subclasses to resolve pending values before returning _value.
174+
Called by _getValue before returning self._value.
175+
Default implementation is a no-op.
176+
"""
177+
pass
178+
169179
def _setValue(self, value):
170180
"""
171181
Set the attribute value from a given value, a given function or a given attribute.
@@ -775,6 +785,9 @@ def getSerializedValue(self):
775785

776786
class ListAttribute(Attribute):
777787

788+
# Sentinel to distinguish 'no pending dynamic value' from 'pending reset to empty'
789+
_NO_PENDING_VALUE = object()
790+
778791
def __init__(self, node, attributeDesc: desc.ListAttribute, isOutput: bool,
779792
root=None, parent=None):
780793
super().__init__(node, attributeDesc, isOutput, root, parent)
@@ -816,13 +829,14 @@ def insert(self, index, value):
816829
self._value.insert(index, attrs)
817830
self.valueChanged.emit()
818831
self._applyExpr()
819-
self.requestGraphUpdate()
832+
if self.isInput:
833+
self.requestGraphUpdate()
820834

821835
@raiseIfLink
822836
def remove(self, index, count=1):
823837
if self._value is None:
824838
return
825-
if self.node.graph:
839+
if self.node.graph and self.isInput:
826840
from meshroom.core.graph import GraphModification
827841
with GraphModification(self.node.graph):
828842
# remove potential links
@@ -832,15 +846,41 @@ def remove(self, index, count=1):
832846
# delete edge if the attribute is linked
833847
self.node.graph.removeEdge(attr)
834848
self._value.removeAt(index, count)
835-
self.requestGraphUpdate()
849+
if self.isInput:
850+
self.requestGraphUpdate()
836851
self.valueChanged.emit()
837852

838853
# Override
839854
def _initValue(self):
855+
self._dynamicValueLock = threading.Lock()
856+
self._dynamicValue = ListAttribute._NO_PENDING_VALUE
840857
self.resetToDefaultValue()
841858

842859
# Override
843860
def _setValue(self, value):
861+
if self.isOutput:
862+
# For output attributes (set during processChunk in a worker thread,
863+
# or during loadOutputAttr in the TaskThread), store raw values without
864+
# creating QObject children to avoid cross-thread parenting issues.
865+
# The raw values are:
866+
# - serialized by saveOutputAttr via getPrimitiveValue
867+
# - materialized into QObjects lazily by _getValue on the main thread
868+
with self._dynamicValueLock:
869+
if value is None:
870+
self._dynamicValue = None # pending reset
871+
else:
872+
self._dynamicValue = self._desc.validateValue(value)
873+
return
874+
875+
# Input attribute path: handle None
876+
if value is None:
877+
if self.node.graph and self._value is not None and len(self._value) > 0:
878+
self.remove(0, len(self))
879+
if self._value is None:
880+
self._value = ListModel(parent=self)
881+
self.valueChanged.emit()
882+
return
883+
844884
if self.node.graph:
845885
self.remove(0, len(self))
846886
if self._handleLinkValue(value):
@@ -852,7 +892,8 @@ def _setValue(self, value):
852892
self._value = ListModel(parent=self)
853893
newValue = self._desc.validateValue(value)
854894
self.extend(newValue)
855-
self.requestGraphUpdate()
895+
if self.isInput:
896+
self.requestGraphUpdate()
856897

857898
# Override
858899
def _applyExpr(self):
@@ -862,6 +903,20 @@ def _applyExpr(self):
862903
for value in self._value:
863904
value._applyExpr()
864905

906+
def _populateFromDynamicValue(self, value):
907+
"""Store raw dynamic values for lazy materialization.
908+
909+
Does NOT create QObject children — safe to call from any thread.
910+
The actual ListModel population happens lazily in _getValue()
911+
when the main thread (e.g. QML) reads the value.
912+
"""
913+
with self._dynamicValueLock:
914+
if value is None:
915+
self._dynamicValue = None # pending reset
916+
else:
917+
self._dynamicValue = self._desc.validateValue(value)
918+
self.valueChanged.emit()
919+
865920
# Override
866921
def resetToDefaultValue(self):
867922
self._value = ListModel(parent=self)
@@ -877,8 +932,57 @@ def getSerializedValue(self):
877932
return self._getInputLink().asLinkExpr()
878933
return [attr.getSerializedValue() for attr in self._value]
879934

935+
value = Property(Variant, Attribute._getValue, _setValue, notify=Attribute.valueChanged)
936+
937+
# Override
938+
def _resolveValue(self):
939+
"""
940+
Lazily materialize QObject children from pending raw dynamic values.
941+
Called by Attribute._getValue (base) before returning self._value.
942+
This hook dispatches via normal Python MRO, bypassing PySide Property
943+
getter dispatch limitations.
944+
Must only create QObjects on the main thread to avoid cross-thread issues.
945+
"""
946+
if self._dynamicValue is not ListAttribute._NO_PENDING_VALUE:
947+
if threading.current_thread() is threading.main_thread():
948+
self._materializeDynamicValue()
949+
950+
def _materializeDynamicValue(self):
951+
"""
952+
Create QObject children in the ListModel from pending raw dynamic values.
953+
Must only be called on the main thread.
954+
"""
955+
956+
# Thread proof reading of dynamic value
957+
with self._dynamicValueLock:
958+
pendingValue = self._dynamicValue
959+
self._dynamicValue = ListAttribute._NO_PENDING_VALUE
960+
961+
# Create an empty list if the value is None
962+
if self._value is None:
963+
self._value = ListModel(parent=self)
964+
elif len(self._value) > 0:
965+
# Erase all items before reassigning
966+
self._value.removeAt(0, len(self._value))
967+
968+
# Effectively create the objects from the raw data
969+
if pendingValue:
970+
attrs = [attributeFactory(self._desc.elementDesc, v, self.isOutput, self.node, self)
971+
for v in pendingValue]
972+
self._value.insert(0, attrs)
973+
974+
self.valueChanged.emit()
975+
880976
# Override
881977
def getPrimitiveValue(self, exportDefault=True):
978+
# If there is a pending dynamic value (set or reset), return it directly
979+
# without touching the ListModel (which may be on a different thread).
980+
with self._dynamicValueLock:
981+
if self._dynamicValue is not ListAttribute._NO_PENDING_VALUE:
982+
if self._dynamicValue is None:
983+
return []
984+
return list(self._dynamicValue)
985+
882986
if exportDefault:
883987
return [attr.getPrimitiveValue(exportDefault=exportDefault) for attr in self._value]
884988
return [attr.getPrimitiveValue(exportDefault=exportDefault) for attr in self._value

meshroom/core/desc/attribute.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -135,13 +135,15 @@ def matchDescription(self, value, strict=True):
135135
class ListAttribute(Attribute):
136136
""" A list of Attributes """
137137
def __init__(self, elementDesc, name, label, description, group="allParams", advanced=False, semantic="",
138-
enabled=True, joinChar=" ", visible=True, exposed=False):
138+
enabled=True, joinChar=" ", visible=True, exposed=False, value=[]):
139139
"""
140140
:param elementDesc: the Attribute description of elements to store in that list
141+
:param value: default value. Use None to declare a dynamic output ListAttribute
142+
whose content is set during processChunk.
141143
"""
142144
self._elementDesc = elementDesc
143145
self._joinChar = joinChar
144-
super(ListAttribute, self).__init__(name=name, label=label, description=description, value=[],
146+
super(ListAttribute, self).__init__(name=name, label=label, description=description, value=value,
145147
invalidate=False, group=group, advanced=advanced, semantic=semantic,
146148
enabled=enabled, visible=visible, exposed=exposed)
147149

meshroom/core/node.py

Lines changed: 18 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1732,22 +1732,35 @@ def resetOutputAttr(self):
17321732
def loadOutputAttr(self):
17331733
""" Load output attributes with dynamic values from a values.json file.
17341734
"""
1735+
1736+
# This does not apply to non dynamic output
17351737
if not self.nodeDesc.hasDynamicOutputAttribute:
17361738
return
1739+
1740+
# Check existence of values.json file
17371741
valuesFile = self.valuesFile
17381742
if not os.path.exists(valuesFile):
17391743
logging.warning(f"No output attr file: {valuesFile}")
17401744
return
17411745

1742-
# logging.warning("load output attr: {}, value: {}".format(self.name, valuesFile))
1746+
# Open json file and parse
17431747
with open(valuesFile) as jsonFile:
17441748
data = json.load(jsonFile)
17451749

1746-
# logging.warning(data)
1750+
# loop over all output attributes in the node description
17471751
for output in self.nodeDesc.outputs:
1752+
# Only consider dynamic values
17481753
if output.isDynamicValue:
17491754
if self.hasAttribute(output.name) and output.name in data:
1750-
self.attribute(output.name).value = data[output.name]
1755+
attr = self.attribute(output.name)
1756+
1757+
# Use _populateFromDynamicValue for compatible classes
1758+
# (E.g. for ListAttributes) to properly
1759+
# create QObject children on the main thread
1760+
if hasattr(attr, '_populateFromDynamicValue'):
1761+
attr._populateFromDynamicValue(data[output.name])
1762+
else:
1763+
attr.value = data[output.name]
17511764
else:
17521765
if not self.hasAttribute(output.name):
17531766
logging.warning(f"loadOutputAttr: Missing dynamic output attribute. Node={self.name}, "
@@ -1765,7 +1778,8 @@ def saveOutputAttr(self):
17651778
for output in self.nodeDesc.outputs:
17661779
if output.isDynamicValue:
17671780
if self.hasAttribute(output.name):
1768-
data[output.name] = self.attribute(output.name).value
1781+
# Store the primitive value and not the value itself
1782+
data[output.name] = self.attribute(output.name).getPrimitiveValue()
17691783
else:
17701784
logging.warning(f"saveOutputAttr: Missing dynamic output attribute: {self.name}.{output.name}")
17711785

tests/test_nodeDynamicOutputs.py

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,34 @@ def process(self, node):
8585
node.floatOutput.value = node.floatInput.value * 2.0
8686

8787

88+
class NodeWithDynamicListOutput(desc.Node):
89+
"""Node with a dynamic output ListAttribute set during processChunk."""
90+
inputs = [
91+
desc.ListAttribute(
92+
name="listInput",
93+
label="List Input",
94+
description="A list of strings as input.",
95+
elementDesc=desc.StringParam(name="value", label="Value", description="", value=""),
96+
),
97+
]
98+
99+
outputs = [
100+
desc.ListAttribute(
101+
name="listOutput",
102+
label="List Output",
103+
description="A dynamic list output set during processing.",
104+
elementDesc=desc.StringParam(name="value", label="Value", description="", value=""),
105+
value=None,
106+
),
107+
]
108+
109+
def processChunk(self, chunk):
110+
# Read input list and produce an output list with uppercased values
111+
inputValues = [attr.value for attr in chunk.node.listInput.value]
112+
outputValues = [v.upper() for v in inputValues]
113+
chunk.node.listOutput.value = outputValues
114+
115+
88116
class InputNodeWithDynamicOutputs(desc.InputNode):
89117
inputs = [
90118
desc.File(
@@ -220,3 +248,77 @@ def test_registerInputNodeWithDynamicOutputsV2(self):
220248
# InputDynamicOutputs has the same description as InputNodeWithDynamicOutputs: had it been valid, it would
221249
# have been loaded and registered by the plugin manager at the upper level of the test suite.
222250
graph.addNewNode("InputDynamicOutputs")
251+
252+
253+
class TestDynamicListOutputs:
254+
"""Tests for dynamic output ListAttribute support."""
255+
256+
@classmethod
257+
def setup_class(cls):
258+
registerNodeDesc(NodeWithDynamicListOutput)
259+
260+
@classmethod
261+
def teardown_class(cls):
262+
unregisterNodeDesc(NodeWithDynamicListOutput)
263+
264+
def test_dynamicListOutputDescriptor(self):
265+
"""Check that a ListAttribute with value=None is correctly flagged as dynamic."""
266+
nodeDesc = NodeWithDynamicListOutput()
267+
assert nodeDesc.hasDynamicOutputAttribute
268+
listOutputDesc = nodeDesc.outputs[0]
269+
assert listOutputDesc.isDynamicValue
270+
271+
def test_processWithDynamicListOutput(self, graphSavedOnDisk):
272+
"""Process a node that sets a dynamic output ListAttribute during processChunk."""
273+
graph: Graph = graphSavedOnDisk
274+
node = graph.addNewNode(NodeWithDynamicListOutput.__name__)
275+
276+
node.listInput.value = ["hello", "world"]
277+
278+
# Execute the node
279+
node.process(inCurrentEnv=True)
280+
281+
# After processChunk, raw values are stored for serialization (thread-safe).
282+
# getPrimitiveValue returns the raw values without requiring QObject children.
283+
assert node.listOutput.getPrimitiveValue() == ["HELLO", "WORLD"]
284+
285+
# loadOutputAttr materializes QObject children (normally on the main thread)
286+
node.loadOutputAttr()
287+
outputValues = [attr.value for attr in node.listOutput.value]
288+
assert outputValues == ["HELLO", "WORLD"]
289+
290+
def test_loadGraphWithComputedDynamicListOutput(self, graphSavedOnDisk):
291+
"""Check that dynamic list output values are persisted and reloaded correctly."""
292+
graph: Graph = graphSavedOnDisk
293+
node = graph.addNewNode(NodeWithDynamicListOutput.__name__)
294+
name = node.name
295+
296+
node.listInput.value = ["foo", "bar", "baz"]
297+
graph.save()
298+
299+
# Execute the node
300+
node.process(inCurrentEnv=True)
301+
302+
# Verify raw output was stored
303+
assert node.listOutput.getPrimitiveValue() == ["FOO", "BAR", "BAZ"]
304+
305+
# Reload the graph — loadOutputAttr populates the ListModel on the main thread
306+
loadedGraph = loadGraph(graph.filepath)
307+
loadedNode = loadedGraph.node(name)
308+
309+
assert loadedNode
310+
loadedOutputValues = [attr.value for attr in loadedNode.listOutput.value]
311+
assert loadedOutputValues == ["FOO", "BAR", "BAZ"]
312+
313+
def test_loadGraphWithUncomputedDynamicListOutput(self, graphSavedOnDisk):
314+
"""Check that an uncomputed dynamic list output is empty after loading."""
315+
graph: Graph = graphSavedOnDisk
316+
node = graph.addNewNode(NodeWithDynamicListOutput.__name__)
317+
graph.save()
318+
319+
loadedGraph = loadGraph(graph.filepath)
320+
loadedNode = loadedGraph.node(node.name)
321+
322+
assert loadedNode
323+
# Uncomputed dynamic list output should be empty
324+
assert len(loadedNode.listOutput) == 0

0 commit comments

Comments
 (0)