WebGL ストリーミング用の Qt Quick アプリケーションを管理する

このスクリプト プラグインは、WebGL ストリーミングが有効な場合に、Qt Quick アプリケーションを起動および停止するのに役立つ UI を作成します。

VRED がシャットダウンした場合、またはすべてのスクリプト プラグインが再ロードされた場合、スクリプト プラグインは破棄されます。この場合、作成されたすべてのプロセスを再度停止して、孤立したプロセスを残さないようにします。これを行うために、関数 onDestroyVREDScriptPlugin() が実装されました。プラグインが破棄される前に自動的に呼び出されます。各スクリプト プラグインは、プラグインの現在のインスタンスが破棄される直前に onDestroyVREDScriptPlugin() を実装して何らかの操作を実行することができます(ただし、必ずしも実装する必要はありません)。

QtQuickStreaming.py

from PySide2 import QtCore, QtWidgets, QtGui, QtNetwork
from PySide2.QtWidgets import QApplication, QFileDialog, QMessageBox
from PySide2.QtCore import QFile, Signal, Slot, QObject, QProcess, QProcessEnvironment
from PySide2.QtNetwork import QTcpSocket

import os, signal
import uiTools

from vrController import vrLogError, vrLogWarning, vrLogInfo

"""
 This script plugin creates a convenience UI that lets you start and stop Qt Quick applications
 with WebGL streaming enabled. Starting a process with this UI starts it as a child process of VRED.
 To work without the UI, you can use this command line:
 $ ./your-qt-application -platform webgl:port=8998
"""

# Load the .ui files. We derive widget classes from these types.
QtQuickStreaming_form, QtQuickStreaming_base = uiTools.loadUiType('QtQuickStreaming.ui')
ProcessWidget_form, ProcessWidget_base = uiTools.loadUiType('process.ui')


def getIcon(name):
    """Returns a QIcon for a button or action."""
    icon = QtGui.QIcon()
    iconPath = ":/iconmanager/resources/General/" + name
    icon.addPixmap(QtGui.QPixmap("{}Disabled.svg".format(iconPath)), QtGui.QIcon.Disabled, QtGui.QIcon.Off)
    icon.addPixmap(QtGui.QPixmap("{}OffNormal.svg".format(iconPath)), QtGui.QIcon.Normal, QtGui.QIcon.Off)
    return icon


class RunningState:
    """ Indicates the state of the process."""
    STOPPED = 0
    STARTED = 1

    @staticmethod
    def createIndicatorPixmap(color):
        """Creates and returns a QPixmap with a circle as indicator for the process running state."""
        pixmap = QtGui.QPixmap(12, 12)
        pixmap.fill(QtCore.Qt.transparent)
        painter = QtGui.QPainter(pixmap)
        painter.setRenderHint(QtGui.QPainter.Antialiasing)
        painter.setPen(QtCore.Qt.NoPen)
        painter.setBrush(QtGui.QBrush(QtGui.QColor(color)))
        painter.drawEllipse(1, 1, 10, 10)
        return pixmap


class ProcessObject(QObject):
    """
    Small wrapper around a QProcess object.
    This class is responsible for starting and stopping a process and logging errors.
    Attributes:
        procName (str): Full application path
        port (int): Port for WebGL streaming
        process (QProcess): We use Qt to manage the process
        runningState (int): RunningState.STARTED or RunningState.STOPPED
    """
    runningStateChanged = Signal(int)
    def __init__(self, name, port):
        super(ProcessObject, self).__init__()
        self.procName = name
        self.port = port
        self.process = None
        self.runningState = RunningState.STOPPED

    def startProcess(self):
        if self.isRunning():
            return
        if not self.isPortAvailable(self.port):
            vrLogWarning("{}: Port {} already in use.".format(self.procName, str(self.port)))
        process = QProcess()
        self.process = process
        process.started.connect(self.processStarted)
        process.errorOccurred.connect(self.processError)
        process.finished.connect(self.processFinished)
        process.readyReadStandardError.connect(self.processStandardError)
        process.setWorkingDirectory(os.path.dirname(self.procName))

        # Enable WebGL platform for this process and set port via QT_QPA_PLATFORM environment
        # variable. This is an alternative to using the -platform command line parameter.
        env = QProcessEnvironment.systemEnvironment()
        env.insert("QT_QPA_PLATFORM", "webgl:port={}".format(str(self.port)))
        process.setProcessEnvironment(env)
        process.start("\"{}\"".format(self.procName))

    def stopProcess(self):
        try:
            if self.isRunning():
                os.kill(self.process.processId(), signal.SIGTERM)
                self.process.waitForFinished(10000)
                self.process = None
        except:
            pass

    def isRunning(self):
        return (self.runningState == RunningState.STARTED and self.process is not None)

    def isPortAvailable(self, port):
        socket = QTcpSocket()
        free = socket.bind(port, QTcpSocket.DontShareAddress)
        socket.close()
        return free

    def processStarted(self):
        print("{} ({}): Process started.".format(self.procName, self.port))
        self.setRunningState(RunningState.STARTED)

    def processFinished(self, exitCode, exitStatus):
        print("{} ({}): Process finished.".format(self.procName, self.port))
        self.setRunningState(RunningState.STOPPED)

    def processError(self, err):
        if self.process is not None:
            vrLogError("{}: {}".format(self.procName, self.process.errorString()))

    def processStandardError(self):
        if self.process is not None:
            vrLogError("{}:\n{}".format(self.procName, self.process.readAllStandardError()))

    def getPath(self):
        return self.procName

    def getPort(self):
        return self.port

    def getRunningState(self):
        return self.runningState

    def setRunningState(self, state):
        self.runningState = state
        self.runningStateChanged.emit(state)


class ProcessWidget(ProcessWidget_form, ProcessWidget_base):
    """
    This widget holds the UI for one entry of the process list.
    Attributes:
        process (ProcessObject): The process represented by this widget
        id (int): Id of the widget to be able to find it in the list
    """
    deleteSignal = Signal(int)  
    id = 0
    def __init__(self, parent, process):
        super(ProcessWidget, self).__init__(parent)
        self.setupUi(self)
        self.process = process
        ProcessWidget.id += 1
        self.id = ProcessWidget.id

        self.startButton.clicked.connect(self._onStartButtonClicked)
        self.stopButton.clicked.connect(self._onStopButtonClicked)
        self.deleteButton.clicked.connect(self._onDeleteButtonClicked)
        self.copyURLButton.clicked.connect(self._onCopyURLButtonClicked)
        self.process.runningStateChanged.connect(self._onRunningStateChanged)

        self.startButton.setIcon(getIcon("Run"))
        self.stopButton.setIcon(getIcon("Stop"))
        self.deleteButton.setIcon(getIcon("Delete"))

        self.procLabel.setText(os.path.basename(self.process.procName))
        self.procLabel.setToolTip(self.process.procName)
        self.procLabel.setStatusTip(self.process.procName)
        self.portEdit.setValue(self.process.port)
        self.stoppedPixmap = RunningState.createIndicatorPixmap("#3c3c3c")
        self.startedPixmap = RunningState.createIndicatorPixmap("#41d971")
        self.updateUI()

    def updateUI(self):
        started = self.process.runningState == RunningState.STARTED
        self.startButton.setEnabled(not started)
        self.stopButton.setEnabled(started)
        self.portEdit.setReadOnly(started)
        runningPixmap = self.startedPixmap if started else self.stoppedPixmap
        self.runningLabel.setPixmap(runningPixmap)

    def _onStartButtonClicked(self):
        self.process.port = self.portEdit.value()
        self.process.startProcess()

    def _onStopButtonClicked(self):
        self.process.stopProcess()

    def _onDeleteButtonClicked(self):
        self.process.stopProcess()
        self.deleteSignal.emit(self.id)

    def _onCopyURLButtonClicked(self):
        url = "http://localhost:{}".format(self.process.port)
        QApplication.clipboard().setText(url)

    def _onRunningStateChanged(self, state):
        self.updateUI()


class QtQuickStreaming(QtQuickStreaming_form, QtQuickStreaming_base):
    """
    This is the main widget for the plugin. It holds a list of processes.
    Attributes:
        parent (QWidget): Parent widget
        processWidgets (dict of int:ProcessWidget): Holds all processes, maps id to process widget
        lastConfigFile (str): The config file that was loaded last
    """
    def __init__(self, parent=None):
        super(QtQuickStreaming, self).__init__(parent)
        parent.layout().addWidget(self)
        self.parent = parent
        self.setupUi(self)
        # This class derives from QMainWindow so that we can have a tool bar and a menu bar.
        # To be able to embed it into the parent widget provided by VRED we need to
        # remove the Window flag.
        self.setWindowFlags(self.windowFlags() & ~QtCore.Qt.Window);
        self.processWidgets = {}
        self.lastConfigFile = ""

        # signal connections
        self.actionAdd.triggered.connect(self._onAdd)
        self.actionStartAll.triggered.connect(self._onStartAll)
        self.actionStopAll.triggered.connect(self._onStopAll)
        self.actionDeleteAll.triggered.connect(self._onDeleteAll)
        self.actionLoad.triggered.connect(self._onLoad)
        self.actionSave.triggered.connect(self._onSave)
        vrFileIOService.projectLoaded.connect(self._onProjectLoaded)

        # UI setup
        self.actionAdd.setIcon(getIcon("CreateNew"))
        self.actionStartAll.setIcon(getIcon("Run"))
        self.actionStopAll.setIcon(getIcon("Stop"))
        self.actionDeleteAll.setIcon(getIcon("Delete"))
        self.actionLoad.setIcon(getIcon("FileOpen"))
        self.actionSave.setIcon(getIcon("Save"))
        self.QuickActionBar.setContextMenuPolicy(QtCore.Qt.ActionsContextMenu);
        self.updateUI();

    def _onAdd(self):
        exeFile = QFileDialog.getOpenFileName(None, "Select Executable", "", "*.exe")[0]
        if len(exeFile) > 0:
            port = self.getAvailablePort()
            self.addProcess(exeFile, port, self.autoStart.isChecked())

    def _onLoad(self):
        configFile = QFileDialog.getOpenFileName(None, "Load config file", "", "*.cfg")[0]
        if len(configFile) > 0:
            self.loadConfig(configFile)

    def _onSave(self):
        configFile = QFileDialog.getSaveFileName(None, "Save config file", self._getSuggestedFilename(), "*.cfg")[0]
        if len(configFile) > 0:
            self.saveConfig(configFile)

    def _getSuggestedFilename(self):
        # Suggest saving the config next to the current .vpb because it will be then
        # automatically loaded with the vpb. See _onProjectLoaded.
        suggestedFilename = ".cfg"
        vredFile = vrFileIOService.getFileName()
        filepath, ext = os.path.splitext(vredFile)
        if ext == ".vpb":
            suggestedFilename = filepath + ".cfg"
        else:
            suggestedFilename = self.lastConfigFile
        return suggestedFilename

    def _onStartAll(self):
        for id in sorted(self.processWidgets):
            widget = self.processWidgets[id]
            widget.process.startProcess()

    def _onStopAll(self):
        for id in sorted(self.processWidgets):
            widget = self.processWidgets[id]
            widget.process.stopProcess()

    def _onDeleteAll(self):
        if len(self.processWidgets) == 0:
            return
        # Ask for confirmation before deleting everything.
        msgTitle = "QtQuickStreaming"
        msgText = "Stop and delete all processes from the list?\nThis cannot be undone."
        msgBox = QMessageBox(QMessageBox.Warning, msgTitle, msgText, QMessageBox.NoButton, self)
        deleteButton = msgBox.addButton("Delete", QMessageBox.ActionRole)
        cancelButton = msgBox.addButton(QMessageBox.Cancel)
        msgBox.exec_()
        if msgBox.clickedButton() == deleteButton:
            self.deleteAllProcesses()

    def _onProjectLoaded(self, file):
        """ Look for a .cfg file with the same name next to the loaded .vpb file and load it. """
        filepath, ext = os.path.splitext(file)
        if ext == ".vpb":
            configFile = filepath + ".cfg"
            if os.path.exists(configFile):
                print("Load config ", configFile)
                self.loadConfig(configFile)

    def _onProcessWidgetDeleted(self, id):
        procWidget = self.processWidgets.pop(id, None)
        self._deleteWidget(procWidget)
        self._onProcessListChanged()

    def _deleteWidget(self, procWidget):
        if procWidget is not None:
            procWidget.process.stopProcess()
            self.processWidgetsLayout.removeWidget(procWidget)
            procWidget.deleteLater()

    def deleteAllProcesses(self):
        for id, widget in list(self.processWidgets.items()):
            self._deleteWidget(widget)
        self.processWidgets = {}
        self._onProcessListChanged()

    def _onProcessListChanged(self):
        self.updateUI()

    def updateUI(self):
        hasProcesses = len(self.processWidgets) > 0
        self.actionSave.setEnabled(hasProcesses)
        self.actionStartAll.setEnabled(hasProcesses)
        self.actionStopAll.setEnabled(hasProcesses)
        self.actionDeleteAll.setEnabled(hasProcesses)

    def addProcess(self, name, port, doStart):
        # create process
        process = ProcessObject(name, port)
        if doStart:
            process.startProcess()
        # create widget for process
        procWidget = ProcessWidget(self, process)
        procWidget.deleteSignal.connect(self._onProcessWidgetDeleted)
        self.processWidgets[procWidget.id] = procWidget
        self.processWidgetsLayout.addWidget(procWidget)
        self._onProcessListChanged()

    def getAvailablePort(self):
        """ Search for a port that is not used yet and return its number. """
        portRange = list(range(9000, 9100))
        socket = QTcpSocket()
        for p in portRange:
            if not self.isPortAssignedToProcess(p):
                free = socket.bind(p, QTcpSocket.DontShareAddress)
                socket.close()
                if free:
                    return p
        return 0

    def isPortAssignedToProcess(self, port):
        for id, widget in list(self.processWidgets.items()):
            if port == widget.process.getPort():
                return True
        return False

    def saveConfig(self, fileName):
        """ Write config file as a pipe delimited text file. """
        try:
            with open(fileName, 'w') as openedFile:
                for id in sorted(self.processWidgets):
                    widget = self.processWidgets[id]
                    path = widget.process.getPath()
                    port = widget.process.getPort()
                    running = int(widget.process.getRunningState())
                    openedFile.write("{}|{}|{}\n".format(path, port, running))
        except IOError as e:
            vrLogError("Could not save {0}. I/O error({1}): {2}".format(fileName, e.errno, e.strerror))
        except:
            vrLogError("Could not save {0}. Unexpected error.".format(fileName))

    def loadConfig(self, fileName):
        if os.path.exists(fileName):
            try:
                with open(fileName, 'r') as openedFile:
                    self.lastConfigFile = fileName
                    self.deleteAllProcesses()
                    for line in openedFile:
                        processName, port, runningState = line.strip().split('|')
                        if len(processName) > 0 and len(port) >0 and len(runningState) >0:
                            doStart = bool(runningState) and self.autoStart.isChecked()
                            self.addProcess(processName, int(port), doStart)
            except IOError as e:
                vrLogError("Could not load {0}. I/O error({1}): {2}".format(fileName, e.errno, e.strerror))
            except:
                vrLogError("Could not load {0}. Unexpected error.".format(fileName))


def onDestroyVREDScriptPlugin():
    """
    onDestroyVREDScriptPlugin() is called before this plugin is destroyed.
    In this plugin we want to stop all processes.
    """
    streamingPlugin.deleteAllProcesses()


# Create the plugin widget
streamingPlugin = QtQuickStreaming(VREDPluginWidget)