mirror of
https://github.com/ynput/ayon-core.git
synced 2025-12-24 21:04:40 +01:00
923 lines
30 KiB
Python
923 lines
30 KiB
Python
import os
|
|
import json
|
|
import time
|
|
import subprocess
|
|
import collections
|
|
import asyncio
|
|
import logging
|
|
import socket
|
|
import platform
|
|
import filecmp
|
|
import tempfile
|
|
import threading
|
|
import shutil
|
|
from queue import Queue
|
|
from contextlib import closing
|
|
|
|
from aiohttp import web
|
|
from aiohttp_json_rpc import JsonRpc
|
|
from aiohttp_json_rpc.protocol import (
|
|
encode_request, encode_error, decode_msg, JsonRpcMsgTyp
|
|
)
|
|
from aiohttp_json_rpc.exceptions import RpcError
|
|
|
|
from openpype.lib import emit_event
|
|
from openpype.hosts.tvpaint.tvpaint_plugin import get_plugin_files_path
|
|
|
|
log = logging.getLogger(__name__)
|
|
log.setLevel(logging.DEBUG)
|
|
|
|
|
|
class CommunicationWrapper:
|
|
# TODO add logs and exceptions
|
|
communicator = None
|
|
|
|
log = logging.getLogger("CommunicationWrapper")
|
|
|
|
@classmethod
|
|
def create_qt_communicator(cls, *args, **kwargs):
|
|
"""Create communicator for Artist usage."""
|
|
communicator = QtCommunicator(*args, **kwargs)
|
|
cls.set_communicator(communicator)
|
|
return communicator
|
|
|
|
@classmethod
|
|
def set_communicator(cls, communicator):
|
|
if not cls.communicator:
|
|
cls.communicator = communicator
|
|
else:
|
|
cls.log.warning("Communicator was set multiple times.")
|
|
|
|
@classmethod
|
|
def client(cls):
|
|
if not cls.communicator:
|
|
return None
|
|
return cls.communicator.client()
|
|
|
|
@classmethod
|
|
def execute_george(cls, george_script):
|
|
"""Execute passed goerge script in TVPaint."""
|
|
if not cls.communicator:
|
|
return
|
|
return cls.communicator.execute_george(george_script)
|
|
|
|
|
|
class WebSocketServer:
|
|
def __init__(self):
|
|
self.client = None
|
|
|
|
self.loop = asyncio.new_event_loop()
|
|
self.app = web.Application(loop=self.loop)
|
|
self.port = self.find_free_port()
|
|
self.websocket_thread = WebsocketServerThread(
|
|
self, self.port, loop=self.loop
|
|
)
|
|
|
|
@property
|
|
def server_is_running(self):
|
|
return self.websocket_thread.server_is_running
|
|
|
|
def add_route(self, *args, **kwargs):
|
|
self.app.router.add_route(*args, **kwargs)
|
|
|
|
@staticmethod
|
|
def find_free_port():
|
|
with closing(
|
|
socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
|
) as sock:
|
|
sock.bind(("", 0))
|
|
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
|
port = sock.getsockname()[1]
|
|
return port
|
|
|
|
def start(self):
|
|
self.websocket_thread.start()
|
|
|
|
def stop(self):
|
|
try:
|
|
if self.websocket_thread.is_running:
|
|
log.debug("Stopping websocket server")
|
|
self.websocket_thread.is_running = False
|
|
self.websocket_thread.stop()
|
|
except Exception:
|
|
log.warning(
|
|
"Error has happened during Killing websocket server",
|
|
exc_info=True
|
|
)
|
|
|
|
|
|
class WebsocketServerThread(threading.Thread):
|
|
""" Listener for websocket rpc requests.
|
|
|
|
It would be probably better to "attach" this to main thread (as for
|
|
example Harmony needs to run something on main thread), but currently
|
|
it creates separate thread and separate asyncio event loop
|
|
"""
|
|
def __init__(self, module, port, loop):
|
|
super(WebsocketServerThread, self).__init__()
|
|
self.is_running = False
|
|
self.server_is_running = False
|
|
self.port = port
|
|
self.module = module
|
|
self.loop = loop
|
|
self.runner = None
|
|
self.site = None
|
|
self.tasks = []
|
|
|
|
def run(self):
|
|
self.is_running = True
|
|
|
|
try:
|
|
log.debug("Starting websocket server")
|
|
|
|
self.loop.run_until_complete(self.start_server())
|
|
|
|
log.info(
|
|
"Running Websocket server on URL:"
|
|
" \"ws://localhost:{}\"".format(self.port)
|
|
)
|
|
|
|
asyncio.ensure_future(self.check_shutdown(), loop=self.loop)
|
|
|
|
self.server_is_running = True
|
|
self.loop.run_forever()
|
|
|
|
except Exception:
|
|
log.warning(
|
|
"Websocket Server service has failed", exc_info=True
|
|
)
|
|
finally:
|
|
self.server_is_running = False
|
|
# optional
|
|
self.loop.close()
|
|
|
|
self.is_running = False
|
|
log.info("Websocket server stopped")
|
|
|
|
async def start_server(self):
|
|
""" Starts runner and TCPsite """
|
|
self.runner = web.AppRunner(self.module.app)
|
|
await self.runner.setup()
|
|
self.site = web.TCPSite(self.runner, "localhost", self.port)
|
|
await self.site.start()
|
|
|
|
def stop(self):
|
|
"""Sets is_running flag to false, 'check_shutdown' shuts server down"""
|
|
self.is_running = False
|
|
|
|
async def check_shutdown(self):
|
|
""" Future that is running and checks if server should be running
|
|
periodically.
|
|
"""
|
|
while self.is_running:
|
|
while self.tasks:
|
|
task = self.tasks.pop(0)
|
|
log.debug("waiting for task {}".format(task))
|
|
await task
|
|
log.debug("returned value {}".format(task.result))
|
|
|
|
await asyncio.sleep(0.5)
|
|
|
|
log.debug("## Server shutdown started")
|
|
|
|
await self.site.stop()
|
|
log.debug("# Site stopped")
|
|
await self.runner.cleanup()
|
|
log.debug("# Server runner stopped")
|
|
tasks = [
|
|
task for task in asyncio.all_tasks()
|
|
if task is not asyncio.current_task()
|
|
]
|
|
list(map(lambda task: task.cancel(), tasks)) # cancel all the tasks
|
|
results = await asyncio.gather(*tasks, return_exceptions=True)
|
|
log.debug(f"Finished awaiting cancelled tasks, results: {results}...")
|
|
await self.loop.shutdown_asyncgens()
|
|
# to really make sure everything else has time to stop
|
|
await asyncio.sleep(0.07)
|
|
self.loop.stop()
|
|
|
|
|
|
class BaseTVPaintRpc(JsonRpc):
|
|
def __init__(self, communication_obj, route_name="", **kwargs):
|
|
super().__init__(**kwargs)
|
|
self.requests_ids = collections.defaultdict(lambda: 0)
|
|
self.waiting_requests = collections.defaultdict(list)
|
|
self.responses = collections.defaultdict(list)
|
|
|
|
self.route_name = route_name
|
|
self.communication_obj = communication_obj
|
|
|
|
async def _handle_rpc_msg(self, http_request, raw_msg):
|
|
# This is duplicated code from super but there is no way how to do it
|
|
# to be able handle server->client requests
|
|
host = http_request.host
|
|
if host in self.waiting_requests:
|
|
try:
|
|
_raw_message = raw_msg.data
|
|
msg = decode_msg(_raw_message)
|
|
|
|
except RpcError as error:
|
|
await self._ws_send_str(http_request, encode_error(error))
|
|
return
|
|
|
|
if msg.type in (JsonRpcMsgTyp.RESULT, JsonRpcMsgTyp.ERROR):
|
|
msg_data = json.loads(_raw_message)
|
|
if msg_data.get("id") in self.waiting_requests[host]:
|
|
self.responses[host].append(msg_data)
|
|
return
|
|
|
|
return await super()._handle_rpc_msg(http_request, raw_msg)
|
|
|
|
def client_connected(self):
|
|
# TODO This is poor check. Add check it is client from TVPaint
|
|
if self.clients:
|
|
return True
|
|
return False
|
|
|
|
def send_notification(self, client, method, params=None):
|
|
if params is None:
|
|
params = []
|
|
asyncio.run_coroutine_threadsafe(
|
|
client.ws.send_str(encode_request(method, params=params)),
|
|
loop=self.loop
|
|
)
|
|
|
|
def send_request(self, client, method, params=None, timeout=0):
|
|
if params is None:
|
|
params = []
|
|
|
|
client_host = client.host
|
|
|
|
request_id = self.requests_ids[client_host]
|
|
self.requests_ids[client_host] += 1
|
|
|
|
self.waiting_requests[client_host].append(request_id)
|
|
|
|
log.debug("Sending request to client {} ({}, {}) id: {}".format(
|
|
client_host, method, params, request_id
|
|
))
|
|
future = asyncio.run_coroutine_threadsafe(
|
|
client.ws.send_str(encode_request(method, request_id, params)),
|
|
loop=self.loop
|
|
)
|
|
result = future.result()
|
|
|
|
not_found = object()
|
|
response = not_found
|
|
start = time.time()
|
|
while True:
|
|
if client.ws.closed:
|
|
return None
|
|
|
|
for _response in self.responses[client_host]:
|
|
_id = _response.get("id")
|
|
if _id == request_id:
|
|
response = _response
|
|
break
|
|
|
|
if response is not not_found:
|
|
break
|
|
|
|
if timeout > 0 and (time.time() - start) > timeout:
|
|
raise Exception("Timeout passed")
|
|
return
|
|
|
|
time.sleep(0.1)
|
|
|
|
if response is not_found:
|
|
raise Exception("Connection closed")
|
|
|
|
self.responses[client_host].remove(response)
|
|
|
|
error = response.get("error")
|
|
result = response.get("result")
|
|
if error:
|
|
raise Exception("Error happened: {}".format(error))
|
|
return result
|
|
|
|
|
|
class QtTVPaintRpc(BaseTVPaintRpc):
|
|
def __init__(self, *args, **kwargs):
|
|
super().__init__(*args, **kwargs)
|
|
|
|
from openpype.tools.utils import host_tools
|
|
self.tools_helper = host_tools.HostToolsHelper()
|
|
|
|
route_name = self.route_name
|
|
|
|
# Register methods
|
|
self.add_methods(
|
|
(route_name, self.workfiles_tool),
|
|
(route_name, self.loader_tool),
|
|
(route_name, self.publish_tool),
|
|
(route_name, self.scene_inventory_tool),
|
|
(route_name, self.library_loader_tool),
|
|
(route_name, self.experimental_tools)
|
|
)
|
|
|
|
# Panel routes for tools
|
|
async def workfiles_tool(self):
|
|
log.info("Triggering Workfile tool")
|
|
item = MainThreadItem(self.tools_helper.show_workfiles)
|
|
self._execute_in_main_thread(item)
|
|
return
|
|
|
|
async def loader_tool(self):
|
|
log.info("Triggering Loader tool")
|
|
item = MainThreadItem(self.tools_helper.show_loader)
|
|
self._execute_in_main_thread(item)
|
|
return
|
|
|
|
async def publish_tool(self):
|
|
log.info("Triggering Publish tool")
|
|
item = MainThreadItem(self.tools_helper.show_publisher_tool)
|
|
self._execute_in_main_thread(item)
|
|
return
|
|
|
|
async def scene_inventory_tool(self):
|
|
"""Open Scene Inventory tool.
|
|
|
|
Function can't confirm if tool was opened becauise one part of
|
|
SceneInventory initialization is calling websocket request to host but
|
|
host can't response because is waiting for response from this call.
|
|
"""
|
|
log.info("Triggering Scene inventory tool")
|
|
item = MainThreadItem(self.tools_helper.show_scene_inventory)
|
|
# Do not wait for result of callback
|
|
self._execute_in_main_thread(item, wait=False)
|
|
return
|
|
|
|
async def library_loader_tool(self):
|
|
log.info("Triggering Library loader tool")
|
|
item = MainThreadItem(self.tools_helper.show_library_loader)
|
|
self._execute_in_main_thread(item)
|
|
return
|
|
|
|
async def experimental_tools(self):
|
|
log.info("Triggering Library loader tool")
|
|
item = MainThreadItem(self.tools_helper.show_experimental_tools_dialog)
|
|
self._execute_in_main_thread(item)
|
|
return
|
|
|
|
async def _async_execute_in_main_thread(self, item, **kwargs):
|
|
await self.communication_obj.async_execute_in_main_thread(
|
|
item, **kwargs
|
|
)
|
|
|
|
def _execute_in_main_thread(self, item, **kwargs):
|
|
return self.communication_obj.execute_in_main_thread(item, **kwargs)
|
|
|
|
|
|
class MainThreadItem:
|
|
"""Structure to store information about callback in main thread.
|
|
|
|
Item should be used to execute callback in main thread which may be needed
|
|
for execution of Qt objects.
|
|
|
|
Item store callback (callable variable), arguments and keyword arguments
|
|
for the callback. Item hold information about it's process.
|
|
"""
|
|
not_set = object()
|
|
sleep_time = 0.1
|
|
|
|
def __init__(self, callback, *args, **kwargs):
|
|
self.done = False
|
|
self.exception = self.not_set
|
|
self.result = self.not_set
|
|
self.callback = callback
|
|
self.args = args
|
|
self.kwargs = kwargs
|
|
|
|
def execute(self):
|
|
"""Execute callback and store its result.
|
|
|
|
Method must be called from main thread. Item is marked as `done`
|
|
when callback execution finished. Store output of callback of exception
|
|
information when callback raises one.
|
|
"""
|
|
log.debug("Executing process in main thread")
|
|
if self.done:
|
|
log.warning("- item is already processed")
|
|
return
|
|
|
|
callback = self.callback
|
|
args = self.args
|
|
kwargs = self.kwargs
|
|
log.info("Running callback: {}".format(str(callback)))
|
|
try:
|
|
result = callback(*args, **kwargs)
|
|
self.result = result
|
|
|
|
except Exception as exc:
|
|
self.exception = exc
|
|
|
|
finally:
|
|
self.done = True
|
|
|
|
def wait(self):
|
|
"""Wait for result from main thread.
|
|
|
|
This method stops current thread until callback is executed.
|
|
|
|
Returns:
|
|
object: Output of callback. May be any type or object.
|
|
|
|
Raises:
|
|
Exception: Reraise any exception that happened during callback
|
|
execution.
|
|
"""
|
|
while not self.done:
|
|
time.sleep(self.sleep_time)
|
|
|
|
if self.exception is self.not_set:
|
|
return self.result
|
|
raise self.exception
|
|
|
|
async def async_wait(self):
|
|
"""Wait for result from main thread.
|
|
|
|
Returns:
|
|
object: Output of callback. May be any type or object.
|
|
|
|
Raises:
|
|
Exception: Reraise any exception that happened during callback
|
|
execution.
|
|
"""
|
|
while not self.done:
|
|
await asyncio.sleep(self.sleep_time)
|
|
|
|
if self.exception is self.not_set:
|
|
return self.result
|
|
raise self.exception
|
|
|
|
|
|
class BaseCommunicator:
|
|
def __init__(self):
|
|
self.process = None
|
|
self.websocket_server = None
|
|
self.websocket_rpc = None
|
|
self.exit_code = None
|
|
self._connected_client = None
|
|
|
|
@property
|
|
def server_is_running(self):
|
|
if self.websocket_server is None:
|
|
return False
|
|
return self.websocket_server.server_is_running
|
|
|
|
def _windows_file_process(self, src_dst_mapping, to_remove):
|
|
"""Windows specific file processing asking for admin permissions.
|
|
|
|
It is required to have administration permissions to modify plugin
|
|
files in TVPaint installation folder.
|
|
|
|
Method requires `pywin32` python module.
|
|
|
|
Args:
|
|
src_dst_mapping (list, tuple, set): Mapping of source file to
|
|
destination. Both must be full path. Each item must be iterable
|
|
of size 2 `(C:/src/file.dll, C:/dst/file.dll)`.
|
|
to_remove (list): Fullpath to files that should be removed.
|
|
"""
|
|
|
|
import pythoncom
|
|
from win32comext.shell import shell
|
|
|
|
# Create temp folder where plugin files are temporary copied
|
|
# - reason is that copy to TVPaint requires administartion permissions
|
|
# but admin may not have access to source folder
|
|
tmp_dir = os.path.normpath(
|
|
tempfile.mkdtemp(prefix="tvpaint_copy_")
|
|
)
|
|
|
|
# Copy source to temp folder and create new mapping
|
|
dst_folders = collections.defaultdict(list)
|
|
new_src_dst_mapping = []
|
|
for old_src, dst in src_dst_mapping:
|
|
new_src = os.path.join(tmp_dir, os.path.split(old_src)[1])
|
|
shutil.copy(old_src, new_src)
|
|
new_src_dst_mapping.append((new_src, dst))
|
|
|
|
for src, dst in new_src_dst_mapping:
|
|
src = os.path.normpath(src)
|
|
dst = os.path.normpath(dst)
|
|
dst_filename = os.path.basename(dst)
|
|
dst_folder_path = os.path.dirname(dst)
|
|
dst_folders[dst_folder_path].append((dst_filename, src))
|
|
|
|
# create an instance of IFileOperation
|
|
fo = pythoncom.CoCreateInstance(
|
|
shell.CLSID_FileOperation,
|
|
None,
|
|
pythoncom.CLSCTX_ALL,
|
|
shell.IID_IFileOperation
|
|
)
|
|
# Add delete command to file operation object
|
|
for filepath in to_remove:
|
|
item = shell.SHCreateItemFromParsingName(
|
|
filepath, None, shell.IID_IShellItem
|
|
)
|
|
fo.DeleteItem(item)
|
|
|
|
# here you can use SetOperationFlags, progress Sinks, etc.
|
|
for folder_path, items in dst_folders.items():
|
|
# create an instance of IShellItem for the target folder
|
|
folder_item = shell.SHCreateItemFromParsingName(
|
|
folder_path, None, shell.IID_IShellItem
|
|
)
|
|
for _dst_filename, source_file_path in items:
|
|
# create an instance of IShellItem for the source item
|
|
copy_item = shell.SHCreateItemFromParsingName(
|
|
source_file_path, None, shell.IID_IShellItem
|
|
)
|
|
# queue the copy operation
|
|
fo.CopyItem(copy_item, folder_item, _dst_filename, None)
|
|
|
|
# commit
|
|
fo.PerformOperations()
|
|
|
|
# Remove temp folder
|
|
shutil.rmtree(tmp_dir)
|
|
|
|
def _prepare_windows_plugin(self, launch_args):
|
|
"""Copy plugin to TVPaint plugins and set PATH to dependencies.
|
|
|
|
Check if plugin in TVPaint's plugins exist and match to plugin
|
|
version to current implementation version. Based on 64-bit or 32-bit
|
|
version of the plugin. Path to libraries required for plugin is added
|
|
to PATH variable.
|
|
"""
|
|
|
|
host_executable = launch_args[0]
|
|
executable_file = os.path.basename(host_executable)
|
|
if "64bit" in executable_file:
|
|
subfolder = "windows_x64"
|
|
elif "32bit" in executable_file:
|
|
subfolder = "windows_x86"
|
|
else:
|
|
raise ValueError(
|
|
"Can't determine if executable "
|
|
"leads to 32-bit or 64-bit TVPaint!"
|
|
)
|
|
|
|
plugin_files_path = get_plugin_files_path()
|
|
# Folder for right windows plugin files
|
|
source_plugins_dir = os.path.join(plugin_files_path, subfolder)
|
|
|
|
# Path to libraries (.dll) required for plugin library
|
|
# - additional libraries can be copied to TVPaint installation folder
|
|
# (next to executable) or added to PATH environment variable
|
|
additional_libs_folder = os.path.join(
|
|
source_plugins_dir,
|
|
"additional_libraries"
|
|
)
|
|
additional_libs_folder = additional_libs_folder.replace("\\", "/")
|
|
if (
|
|
os.path.exists(additional_libs_folder)
|
|
and additional_libs_folder not in os.environ["PATH"]
|
|
):
|
|
os.environ["PATH"] += (os.pathsep + additional_libs_folder)
|
|
|
|
# Path to TVPaint's plugins folder (where we want to add our plugin)
|
|
host_plugins_path = os.path.join(
|
|
os.path.dirname(host_executable),
|
|
"plugins"
|
|
)
|
|
|
|
# Files that must be copied to TVPaint's plugin folder
|
|
plugin_dir = os.path.join(source_plugins_dir, "plugin")
|
|
|
|
to_copy = []
|
|
to_remove = []
|
|
# Remove old plugin name
|
|
deprecated_filepath = os.path.join(
|
|
host_plugins_path, "AvalonPlugin.dll"
|
|
)
|
|
if os.path.exists(deprecated_filepath):
|
|
to_remove.append(deprecated_filepath)
|
|
|
|
for filename in os.listdir(plugin_dir):
|
|
src_full_path = os.path.join(plugin_dir, filename)
|
|
dst_full_path = os.path.join(host_plugins_path, filename)
|
|
if dst_full_path in to_remove:
|
|
to_remove.remove(dst_full_path)
|
|
|
|
if (
|
|
not os.path.exists(dst_full_path)
|
|
or not filecmp.cmp(src_full_path, dst_full_path)
|
|
):
|
|
to_copy.append((src_full_path, dst_full_path))
|
|
|
|
# Skip copy if everything is done
|
|
if not to_copy and not to_remove:
|
|
return
|
|
|
|
# Try to copy
|
|
try:
|
|
self._windows_file_process(to_copy, to_remove)
|
|
except Exception:
|
|
log.error("Plugin copy failed", exc_info=True)
|
|
|
|
# Validate copy was done
|
|
invalid_copy = []
|
|
for src, dst in to_copy:
|
|
if not os.path.exists(dst) or not filecmp.cmp(src, dst):
|
|
invalid_copy.append((src, dst))
|
|
|
|
# Validate delete was dones
|
|
invalid_remove = []
|
|
for filepath in to_remove:
|
|
if os.path.exists(filepath):
|
|
invalid_remove.append(filepath)
|
|
|
|
if not invalid_remove and not invalid_copy:
|
|
return
|
|
|
|
msg_parts = []
|
|
if invalid_remove:
|
|
msg_parts.append(
|
|
"Failed to remove files: {}".format(", ".join(invalid_remove))
|
|
)
|
|
|
|
if invalid_copy:
|
|
_invalid = [
|
|
"\"{}\" -> \"{}\"".format(src, dst)
|
|
for src, dst in invalid_copy
|
|
]
|
|
msg_parts.append(
|
|
"Failed to copy files: {}".format(", ".join(_invalid))
|
|
)
|
|
raise RuntimeError(" & ".join(msg_parts))
|
|
|
|
def _launch_tv_paint(self, launch_args):
|
|
flags = (
|
|
subprocess.DETACHED_PROCESS
|
|
| subprocess.CREATE_NEW_PROCESS_GROUP
|
|
)
|
|
env = os.environ.copy()
|
|
# Remove QuickTime from PATH on windows
|
|
# - quicktime overrides TVPaint's ffmpeg encode/decode which may
|
|
# cause issues on loading
|
|
if platform.system().lower() == "windows":
|
|
new_path = []
|
|
for path in env["PATH"].split(os.pathsep):
|
|
if path and "quicktime" not in path.lower():
|
|
new_path.append(path)
|
|
env["PATH"] = os.pathsep.join(new_path)
|
|
|
|
kwargs = {
|
|
"env": env,
|
|
"creationflags": flags
|
|
}
|
|
self.process = subprocess.Popen(launch_args, **kwargs)
|
|
|
|
def _create_routes(self):
|
|
self.websocket_rpc = BaseTVPaintRpc(
|
|
self, loop=self.websocket_server.loop
|
|
)
|
|
self.websocket_server.add_route(
|
|
"*", "/", self.websocket_rpc.handle_request
|
|
)
|
|
|
|
def _start_webserver(self):
|
|
self.websocket_server.start()
|
|
# Make sure RPC is using same loop as websocket server
|
|
while not self.websocket_server.server_is_running:
|
|
time.sleep(0.1)
|
|
|
|
def _stop_webserver(self):
|
|
self.websocket_server.stop()
|
|
|
|
def _exit(self, exit_code=None):
|
|
self._stop_webserver()
|
|
if exit_code is not None:
|
|
self.exit_code = exit_code
|
|
|
|
if self.exit_code is None:
|
|
self.exit_code = 0
|
|
|
|
def stop(self):
|
|
"""Stop communication and currently running python process."""
|
|
log.info("Stopping communication")
|
|
self._exit()
|
|
|
|
def launch(self, launch_args):
|
|
"""Prepare all required data and launch host.
|
|
|
|
First is prepared websocket server as communication point for host,
|
|
when server is ready to use host is launched as subprocess.
|
|
"""
|
|
if platform.system().lower() == "windows":
|
|
self._prepare_windows_plugin(launch_args)
|
|
|
|
# Launch TVPaint and the websocket server.
|
|
log.info("Launching TVPaint")
|
|
self.websocket_server = WebSocketServer()
|
|
|
|
self._create_routes()
|
|
|
|
os.environ["WEBSOCKET_URL"] = "ws://localhost:{}".format(
|
|
self.websocket_server.port
|
|
)
|
|
|
|
log.info("Added request handler for url: {}".format(
|
|
os.environ["WEBSOCKET_URL"]
|
|
))
|
|
|
|
self._start_webserver()
|
|
|
|
# Start TVPaint when server is running
|
|
self._launch_tv_paint(launch_args)
|
|
|
|
log.info("Waiting for client connection")
|
|
while True:
|
|
if self.process.poll() is not None:
|
|
log.debug("Host process is not alive. Exiting")
|
|
self._exit(1)
|
|
return
|
|
|
|
if self.websocket_rpc.client_connected():
|
|
log.info("Client has connected")
|
|
break
|
|
time.sleep(0.5)
|
|
|
|
self._on_client_connect()
|
|
|
|
emit_event("application.launched")
|
|
|
|
def _on_client_connect(self):
|
|
self._initial_textfile_write()
|
|
|
|
def _initial_textfile_write(self):
|
|
"""Show popup about Write to file at start of TVPaint."""
|
|
tmp_file = tempfile.NamedTemporaryFile(
|
|
mode="w", prefix="a_tvp_", suffix=".txt", delete=False
|
|
)
|
|
tmp_file.close()
|
|
tmp_filepath = tmp_file.name.replace("\\", "/")
|
|
george_script = (
|
|
"tv_writetextfile \"strict\" \"append\" \"{}\" \"empty\""
|
|
).format(tmp_filepath)
|
|
|
|
result = CommunicationWrapper.execute_george(george_script)
|
|
|
|
# Remote the file
|
|
os.remove(tmp_filepath)
|
|
|
|
if result is None:
|
|
log.warning(
|
|
"Host was probably closed before plugin was initialized."
|
|
)
|
|
elif result.lower() == "forbidden":
|
|
log.warning("User didn't confirm saving files.")
|
|
|
|
def _client(self):
|
|
if not self.websocket_rpc:
|
|
log.warning("Communicator's server did not start yet.")
|
|
return None
|
|
|
|
for client in self.websocket_rpc.clients:
|
|
if not client.ws.closed:
|
|
return client
|
|
log.warning("Client is not yet connected to Communicator.")
|
|
return None
|
|
|
|
def client(self):
|
|
if not self._connected_client or self._connected_client.ws.closed:
|
|
self._connected_client = self._client()
|
|
return self._connected_client
|
|
|
|
def send_request(self, method, params=None):
|
|
client = self.client()
|
|
if not client:
|
|
return
|
|
|
|
return self.websocket_rpc.send_request(
|
|
client, method, params
|
|
)
|
|
|
|
def send_notification(self, method, params=None):
|
|
client = self.client()
|
|
if not client:
|
|
return
|
|
|
|
self.websocket_rpc.send_notification(
|
|
client, method, params
|
|
)
|
|
|
|
def execute_george(self, george_script):
|
|
"""Execute passed goerge script in TVPaint."""
|
|
return self.send_request(
|
|
"execute_george", [george_script]
|
|
)
|
|
|
|
def execute_george_through_file(self, george_script):
|
|
"""Execute george script with temp file.
|
|
|
|
Allows to execute multiline george script without stopping websocket
|
|
client.
|
|
|
|
On windows make sure script does not contain paths with backwards
|
|
slashes in paths, TVPaint won't execute properly in that case.
|
|
|
|
Args:
|
|
george_script (str): George script to execute. May be multilined.
|
|
"""
|
|
temporary_file = tempfile.NamedTemporaryFile(
|
|
mode="w", prefix="a_tvp_", suffix=".grg", delete=False
|
|
)
|
|
temporary_file.write(george_script)
|
|
temporary_file.close()
|
|
temp_file_path = temporary_file.name.replace("\\", "/")
|
|
self.execute_george("tv_runscript {}".format(temp_file_path))
|
|
os.remove(temp_file_path)
|
|
|
|
|
|
class QtCommunicator(BaseCommunicator):
|
|
menu_definitions = {
|
|
"title": "OpenPype Tools",
|
|
"menu_items": [
|
|
{
|
|
"callback": "workfiles_tool",
|
|
"label": "Workfiles",
|
|
"help": "Open workfiles tool"
|
|
}, {
|
|
"callback": "loader_tool",
|
|
"label": "Load",
|
|
"help": "Open loader tool"
|
|
}, {
|
|
"callback": "scene_inventory_tool",
|
|
"label": "Scene inventory",
|
|
"help": "Open scene inventory tool"
|
|
}, {
|
|
"callback": "publish_tool",
|
|
"label": "Publish",
|
|
"help": "Open publisher"
|
|
}, {
|
|
"callback": "library_loader_tool",
|
|
"label": "Library",
|
|
"help": "Open library loader tool"
|
|
}, {
|
|
"callback": "experimental_tools",
|
|
"label": "Experimental tools",
|
|
"help": "Open experimental tools dialog"
|
|
}
|
|
]
|
|
}
|
|
|
|
def __init__(self, qt_app):
|
|
super().__init__()
|
|
self.callback_queue = Queue()
|
|
self.qt_app = qt_app
|
|
|
|
def _create_routes(self):
|
|
self.websocket_rpc = QtTVPaintRpc(
|
|
self, loop=self.websocket_server.loop
|
|
)
|
|
self.websocket_server.add_route(
|
|
"*", "/", self.websocket_rpc.handle_request
|
|
)
|
|
|
|
def execute_in_main_thread(self, main_thread_item, wait=True):
|
|
"""Add `MainThreadItem` to callback queue and wait for result."""
|
|
self.callback_queue.put(main_thread_item)
|
|
if wait:
|
|
return main_thread_item.wait()
|
|
return
|
|
|
|
async def async_execute_in_main_thread(self, main_thread_item, wait=True):
|
|
"""Add `MainThreadItem` to callback queue and wait for result."""
|
|
self.callback_queue.put(main_thread_item)
|
|
if wait:
|
|
return await main_thread_item.async_wait()
|
|
|
|
def main_thread_listen(self):
|
|
"""Get last `MainThreadItem` from queue.
|
|
|
|
Must be called from main thread.
|
|
|
|
Method checks if host process is still running as it may cause
|
|
issues if not.
|
|
"""
|
|
# check if host still running
|
|
if self.process.poll() is not None:
|
|
self._exit()
|
|
return None
|
|
|
|
if self.callback_queue.empty():
|
|
return None
|
|
return self.callback_queue.get()
|
|
|
|
def _on_client_connect(self):
|
|
super()._on_client_connect()
|
|
self._build_menu()
|
|
|
|
def _build_menu(self):
|
|
self.send_request(
|
|
"define_menu", [self.menu_definitions]
|
|
)
|
|
|
|
def _exit(self, *args, **kwargs):
|
|
super()._exit(*args, **kwargs)
|
|
emit_event("application.exit")
|
|
self.qt_app.exit(self.exit_code)
|