Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Plugin System #2407

Open
wants to merge 11 commits into
base: develop
Choose a base branch
from
Open
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 43 additions & 0 deletions .bandit
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
[tool.bandit]
skips = [B101, B102, B105, B106, B107, B113, B202, B401, B402, B403, B404, B405, B406, B407, B408, B409, B410, B413, B307, B311, B507, B602, B603, B605, B607, B610, B611, B703]

[tool.bandit.any_other_function_with_shell_equals_true]
no_shell = [
"os.execl",
"os.execle",
"os.execlp",
"os.execlpe",
"os.execv",
"os.execve",
"os.execvp",
"os.execvpe",
"os.spawnl",
"os.spawnle",
"os.spawnlp",
"os.spawnlpe",
"os.spawnv",
"os.spawnve",
"os.spawnvp",
"os.spawnvpe",
"os.startfile"
]
shell = [
"os.system",
"os.popen",
"os.popen2",
"os.popen3",
"os.popen4",
"popen2.popen2",
"popen2.popen3",
"popen2.popen4",
"popen2.Popen3",
"popen2.Popen4",
"commands.getoutput",
"commands.getstatusoutput"
]
subprocess = [
"subprocess.Popen",
"subprocess.call",
"subprocess.check_call",
"subprocess.check_output"
]
8 changes: 8 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -68,6 +68,14 @@ You can create custom nodes in python and make them available in Meshroom using
In a standard precompiled version of Meshroom, you can also directly add custom nodes in `lib/meshroom/nodes`.
To be recognized by Meshroom, a custom folder with nodes should be a Python module (an `__init__.py` file is needed).

### Plugins

Meshroom supports installing containerised plugins via Docker (with the [NVIDIA Container Toolkit](https://docs.nvidia.com/datacenter/cloud-native/container-toolkit/latest/install-guide.html)) or [Anaconda](https://docs.anaconda.com/free/miniconda/index.html).

To do so, make sure docker or anaconda is installed properly and available from the command line.
Then click on `File > Advanced > Install Plugin From URL` or `File > Advanced > Install Plugin From Local Folder` to begin the installation.

To learn more about using or creating plugins, check the explanations [here](meshroom/plugins/README.md).

## License

20 changes: 12 additions & 8 deletions meshroom/core/__init__.py
Original file line number Diff line number Diff line change
@@ -19,20 +19,27 @@
pass

from meshroom.core.submitter import BaseSubmitter
from . import desc
from meshroom.core import desc

# Setup logging
logging.basicConfig(format='[%(asctime)s][%(levelname)s] %(message)s', level=logging.INFO)

# make a UUID based on the host ID and current time
sessionUid = str(uuid.uuid1())

cacheFolderName = 'MeshroomCache'
defaultCacheFolder = os.environ.get('MESHROOM_CACHE', os.path.join(tempfile.gettempdir(), cacheFolderName))
nodesDesc = {}
submitters = {}
pipelineTemplates = {}

#meshroom paths
meshroomFolder = os.path.dirname(os.path.dirname(__file__))
cacheFolderName = 'MeshroomCache'
defaultCacheFolder = os.environ.get('MESHROOM_CACHE', os.path.join(tempfile.gettempdir(), cacheFolderName))

#plugin paths
pluginsNodesFolder = os.path.join(meshroomFolder, "plugins")
pluginsPipelinesFolder = os.path.join(meshroomFolder, "pipelines")
pluginCatalogFile = os.path.join(meshroomFolder, "plugins", "catalog.json")

def hashValue(value):
""" Hash 'value' using sha1. """
@@ -329,29 +336,26 @@ def loadPipelineTemplates(folder):


def initNodes():
meshroomFolder = os.path.dirname(os.path.dirname(__file__))
additionalNodesPath = os.environ.get("MESHROOM_NODES_PATH", "").split(os.pathsep)
# filter empty strings
additionalNodesPath = [i for i in additionalNodesPath if i]
nodesFolders = [os.path.join(meshroomFolder, 'nodes')] + additionalNodesPath
nodesFolders = [os.path.join(meshroomFolder, 'nodes')] + additionalNodesPath + [pluginsNodesFolder]
for f in nodesFolders:
loadAllNodes(folder=f)


def initSubmitters():
meshroomFolder = os.path.dirname(os.path.dirname(__file__))
subs = loadSubmitters(os.environ.get("MESHROOM_SUBMITTERS_PATH", meshroomFolder), 'submitters')
for sub in subs:
registerSubmitter(sub())


def initPipelines():
meshroomFolder = os.path.dirname(os.path.dirname(__file__))
# Load pipeline templates: check in the default folder and any folder the user might have
# added to the environment variable
additionalPipelinesPath = os.environ.get("MESHROOM_PIPELINE_TEMPLATES_PATH", "").split(os.pathsep)
additionalPipelinesPath = [i for i in additionalPipelinesPath if i]
pipelineTemplatesFolders = [os.path.join(meshroomFolder, 'pipelines')] + additionalPipelinesPath
pipelineTemplatesFolders = [os.path.join(meshroomFolder, 'pipelines')] + additionalPipelinesPath + [pluginsPipelinesFolder]
for f in pipelineTemplatesFolders:
if os.path.isdir(f):
loadPipelineTemplates(f)
59 changes: 58 additions & 1 deletion meshroom/core/desc.py
Original file line number Diff line number Diff line change
@@ -717,9 +717,56 @@
documentation = ''
category = 'Other'

_isPlugin = True

def __init__(self):
super(Node, self).__init__()
self.hasDynamicOutputAttribute = any(output.isDynamicValue for output in self.outputs)
try:
self.envFile
self.envType
except:

Check notice on line 728 in meshroom/core/desc.py

codefactor.io / CodeFactor

meshroom/core/desc.py#L728

Do not use bare 'except'. (E722)
self._isPlugin=False

@property
def envType(cls):
from core.plugin import EnvType #lazy import for plugin to avoid circular dependency
return EnvType.NONE

@property
def envFile(cls):
"""
Env file used to build the environement, you may overwrite this to custom the behaviour
"""
raise NotImplementedError("You must specify an env file")

@property
def _envName(cls):
"""
Get the env name by hashing the env files, overwrite this to use a custom pre-build env
"""
from meshroom.core.plugin import getEnvName, EnvType #lazy import as to avoid circular dep
if cls.envType.value == EnvType.REZ.value:
return cls.envFile
with open(cls.envFile, 'r') as file:
envContent = file.read()

return getEnvName(envContent)

@property
def isPlugin(self):
"""
Tests if the node is a valid plugin node
"""
return self._isPlugin

@property
def isBuilt(self):
"""
Tests if the environnement is built
"""
from meshroom.core.plugin import isBuilt
return self._isPlugin and isBuilt(self)

def upgradeAttributeValues(self, attrValues, fromVersion):
return attrValues
@@ -806,7 +853,17 @@
if chunk.node.isParallelized and chunk.node.size > 1:
cmdSuffix = ' ' + self.commandLineRange.format(**chunk.range.toDict())

return cmdPrefix + chunk.node.nodeDesc.commandLine.format(**chunk.node._cmdVars) + cmdSuffix
cmd=cmdPrefix + chunk.node.nodeDesc.commandLine.format(**chunk.node._cmdVars) + cmdSuffix

#the process in Popen does not seem to use the right python, even if meshroom_compute is called within the env
#so in the case of command line using python, we have to make sure it is using the correct python
from meshroom.core.plugin import EnvType, getVenvPath, getVenvExe #lazy import to prevent circular dep
if self.isPlugin and self.envType == EnvType.VENV:
envPath = getVenvPath(self._envName)
envExe = getVenvExe(envPath)
cmd=cmd.replace("python", envExe)

return cmd

def stopProcess(self, chunk):
# The same node could exists several times in the graph and
103 changes: 66 additions & 37 deletions meshroom/core/node.py
Original file line number Diff line number Diff line change
@@ -21,7 +21,6 @@
from meshroom.core.attribute import attributeFactory, ListAttribute, GroupAttribute, Attribute
from meshroom.core.exception import NodeUpgradeError, UnknownNodeTypeError


def getWritingFilepath(filepath):
return filepath + '.writing.' + str(uuid.uuid4())

@@ -51,6 +50,8 @@
KILLED = 5
SUCCESS = 6
INPUT = 7 # Special status for input nodes
BUILD = 8
FIRST_RUN = 9


class ExecMode(Enum):
@@ -380,16 +381,16 @@
renameWritingToFinalPath(statisticsFilepathWriting, statisticsFilepath)

def isAlreadySubmitted(self):
return self._status.status in (Status.SUBMITTED, Status.RUNNING)
return self._status.status in (Status.SUBMITTED, Status.RUNNING, Status.BUILD, Status.FIRST_RUN)

def isAlreadySubmittedOrFinished(self):
return self._status.status in (Status.SUBMITTED, Status.RUNNING, Status.SUCCESS)
return self._status.status in (Status.SUBMITTED, Status.RUNNING, Status.SUCCESS, Status.BUILD, Status.FIRST_RUN)

def isFinishedOrRunning(self):
return self._status.status in (Status.SUCCESS, Status.RUNNING)
return self._status.status in (Status.SUCCESS, Status.RUNNING, Status.BUILD, Status.FIRST_RUN)

def isRunning(self):
return self._status.status == Status.RUNNING
return self._status.status in (Status.RUNNING, Status.BUILD, Status.FIRST_RUN)

def isStopped(self):
return self._status.status == Status.STOPPED
@@ -401,36 +402,57 @@
if not forceCompute and self._status.status == Status.SUCCESS:
logging.info("Node chunk already computed: {}".format(self.name))
return
global runningProcesses
runningProcesses[self.name] = self
self._status.initStartCompute()
exceptionStatus = None
startTime = time.time()
self.upgradeStatusTo(Status.RUNNING)
self.statThread = stats.StatisticsThread(self)
self.statThread.start()
try:
self.node.nodeDesc.processChunk(self)
except Exception:
if self._status.status != Status.STOPPED:
exceptionStatus = Status.ERROR
raise
except (KeyboardInterrupt, SystemError, GeneratorExit):
exceptionStatus = Status.STOPPED
raise
finally:
self._status.initEndCompute()
self._status.elapsedTime = time.time() - startTime
if exceptionStatus is not None:
self.upgradeStatusTo(exceptionStatus)
logging.info(" - elapsed time: {}".format(self._status.elapsedTimeStr))
# Ask and wait for the stats thread to stop
self.statThread.stopRequest()
self.statThread.join()
self.statistics = stats.Statistics()
del runningProcesses[self.name]

self.upgradeStatusTo(Status.SUCCESS)

#if plugin node and if first call call meshroom_compute inside the env on 'host' so that the processchunk
# of the node will be ran into the env
if self.node.nodeDesc.isPlugin and self._status.status!=Status.FIRST_RUN:
try:
from meshroom.core.plugin import isBuilt, build, getCommandLine #lazy import to avoid circular dep
if not isBuilt(self.node.nodeDesc):
self.upgradeStatusTo(Status.BUILD)
build(self.node.nodeDesc)
self.upgradeStatusTo(Status.FIRST_RUN)
command = getCommandLine(self)
#NOTE: docker returns 0 even if mount fail (it fails on the deamon side)
logging.info("Running plugin node with "+command)
status = os.system(command)
if status != 0:
raise RuntimeError("Error in node execution")
self.updateStatusFromCache()
except Exception as ex:
self.logger.exception(ex)
self.upgradeStatusTo(Status.ERROR)
else:
global runningProcesses
runningProcesses[self.name] = self
self._status.initStartCompute()
exceptionStatus = None
startTime = time.time()
self.upgradeStatusTo(Status.RUNNING)
self.statThread = stats.StatisticsThread(self)
self.statThread.start()
try:
self.node.nodeDesc.processChunk(self)
except Exception:
if self._status.status != Status.STOPPED:
exceptionStatus = Status.ERROR
raise
except (KeyboardInterrupt, SystemError, GeneratorExit):
exceptionStatus = Status.STOPPED
raise
finally:
self._status.initEndCompute()
self._status.elapsedTime = time.time() - startTime
if exceptionStatus is not None:
self.upgradeStatusTo(exceptionStatus)
logging.info(" - elapsed time: {}".format(self._status.elapsedTimeStr))
# Ask and wait for the stats thread to stop
self.statThread.stopRequest()
self.statThread.join()
self.statistics = stats.Statistics()
del runningProcesses[self.name]

self.upgradeStatusTo(Status.SUCCESS)

def stopProcess(self):
if not self.isExtern():
@@ -460,6 +482,7 @@
statusNodeName = Property(str, lambda self: self._status.nodeName, constant=True)

elapsedTime = Property(float, lambda self: self._status.elapsedTime, notify=statusChanged)



# Simple structure for storing node position
@@ -1130,8 +1153,8 @@
return Status.INPUT
chunksStatus = [chunk.status.status for chunk in self._chunks]

anyOf = (Status.ERROR, Status.STOPPED, Status.KILLED,
Status.RUNNING, Status.SUBMITTED)
anyOf = (Status.ERROR, Status.STOPPED, Status.KILLED, Status.RUNNING, Status.BUILD, Status.FIRST_RUN,
Status.SUBMITTED,)
allOf = (Status.SUCCESS,)

for status in anyOf:
@@ -1394,6 +1417,12 @@
hasSequenceOutput = Property(bool, hasSequenceOutputAttribute, notify=outputAttrEnabledChanged)
has3DOutput = Property(bool, has3DOutputAttribute, notify=outputAttrEnabledChanged)

isPlugin = Property(bool, lambda self: self.nodeDesc.isPlugin if self.nodeDesc is not None else False, constant=True)

isEnvBuild = (not isPlugin) #init build status false its not a plugin
buildStatusChanged = Signal() #event to notify change in status
isBuiltStatus = Property(bool, lambda self: self.isEnvBuild, notify = buildStatusChanged)

Check notice on line 1424 in meshroom/core/node.py

codefactor.io / CodeFactor

meshroom/core/node.py#L1424

Multiple spaces after ','. (E241)

Check notice on line 1424 in meshroom/core/node.py

codefactor.io / CodeFactor

meshroom/core/node.py#L1424

Multiple spaces before operator. (E221)
# isBuiltStatus = Property(bool, lambda self: self.nodeDesc.isBuilt, constant=True)

class Node(BaseNode):
"""
376 changes: 376 additions & 0 deletions meshroom/core/plugin.py

Large diffs are not rendered by default.

40 changes: 40 additions & 0 deletions meshroom/plugins/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
# Intro

A plugin is a set of one or several nodes that are not part of the Meshroom/Alicevision main project.
They are meant to facilitate the creation of custom pipeline, make distribution and installation of extra nodes easy, and allow the use of different level of isolation at the node level.
Each node within a plugin may use the same or differents environnement.

# Making Meshroom Plugins

To make a new plugin, make your node inheriting from `meshroom.core.plugins.PluginNode`
In your new node class, overwrite the variable `envFile` to point to the environment file (e.g. the `yaml` or `dockerfile`) that sets up your installation, end `envType` to specify the type of plugin. The path to this file should be relative to the path of the node, and within the same folder (or subsequent child folder) as the node definition.

The code in `processChunk` in your node definition will be automatically executed within the envirenoment, using `meshroom_compute`.
A new status `FIRST_RUN` denotes the stage in between the environement startup and the execution of the node.

Make sur your imports are lazy, in `processChunk`.
Several nodes share the same environment as long as they point to the same environment file.
Changing this file will trigger a rebuild on the environment.

You may install plugin from a git repository or from a local folder. In the later case, you may edit the code directly from your source folder.

By default, Meshroom will look for node definition in `[plugin folder]/meshroomNodes` and new pipelines in `[plugin folder]/meshroomPipelines` and assumes only one environement is needed.

To modify this behavior, you may put a json file named `meshroomPlugin.json` at the root of your folder/repository.
The file must have the following structure:
```
[
{
"pluginName":"[YOUR_PLUGIN_NAME]",
"nodesFolder":"[YOUR_FOLDER_RELATIVE_TO_THE_ROOT_REPO_OR_FOLDER],
"pipelineFolder":"[YOUR_CUSTOM_PIEPILINE_FOLDER"
},
{
"pluginName":"Dummy Plugin",
"nodesFolder":"dummy"
}
]
```

The environment of the nodes are going to be build the first time it is needed (status will be `BUILD`, in purple).

9 changes: 9 additions & 0 deletions meshroom/plugins/catalog.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
[
{
"pluginName":"Meshroom Research",
"pluginUrl":"https://github.com/alicevision/MeshroomResearch/",
"description":"Meshroom-Research comprises a collection of experimental plugins for Meshroom",
"isCollection":true,
"nodeTypes":["Python", "Docker", "Conda"]
}
]
31 changes: 28 additions & 3 deletions meshroom/ui/graph.py
Original file line number Diff line number Diff line change
@@ -44,6 +44,7 @@ def __init__(self, parent=None):
self._stopFlag = Event()
self._refreshInterval = 5 # refresh interval in seconds
self._files = []
self._nodes = []
if submitters:
self._filePollerRefresh = PollerRefreshStatus.MINIMAL_ENABLED
else:
@@ -53,7 +54,7 @@ def __del__(self):
self._threadPool.terminate()
self._threadPool.join()

def start(self, files=None):
def start(self, files=None, nodes=None):
""" Start polling thread.
Args:
@@ -66,6 +67,7 @@ def start(self, files=None):
return
self._stopFlag.clear()
self._files = files or []
self._nodes = nodes or []
self._thread = Thread(target=self.run)
self._thread.start()

@@ -78,6 +80,15 @@ def setFiles(self, files):
with self._mutex:
self._files = files

def setNodes(self, nodes):
""" Set the list of nodes to monitor
Args:
nodes: the list of nodes to monitor
"""
with self._mutex:
self._nodes = nodes

def stop(self):
""" Request polling thread to stop. """
if not self._thread:
@@ -94,6 +105,16 @@ def getFileLastModTime(f):
except OSError:
return -1

@staticmethod
def updatePluginEnvStatus(n):
""" Will update the status of the plugin env """
if n.nodeDesc is not None:
try:
n.isEnvBuild=n.nodeDesc.isBuilt
n.buildStatusChanged.emit()
except Exception as E:
logging.warn("Plugin status update failed, node may be already deleted")

def run(self):
""" Poll watched files for last modification time. """
while not self._stopFlag.wait(self._refreshInterval):
@@ -103,6 +124,8 @@ def run(self):
with self._mutex:
if files == self._files:
self.timesAvailable.emit(times)
#update plugin nodes
_ = self._threadPool.map(self.updatePluginEnvStatus, self._nodes)

def onFilePollerRefreshChanged(self, value):
""" Stop or start the file poller depending on the new refresh status. """
@@ -116,7 +139,6 @@ def onFilePollerRefreshChanged(self, value):
filePollerRefresh = Property(int, lambda self: self._filePollerRefresh.value, constant=True)
filePollerRefreshReady = Signal() # The refresh status has been updated and is ready to be used


class ChunksMonitor(QObject):
"""
ChunksMonitor regularly check NodeChunks' status files for modification and trigger their update on change.
@@ -147,6 +169,8 @@ def setChunks(self, chunks):
self.monitorableChunks = chunks
files, monitoredChunks = self.watchedStatusFiles
self._filesTimePoller.setFiles(files)
pluginNodes = [c.node for c in chunks if c.node.isPlugin]
self._filesTimePoller.setNodes(pluginNodes)
self.monitoredChunks = monitoredChunks

def stop(self):
@@ -172,7 +196,8 @@ def watchedStatusFiles(self):
elif self.filePollerRefresh is PollerRefreshStatus.MINIMAL_ENABLED.value:
for c in self.monitorableChunks:
# When a chunk's status is ERROR, it may be externally re-submitted and it should thus still be monitored
if c._status.status is Status.SUBMITTED or c._status.status is Status.RUNNING or c._status.status is Status.ERROR:
#Plugin nodes are always moniotored
if c.node.isPlugin or c._status.status is Status.SUBMITTED or c._status.status is Status.RUNNING or c._status.status is Status.ERROR:
files.append(c.statusFile)
chunks.append(c)
return files, chunks
107 changes: 107 additions & 0 deletions meshroom/ui/qml/Application.qml
Original file line number Diff line number Diff line change
@@ -131,6 +131,73 @@ Page {
}
}

//File browser for plugin
Dialog {
id: pluginURLDialog
title: "Plugin URL"
height: 150
width: 300
standardButtons: StandardButton.Ok | StandardButton.Cancel
//focus: true
Column {
anchors.fill: parent
Text {
text: "Plugin URL"
height: 40
}
TextField {
id: urlInput
width: parent.width * 0.75
focus: true
}
}
onButtonClicked: {
if (clickedButton==StandardButton.Ok) {
console.log("Accepted " + clickedButton)
if (_reconstruction.installPlugin(urlInput.text)) {
pluginInstalledDialog.open()
} else {
pluginNotInstalledDialog.open()
}
}
}
}

// dialogs for plugins
MessageDialog {
id: pluginInstalledDialog
title: "Plugin installed"
modal: true
canCopy: false
Label {
text: "Plugin installed, please restart meshroom for the changes to take effect"
}
}

MessageDialog {
id: pluginNotInstalledDialog
title: "Plugin not installed"
modal: true
canCopy: false
Label {
text: "Something went wrong, plugin not installed"
}
}

// plugin installation from path or url
Platform.FolderDialog {
id: intallPluginDialog
options: Platform.FolderDialog.DontUseNativeDialog
title: "Install Plugin"
onAccepted: {
if (_reconstruction.installPlugin(currentFolder.toString())) {
pluginInstalledDialog.open()
} else {
pluginNotInstalledDialog.open()
}
}
}

Item {
id: computeManager

@@ -525,6 +592,23 @@ Page {
}
}

Action {
id: installPluginFromFolderAction
text: "Install Plugin From Local Folder"
onTriggered: {
initFileDialogFolder(intallPluginDialog)
intallPluginDialog.open()
}
}

Action {
id: installPluginFromURLAction
text: "Install Plugin From URL"
onTriggered: {
pluginURLDialog.open()
}
}

header: RowLayout {
spacing: 0
MaterialToolButton {
@@ -741,6 +825,18 @@ Page {
ToolTip.visible: hovered
ToolTip.text: removeImagesFromAllGroupsAction.tooltip
}

MenuItem {
action: installPluginFromFolderAction
ToolTip.visible: hovered
ToolTip.text: "Install plugin from a folder"
}

MenuItem {
action: installPluginFromURLAction
ToolTip.visible: hovered
ToolTip.text: "Install plugin from a local or online url"
}
}
MenuSeparator { }
Action {
@@ -1214,6 +1310,17 @@ Page {
var n = _reconstruction.upgradeNode(node)
_reconstruction.selectedNode = n
}

onDoBuild: {
try {
_reconstruction.buildNode(node.name)
node.isNotBuilt=false
} catch (error) {
//NOTE: could do an error popup
console.log("Build error:")
console.log(error)
}
}
}
}
}
14 changes: 13 additions & 1 deletion meshroom/ui/qml/GraphEditor/Node.qml
Original file line number Diff line number Diff line change
@@ -19,6 +19,9 @@ Item {
property bool readOnly: node.locked
/// Whether the node is in compatibility mode
readonly property bool isCompatibilityNode: node ? node.hasOwnProperty("compatibilityIssue") : false
/// Whether the node is a plugin that needs to be build
readonly property bool isPlugin: node ? node.isPlugin : false
property bool isNotBuilt: node ? (!node.isBuiltStatus) : false
/// Mouse related states
property bool mainSelected: false
property bool selected: false
@@ -28,7 +31,7 @@ Item {
property point position: Qt.point(x, y)
/// Styling
property color shadowColor: "#cc000000"
readonly property color defaultColor: isCompatibilityNode ? "#444" : !node.isComputable ? "#BA3D69" : activePalette.base
readonly property color defaultColor: isCompatibilityNode ? "#444" : (!node.isComputable ? "#BA3D69" : activePalette.base)
property color baseColor: defaultColor

property point mousePosition: Qt.point(mouseArea.mouseX, mouseArea.mouseY)
@@ -232,6 +235,15 @@ Item {
issueDetails: root.node.issueDetails
}
}

// ToBuild icon for PluginNodes
Loader {
active: root.isPlugin && root.isNotBuilt
sourceComponent: ToBuildBadge {
sourceComponent: iconDelegate
}
}


// Data sharing indicator
// Note: for an unknown reason, there are some performance issues with the UI refresh.
14 changes: 14 additions & 0 deletions meshroom/ui/qml/GraphEditor/NodeEditor.qml
Original file line number Diff line number Diff line change
@@ -18,9 +18,12 @@ Panel {
property bool readOnly: false
property bool isCompatibilityNode: node && node.compatibilityIssue !== undefined
property string nodeStartDateTime: ""
readonly property bool isPlugin: node ? node.isPlugin : false
property bool isNotBuilt: node ? (!node.isBuiltStatus) : false

signal attributeDoubleClicked(var mouse, var attribute)
signal upgradeRequest()
signal doBuild()

title: "Node" + (node !== null ? " - <b>" + node.label + "</b>" + (node.label !== node.defaultLabel ? " (" + node.defaultLabel + ")" : "") : "")
icon: MaterialLabel { text: MaterialIcons.tune }
@@ -225,6 +228,17 @@ Panel {
}
}

Loader {
active: root.isPlugin && root.isNotBuilt
Layout.fillWidth: true
visible: active // for layout update

sourceComponent: ToBuildBadge {
onDoBuild: root.doBuild()
sourceComponent: bannerDelegate
}
}

Loader {
Layout.fillHeight: true
Layout.fillWidth: true
66 changes: 66 additions & 0 deletions meshroom/ui/qml/GraphEditor/ToBuildBadge.qml
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import QtQuick 2.15
import QtQuick.Controls 2.15
import QtQuick.Layouts 1.11
import MaterialIcons 2.2

Loader {
id: root

sourceComponent: iconDelegate

signal doBuild()

property Component iconDelegate: Component {

Label {
text: MaterialIcons.warning
font.family: MaterialIcons.fontFamily
font.pointSize: 12
color: "#66207F"

MouseArea {
anchors.fill: parent
hoverEnabled: true
onPressed: mouse.accepted = false
ToolTip.text: "Node env needs to be built"
ToolTip.visible: containsMouse
}
}
}

property Component bannerDelegate: Component {

Pane {
padding: 6
clip: true
background: Rectangle { color: "#66207F" }

RowLayout {
width: parent.width
Column {
Layout.fillWidth: true
Label {
width: parent.width
elide: Label.ElideMiddle
font.bold: true
text: "Env needs to be built"
color: "white"
}
Label {
width: parent.width
elide: Label.ElideMiddle
color: "white"
}
}
Button {
visible: (parent.width > width) ? 1 : 0
palette.window: root.color
palette.button: Qt.darker(root.color, 1.2)
palette.buttonText: "white"
text: "Build"
onClicked: doBuild()
}
}
}
}
}
4 changes: 3 additions & 1 deletion meshroom/ui/qml/GraphEditor/common.js
Original file line number Diff line number Diff line change
@@ -5,7 +5,9 @@ var statusColors = {
"RUNNING": "#FF9800",
"ERROR": "#F44336",
"SUCCESS": "#4CAF50",
"STOPPED": "#E91E63"
"STOPPED": "#E91E63",
"BUILD": "#66207f",
"FIRST_RUN": "#A52A2A"
}

var statusColorsExternOverrides = {
3 changes: 2 additions & 1 deletion meshroom/ui/qml/GraphEditor/qmldir
Original file line number Diff line number Diff line change
@@ -9,7 +9,8 @@ AttributePin 1.0 AttributePin.qml
AttributeEditor 1.0 AttributeEditor.qml
AttributeItemDelegate 1.0 AttributeItemDelegate.qml
CompatibilityBadge 1.0 CompatibilityBadge.qml
ToBuildBadge 1.0 ToBuildBadge.qml
CompatibilityManager 1.0 CompatibilityManager.qml
singleton GraphEditorSettings 1.0 GraphEditorSettings.qml
TaskManager 1.0 TaskManager.qml
ScriptEditor 1.0 ScriptEditor.qml
ScriptEditor 1.0 ScriptEditor.qml
6 changes: 5 additions & 1 deletion meshroom/ui/qml/Utils/Colors.qml
Original file line number Diff line number Diff line change
@@ -19,14 +19,18 @@ QtObject {
readonly property color lime: "#CDDC39"
readonly property color grey: "#555555"
readonly property color lightgrey: "#999999"
readonly property color deeppurple: "#66207F"
readonly property color brown: "#A52A2A"

readonly property var statusColors: {
"NONE": "transparent",
"SUBMITTED": cyan,
"RUNNING": orange,
"ERROR": red,
"SUCCESS": green,
"STOPPED": pink
"STOPPED": pink,
"BUILD": deeppurple,
"FIRST_RUN": brown
}

readonly property var ghostColors: {
42 changes: 27 additions & 15 deletions meshroom/ui/reconstruction.py
Original file line number Diff line number Diff line change
@@ -18,6 +18,7 @@
from meshroom.ui.graph import UIGraph
from meshroom.ui.utils import makeProperty
from meshroom.ui.components.filepath import FilepathHelper
from meshroom.core.plugin import installPlugin


class Message(QObject):
@@ -413,6 +414,16 @@ def __init__(self, nodeType, parent=None):
nodeChanged = Signal()
node = makeProperty(QObject, "_node", nodeChanged, resetOnDestroy=True)

def prepareUrlLocalFile(url):
if isinstance(url, (QUrl)):
# depending how the QUrl has been initialized,
# toLocalFile() may return the local path or an empty string
localFile = url.toLocalFile()
if not localFile:
localFile = url.toString()
else:
localFile = url
return localFile

class Reconstruction(UIGraph):
"""
@@ -567,16 +578,22 @@ def load(self, filepath, setupProjectFile=True, publishOutputs=False):
@Slot(QUrl, result=bool)
@Slot(QUrl, bool, bool, result=bool)
def loadUrl(self, url, setupProjectFile=True, publishOutputs=False):
if isinstance(url, (QUrl)):
# depending how the QUrl has been initialized,
# toLocalFile() may return the local path or an empty string
localFile = url.toLocalFile()
if not localFile:
localFile = url.toString()
else:
localFile = url
localFile = prepareUrlLocalFile(url)
return self.load(localFile, setupProjectFile, publishOutputs)

@Slot(QUrl, result=bool)
def installPlugin(self, url):
localFile = prepareUrlLocalFile(url)
return installPlugin(localFile)

@Slot(str, result=bool)
def buildNode(self, nodeName):
print("***Building "+nodeName)
node = self._graph.node(nodeName)
from meshroom.core.plugin import isBuilt, build #lazy import to avoid circular dep
if not isBuilt(node.nodeDesc):
build(node.nodeDesc)

def onGraphChanged(self):
""" React to the change of the internal graph. """
self._liveSfmManager.reset()
@@ -904,12 +921,7 @@ def importImagesFromFolder(self, path, recursive=False):
def importImagesUrls(self, imagePaths, recursive=False):
paths = []
for imagePath in imagePaths:
if isinstance(imagePath, (QUrl)):
p = imagePath.toLocalFile()
if not p:
p = imagePath.toString()
else:
p = imagePath
p = prepareUrlLocalFile(imagePath)
paths.append(p)
self.importImagesFromFolder(paths)

@@ -1285,4 +1297,4 @@ def setCurrentViewPath(self, path):
# Signals to propagate high-level messages
error = Signal(Message)
warning = Signal(Message)
info = Signal(Message)
info = Signal(Message)
5 changes: 5 additions & 0 deletions tests/nodes/plugins/Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
FROM python:3
RUN python -m pip install --no-cache-dir numpy
RUN python -m pip install --no-cache-dir psutil
#overwrides entry point otherwise will directly execute python
ENTRYPOINT [ "/bin/bash", "-l", "-c" ]
Empty file added tests/nodes/plugins/__init__.py
Empty file.
171 changes: 171 additions & 0 deletions tests/nodes/plugins/dummyNodes.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,171 @@


import os
from meshroom.core.plugin import PluginNode, PluginCommandLineNode, EnvType

#Python nodes

class DummyConda(PluginNode):

category = 'Dummy'
documentation = ''' '''

envType = EnvType.CONDA
envFile = os.path.join(os.path.dirname(__file__), "env.yaml")

inputs = []
outputs = []

def processChunk(self, chunk):
import numpy as np
chunk.logManager.start("info")
chunk.logger.info(np.abs(-1))

class DummyDocker(PluginNode):

category = 'Dummy'
documentation = ''' '''

envType = EnvType.DOCKER
envFile = os.path.join(os.path.dirname(__file__), "Dockerfile")

inputs = []
outputs = []

def processChunk(self, chunk):
import numpy as np
chunk.logManager.start("info")
chunk.logger.info(np.abs(-1))


class DummyVenv(PluginNode):

category = 'Dummy'
documentation = ''' '''

envType = EnvType.VENV
envFile = os.path.join(os.path.dirname(__file__), "requirements.txt")

inputs = []
outputs = []

def processChunk(self, chunk):
import numpy as np
chunk.logManager.start("info")
chunk.logger.info(np.abs(-1))

class DummyPip(PluginNode):

category = 'Dummy'
documentation = ''' '''

envType = EnvType.PIP
envFile = os.path.join(os.path.dirname(__file__), "requirements.txt")

inputs = []
outputs = []

def processChunk(self, chunk):
import numpy as np
chunk.logManager.start("info")
chunk.logger.info(np.abs(-1))

class DummyNone(PluginNode):

category = 'Dummy'
documentation = ''' '''

envType = EnvType.NONE
envFile = None

inputs = []
outputs = []

def processChunk(self, chunk):
import numpy as np
chunk.logManager.start("info")
chunk.logger.info(np.abs(-1))

class DummyRez(PluginNode):

category = 'Dummy'
documentation = ''' '''

envType = EnvType.REZ
envFile = "numpy"

inputs = []
outputs = []

def processChunk(self, chunk):
import numpy as np
chunk.logManager.start("info")
chunk.logger.info(np.abs(-1))

#Command line node

class DummyCondaCL(PluginCommandLineNode):

category = 'Dummy'
documentation = ''' '''

envType = EnvType.CONDA
envFile = os.path.join(os.path.dirname(__file__), "env.yaml")

inputs = []
outputs = []

commandLine = "python -c \"import numpy as np; print(np.abs(-1))\""

class DummyDockerCL(PluginCommandLineNode):

category = 'Dummy'
documentation = ''' '''

envType = EnvType.DOCKER
envFile = os.path.join(os.path.dirname(__file__), "Dockerfile")

inputs = []
outputs = []

commandLine = "python -c \"import numpy as np; print(np.abs(-1))\""


class DummyVenvCL(PluginCommandLineNode):

category = 'Dummy'
documentation = ''' '''

envType = EnvType.VENV
envFile = os.path.join(os.path.dirname(__file__), "requirements.txt")

inputs = []
outputs = []

commandLine = "python -c \"import numpy as np; print(np.abs(-1))\""

class DummyPipCL(PluginCommandLineNode):

category = 'Dummy'
documentation = ''' '''

envType = EnvType.PIP
envFile = os.path.join(os.path.dirname(__file__), "requirements.txt")

inputs = []
outputs = []

commandLine = "python -c \"import numpy as np; print(np.abs(-1))\""

class DummyNoneCL(PluginCommandLineNode):

category = 'Dummy'
documentation = ''' '''

envType = EnvType.NONE
envFile = None

inputs = []
outputs = []

commandLine = "python -c \"import numpy as np; print(np.abs(-1))\""
8 changes: 8 additions & 0 deletions tests/nodes/plugins/env.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
name: dummy
channels:
- defaults
- conda-forge
dependencies:
- python
- numpy
- psutil
6 changes: 6 additions & 0 deletions tests/nodes/plugins/meshroomPlugin.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
[
{
"pluginName":"Dummy",
"nodesFolder":"."
}
]
2 changes: 2 additions & 0 deletions tests/nodes/plugins/requirements.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
numpy
psutil
18 changes: 18 additions & 0 deletions tests/test_plugin_nodes.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import logging
import os

from meshroom.core.graph import Graph

logging = logging.getLogger(__name__)

def test_pluginNodes():
#Dont run the tests in the CI as we are unable to install plugins beforehand
if "CI" in os.environ:
return
graph = Graph('')
graph.addNewNode('DummyCondaNode')
graph.addNewNode('DummyDockerNode')
graph.addNewNode('DummyPipNode')
graph.addNewNode('DummyVenvNode')