Add "minimal" mode for template files

Add a specific option to save a graph as a template ("Save As
Template") in "minimal" mode.

This mode only saves, for each node in the graph, the input and
output attributes whose values differ from the default ones. Any
attribute that is not serialized in the saved template file is
assumed to have its default value.
If a conflict is detected on a node when opening the template
file, it is logged but solved automatically.

The goal of this minimal mode is to prevent template files from
needing an update every single time a modification (may it be
minor or major) is done on a node description. Templates can
thus follow node changes and still be usable.
This commit is contained in:
Candice Bentéjac 2022-09-01 17:32:18 +02:00
parent 1f800aefd5
commit c44b2a8c00
4 changed files with 90 additions and 11 deletions

View file

@ -261,6 +261,9 @@ class Graph(BaseObject):
self.header = fileData.get(Graph.IO.Keys.Header, {}) self.header = fileData.get(Graph.IO.Keys.Header, {})
nodesVersions = self.header.get(Graph.IO.Keys.NodesVersions, {}) nodesVersions = self.header.get(Graph.IO.Keys.NodesVersions, {})
# check whether the file was saved as a template in minimal mode
isTemplate = self.header.get("template", False)
with GraphModification(self): with GraphModification(self):
# iterate over nodes sorted by suffix index in their names # iterate over nodes sorted by suffix index in their names
for nodeName, nodeData in sorted(graphData.items(), key=lambda x: self.getNodeIndexFromName(x[0])): for nodeName, nodeData in sorted(graphData.items(), key=lambda x: self.getNodeIndexFromName(x[0])):
@ -273,7 +276,8 @@ class Graph(BaseObject):
# 3. fallback to no version "0.0": retro-compatibility # 3. fallback to no version "0.0": retro-compatibility
if "version" not in nodeData: if "version" not in nodeData:
nodeData["version"] = nodesVersions.get(nodeData["nodeType"], "0.0") nodeData["version"] = nodesVersions.get(nodeData["nodeType"], "0.0")
n = nodeFactory(nodeData, nodeName)
n = nodeFactory(nodeData, nodeName, template=isTemplate)
# Add node to the graph with raw attributes values # Add node to the graph with raw attributes values
self._addNode(n, nodeName) self._addNode(n, nodeName)
@ -999,7 +1003,7 @@ class Graph(BaseObject):
def asString(self): def asString(self):
return str(self.toDict()) return str(self.toDict())
def save(self, filepath=None, setupProjectFile=True): def save(self, filepath=None, setupProjectFile=True, template=False):
path = filepath or self._filepath path = filepath or self._filepath
if not path: if not path:
raise ValueError("filepath must be specified for unsaved files.") raise ValueError("filepath must be specified for unsaved files.")
@ -1015,10 +1019,18 @@ class Graph(BaseObject):
for p in usedNodeTypes for p in usedNodeTypes
} }
data = { data = {}
Graph.IO.Keys.Header: self.header, if template:
Graph.IO.Keys.Graph: self.toDict() self.header["template"] = True
} data = {
Graph.IO.Keys.Header: self.header,
Graph.IO.Keys.Graph: self.getNonDefaultAttributes()
}
else:
data = {
Graph.IO.Keys.Header: self.header,
Graph.IO.Keys.Graph: self.toDict()
}
with open(path, 'w') as jsonFile: with open(path, 'w') as jsonFile:
json.dump(data, jsonFile, indent=4) json.dump(data, jsonFile, indent=4)
@ -1026,6 +1038,35 @@ class Graph(BaseObject):
if path != self._filepath and setupProjectFile: if path != self._filepath and setupProjectFile:
self._setFilepath(path) self._setFilepath(path)
def getNonDefaultAttributes(self):
"""
Instead of getting all the inputs/outputs attribute keys, only get the keys of
the attributes whose value is not the default one.
Returns:
dict: self.toDict() with all the inputs/outputs attributes with default values removed
"""
graph = self.toDict()
for nodeName in graph.keys():
node = self.node(nodeName)
inputKeys = list(graph[nodeName]["inputs"].keys())
outputKeys = list(graph[nodeName]["outputs"].keys())
for attrName in inputKeys:
attribute = node.attribute(attrName)
# check that attribute is not a link for choice attributes
if attribute.isDefault and not attribute.isLink:
del graph[nodeName]["inputs"][attrName]
for attrName in outputKeys:
attribute = node.attribute(attrName)
# check that attribute is not a link for choice attributes
if attribute.isDefault and not attribute.isLink:
del graph[nodeName]["outputs"][attrName]
return graph
def _setFilepath(self, filepath): def _setFilepath(self, filepath):
""" """
Set the internal filepath of this Graph. Set the internal filepath of this Graph.

View file

@ -1393,7 +1393,7 @@ class CompatibilityNode(BaseNode):
issueDetails = Property(str, issueDetails.fget, constant=True) issueDetails = Property(str, issueDetails.fget, constant=True)
def nodeFactory(nodeDict, name=None): def nodeFactory(nodeDict, name=None, template=False):
""" """
Create a node instance by deserializing the given node data. Create a node instance by deserializing the given node data.
If the serialized data matches the corresponding node type description, a Node instance is created. If the serialized data matches the corresponding node type description, a Node instance is created.
@ -1437,9 +1437,10 @@ def nodeFactory(nodeDict, name=None):
compatibilityIssue = CompatibilityIssue.VersionConflict compatibilityIssue = CompatibilityIssue.VersionConflict
# in other cases, check attributes compatibility between serialized node and its description # in other cases, check attributes compatibility between serialized node and its description
else: else:
# check that the node has the exact same set of inputs/outputs as its description # check that the node has the exact same set of inputs/outputs as its description, except if the node
if sorted([attr.name for attr in nodeDesc.inputs]) != sorted(inputs.keys()) or \ # is described in a template file, in which only non-default parameters are saved
sorted([attr.name for attr in nodeDesc.outputs]) != sorted(outputs.keys()): if not template and (sorted([attr.name for attr in nodeDesc.inputs]) != sorted(inputs.keys()) or \
sorted([attr.name for attr in nodeDesc.outputs]) != sorted(outputs.keys())):
compatibilityIssue = CompatibilityIssue.DescriptionConflict compatibilityIssue = CompatibilityIssue.DescriptionConflict
# verify that all inputs match their descriptions # verify that all inputs match their descriptions
for attrName, value in inputs.items(): for attrName, value in inputs.items():
@ -1463,5 +1464,7 @@ def nodeFactory(nodeDict, name=None):
if not internalFolder and nodeDesc: if not internalFolder and nodeDesc:
logging.warning("No serialized output data: performing automatic upgrade on '{}'".format(name)) logging.warning("No serialized output data: performing automatic upgrade on '{}'".format(name))
node = node.upgrade() node = node.upgrade()
elif template: # if the node comes from a template file and there is a conflict, it should be upgraded anyway
node = node.upgrade()
return node return node

View file

@ -354,6 +354,14 @@ class UIGraph(QObject):
@Slot(QUrl) @Slot(QUrl)
def saveAs(self, url): def saveAs(self, url):
self._saveAs(url)
@Slot(QUrl)
def saveAsTemplate(self, url):
self._saveAs(url, setupProjectFile=False, template=True)
def _saveAs(self, url, setupProjectFile=True, template=False):
""" Helper function for 'save as' features. """
if isinstance(url, (str)): if isinstance(url, (str)):
localFile = url localFile = url
else: else:
@ -361,7 +369,7 @@ class UIGraph(QObject):
# ensure file is saved with ".mg" extension # ensure file is saved with ".mg" extension
if os.path.splitext(localFile)[-1] != ".mg": if os.path.splitext(localFile)[-1] != ".mg":
localFile += ".mg" localFile += ".mg"
self._graph.save(localFile) self._graph.save(localFile, setupProjectFile=setupProjectFile, template=template)
self._undoStack.setClean() self._undoStack.setClean()
# saving file on disk impacts cache folder location # saving file on disk impacts cache folder location
# => force re-evaluation of monitored status files paths # => force re-evaluation of monitored status files paths

View file

@ -142,6 +142,22 @@ ApplicationWindow {
onRejected: closed(Platform.Dialog.Rejected) onRejected: closed(Platform.Dialog.Rejected)
} }
Platform.FileDialog {
id: saveTemplateDialog
signal closed(var result)
title: "Save Template"
nameFilters: ["Meshroom Graphs (*.mg)"]
defaultSuffix: ".mg"
fileMode: Platform.FileDialog.SaveFile
onAccepted: {
_reconstruction.saveAsTemplate(file)
closed(Platform.Dialog.Accepted)
}
onRejected: closed(Platform.Dialog.Rejected)
}
Item { Item {
id: computeManager id: computeManager
@ -550,6 +566,17 @@ ApplicationWindow {
saveFileDialog.open() saveFileDialog.open()
} }
} }
Action {
id: saveAsTemplateAction
text: "Save As Template..."
shortcut: "Ctrl+Shift+T"
onTriggered: {
if(_reconstruction.graph && _reconstruction.graph.filepath) {
saveTemplateDialog.folder = Filepath.stringToUrl(Filepath.dirname(_reconstruction.graph.filepath))
}
saveTemplateDialog.open()
}
}
MenuSeparator { } MenuSeparator { }
Action { Action {
text: "Quit" text: "Quit"