Merge remote-tracking branch 'upstream/develop' into maya_look_containerize_reference_only

# Conflicts:
#	openpype/hosts/maya/plugins/load/load_look.py
This commit is contained in:
Roy Nieterau 2022-02-12 10:38:13 +01:00
commit 767537ca05
611 changed files with 353730 additions and 137053 deletions

View file

@ -412,3 +412,23 @@ def repack_version(directory):
directory name.
"""
PypeCommands().repack_version(directory)
@main.command()
@click.option("--project", help="Project name")
@click.option(
"--dirpath", help="Directory where package is stored", default=None
)
def pack_project(project, dirpath):
"""Create a package of project with all files and database dump."""
PypeCommands().pack_project(project, dirpath)
@main.command()
@click.option("--zipfile", help="Path to zip file")
@click.option(
"--root", help="Replace root which was stored in project", default=None
)
def unpack_project(zipfile, root):
"""Create a package of project with all files and database dump."""
PypeCommands().unpack_project(zipfile, root)

View file

@ -6,6 +6,8 @@ class AddLastWorkfileToLaunchArgs(PreLaunchHook):
"""Add last workfile path to launch arguments.
This is not possible to do for all applications the same way.
Checks 'start_last_workfile', if set to False, it will not open last
workfile. This property is set explicitly in Launcher.
"""
# Execute after workfile template copy

View file

@ -43,6 +43,7 @@ class GlobalHostDataHook(PreLaunchHook):
"env": self.launch_context.env,
"start_last_workfile": self.data.get("start_last_workfile"),
"last_workfile_path": self.data.get("last_workfile_path"),
"log": self.log

View file

@ -1,10 +1,10 @@
import os
import subprocess
from openpype.lib import (
PreLaunchHook,
get_openpype_execute_args
)
from openpype.lib.applications import get_non_python_host_kwargs
from openpype import PACKAGE_DIR as OPENPYPE_DIR
@ -40,7 +40,10 @@ class NonPythonHostHook(PreLaunchHook):
)
# Add workfile path if exists
workfile_path = self.data["last_workfile_path"]
if os.path.exists(workfile_path):
if (
self.data.get("start_last_workfile")
and workfile_path
and os.path.exists(workfile_path)):
new_launch_args.append(workfile_path)
# Append as whole list as these areguments should not be separated
@ -48,3 +51,7 @@ class NonPythonHostHook(PreLaunchHook):
if remainders:
self.launch_context.launch_args.extend(remainders)
self.launch_context.kwargs = \
get_non_python_host_kwargs(self.launch_context.kwargs)

View file

@ -35,7 +35,6 @@ def main(*subprocess_args):
launcher.start()
if os.environ.get("HEADLESS_PUBLISH"):
# reusing ConsoleTrayApp approach as it was already implemented
launcher.execute_in_main_thread(lambda: headless_publish(
log,
"CloseAE",

View file

@ -118,6 +118,7 @@ class CollectAERender(abstract_collect_render.AbstractCollectRender):
instance.anatomyData = context.data["anatomyData"]
instance.outputDir = self._get_output_dir(instance)
instance.context = context
settings = get_project_settings(os.getenv("AVALON_PROJECT"))
reviewable_subset_filter = \
@ -142,7 +143,6 @@ class CollectAERender(abstract_collect_render.AbstractCollectRender):
break
self.log.info("New instance:: {}".format(instance))
instances.append(instance)
return instances

View file

@ -5,11 +5,8 @@ def add_implementation_envs(env, _app):
"""Modify environments to contain all required for implementation."""
# Prepare path to implementation script
implementation_user_script_path = os.path.join(
os.environ["OPENPYPE_REPOS_ROOT"],
"repos",
"avalon-core",
"setup",
"blender"
os.path.dirname(os.path.abspath(__file__)),
"blender_addon"
)
# Add blender implementation script path to PYTHONPATH

View file

@ -1,94 +1,64 @@
import os
import sys
import traceback
"""Public API
import bpy
Anything that isn't defined here is INTERNAL and unreliable for external use.
from .lib import append_user_scripts
"""
from avalon import api as avalon
from pyblish import api as pyblish
from .pipeline import (
install,
uninstall,
ls,
publish,
containerise,
)
import openpype.hosts.blender
from .plugin import (
Creator,
Loader,
)
HOST_DIR = os.path.dirname(os.path.abspath(openpype.hosts.blender.__file__))
PLUGINS_DIR = os.path.join(HOST_DIR, "plugins")
PUBLISH_PATH = os.path.join(PLUGINS_DIR, "publish")
LOAD_PATH = os.path.join(PLUGINS_DIR, "load")
CREATE_PATH = os.path.join(PLUGINS_DIR, "create")
INVENTORY_PATH = os.path.join(PLUGINS_DIR, "inventory")
from .workio import (
open_file,
save_file,
current_file,
has_unsaved_changes,
file_extensions,
work_root,
)
ORIGINAL_EXCEPTHOOK = sys.excepthook
from .lib import (
lsattr,
lsattrs,
read,
maintained_selection,
get_selection,
# unique_name,
)
def pype_excepthook_handler(*args):
traceback.print_exception(*args)
__all__ = [
"install",
"uninstall",
"ls",
"publish",
"containerise",
"Creator",
"Loader",
def install():
"""Install Blender configuration for Avalon."""
sys.excepthook = pype_excepthook_handler
pyblish.register_plugin_path(str(PUBLISH_PATH))
avalon.register_plugin_path(avalon.Loader, str(LOAD_PATH))
avalon.register_plugin_path(avalon.Creator, str(CREATE_PATH))
append_user_scripts()
avalon.on("new", on_new)
avalon.on("open", on_open)
# Workfiles API
"open_file",
"save_file",
"current_file",
"has_unsaved_changes",
"file_extensions",
"work_root",
def uninstall():
"""Uninstall Blender configuration for Avalon."""
sys.excepthook = ORIGINAL_EXCEPTHOOK
pyblish.deregister_plugin_path(str(PUBLISH_PATH))
avalon.deregister_plugin_path(avalon.Loader, str(LOAD_PATH))
avalon.deregister_plugin_path(avalon.Creator, str(CREATE_PATH))
def set_start_end_frames():
from avalon import io
asset_name = io.Session["AVALON_ASSET"]
asset_doc = io.find_one({
"type": "asset",
"name": asset_name
})
scene = bpy.context.scene
# Default scene settings
frameStart = scene.frame_start
frameEnd = scene.frame_end
fps = scene.render.fps
resolution_x = scene.render.resolution_x
resolution_y = scene.render.resolution_y
# Check if settings are set
data = asset_doc.get("data")
if not data:
return
if data.get("frameStart"):
frameStart = data.get("frameStart")
if data.get("frameEnd"):
frameEnd = data.get("frameEnd")
if data.get("fps"):
fps = data.get("fps")
if data.get("resolutionWidth"):
resolution_x = data.get("resolutionWidth")
if data.get("resolutionHeight"):
resolution_y = data.get("resolutionHeight")
scene.frame_start = frameStart
scene.frame_end = frameEnd
scene.render.fps = fps
scene.render.resolution_x = resolution_x
scene.render.resolution_y = resolution_y
def on_new(arg1, arg2):
set_start_end_frames()
def on_open(arg1, arg2):
set_start_end_frames()
# Utility functions
"maintained_selection",
"lsattr",
"lsattrs",
"read",
"get_selection",
# "unique_name",
]

Binary file not shown.

After

Width:  |  Height:  |  Size: 632 B

View file

@ -1,9 +1,16 @@
import os
import traceback
import importlib
import contextlib
from typing import Dict, List, Union
import bpy
import addon_utils
from openpype.api import Logger
from . import pipeline
log = Logger.get_logger(__name__)
def load_scripts(paths):
@ -125,3 +132,155 @@ def append_user_scripts():
except Exception:
print("Couldn't load user scripts \"{}\"".format(user_scripts))
traceback.print_exc()
def imprint(node: bpy.types.bpy_struct_meta_idprop, data: Dict):
r"""Write `data` to `node` as userDefined attributes
Arguments:
node: Long name of node
data: Dictionary of key/value pairs
Example:
>>> import bpy
>>> def compute():
... return 6
...
>>> bpy.ops.mesh.primitive_cube_add()
>>> cube = bpy.context.view_layer.objects.active
>>> imprint(cube, {
... "regularString": "myFamily",
... "computedValue": lambda: compute()
... })
...
>>> cube['avalon']['computedValue']
6
"""
imprint_data = dict()
for key, value in data.items():
if value is None:
continue
if callable(value):
# Support values evaluated at imprint
value = value()
if not isinstance(value, (int, float, bool, str, list)):
raise TypeError(f"Unsupported type: {type(value)}")
imprint_data[key] = value
pipeline.metadata_update(node, imprint_data)
def lsattr(attr: str,
value: Union[str, int, bool, List, Dict, None] = None) -> List:
r"""Return nodes matching `attr` and `value`
Arguments:
attr: Name of Blender property
value: Value of attribute. If none
is provided, return all nodes with this attribute.
Example:
>>> lsattr("id", "myId")
... [bpy.data.objects["myNode"]
>>> lsattr("id")
... [bpy.data.objects["myNode"], bpy.data.objects["myOtherNode"]]
Returns:
list
"""
return lsattrs({attr: value})
def lsattrs(attrs: Dict) -> List:
r"""Return nodes with the given attribute(s).
Arguments:
attrs: Name and value pairs of expected matches
Example:
>>> lsattrs({"age": 5}) # Return nodes with an `age` of 5
# Return nodes with both `age` and `color` of 5 and blue
>>> lsattrs({"age": 5, "color": "blue"})
Returns a list.
"""
# For now return all objects, not filtered by scene/collection/view_layer.
matches = set()
for coll in dir(bpy.data):
if not isinstance(
getattr(bpy.data, coll),
bpy.types.bpy_prop_collection,
):
continue
for node in getattr(bpy.data, coll):
for attr, value in attrs.items():
avalon_prop = node.get(pipeline.AVALON_PROPERTY)
if not avalon_prop:
continue
if (avalon_prop.get(attr)
and (value is None or avalon_prop.get(attr) == value)):
matches.add(node)
return list(matches)
def read(node: bpy.types.bpy_struct_meta_idprop):
"""Return user-defined attributes from `node`"""
data = dict(node.get(pipeline.AVALON_PROPERTY))
# Ignore hidden/internal data
data = {
key: value
for key, value in data.items() if not key.startswith("_")
}
return data
def get_selection() -> List[bpy.types.Object]:
"""Return the selected objects from the current scene."""
return [obj for obj in bpy.context.scene.objects if obj.select_get()]
@contextlib.contextmanager
def maintained_selection():
r"""Maintain selection during context
Example:
>>> with maintained_selection():
... # Modify selection
... bpy.ops.object.select_all(action='DESELECT')
>>> # Selection restored
"""
previous_selection = get_selection()
previous_active = bpy.context.view_layer.objects.active
try:
yield
finally:
# Clear the selection
for node in get_selection():
node.select_set(state=False)
if previous_selection:
for node in previous_selection:
try:
node.select_set(state=True)
except ReferenceError:
# This could happen if a selected node was deleted during
# the context.
log.exception("Failed to reselect")
continue
try:
bpy.context.view_layer.objects.active = previous_active
except ReferenceError:
# This could happen if the active node was deleted during the
# context.
log.exception("Failed to set active object.")

View file

@ -0,0 +1,410 @@
"""Blender operators and menus for use with Avalon."""
import os
import sys
import platform
import time
import traceback
import collections
from pathlib import Path
from types import ModuleType
from typing import Dict, List, Optional, Union
from Qt import QtWidgets, QtCore
import bpy
import bpy.utils.previews
import avalon.api
from openpype.tools.utils import host_tools
from openpype import style
from .workio import OpenFileCacher
PREVIEW_COLLECTIONS: Dict = dict()
# This seems like a good value to keep the Qt app responsive and doesn't slow
# down Blender. At least on macOS I the interace of Blender gets very laggy if
# you make it smaller.
TIMER_INTERVAL: float = 0.01
class BlenderApplication(QtWidgets.QApplication):
_instance = None
blender_windows = {}
def __init__(self, *args, **kwargs):
super(BlenderApplication, self).__init__(*args, **kwargs)
self.setQuitOnLastWindowClosed(False)
self.setStyleSheet(style.load_stylesheet())
self.lastWindowClosed.connect(self.__class__.reset)
@classmethod
def get_app(cls):
if cls._instance is None:
cls._instance = cls(sys.argv)
return cls._instance
@classmethod
def reset(cls):
cls._instance = None
@classmethod
def store_window(cls, identifier, window):
current_window = cls.get_window(identifier)
cls.blender_windows[identifier] = window
if current_window:
current_window.close()
# current_window.deleteLater()
@classmethod
def get_window(cls, identifier):
return cls.blender_windows.get(identifier)
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 it's 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 raise one.
"""
print("Executing process in main thread")
if self.done:
print("- item is already processed")
return
callback = self.callback
args = self.args
kwargs = self.kwargs
print("Running callback: {}".format(str(callback)))
try:
result = callback(*args, **kwargs)
self.result = result
except Exception:
self.exception = sys.exc_info()
finally:
print("Done")
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:
print(self.done)
time.sleep(self.sleep_time)
if self.exception is self.not_set:
return self.result
raise self.exception
class GlobalClass:
app = None
main_thread_callbacks = collections.deque()
is_windows = platform.system().lower() == "windows"
def execute_in_main_thread(main_thead_item):
print("execute_in_main_thread")
GlobalClass.main_thread_callbacks.append(main_thead_item)
def _process_app_events() -> Optional[float]:
"""Process the events of the Qt app if the window is still visible.
If the app has any top level windows and at least one of them is visible
return the time after which this function should be run again. Else return
None, so the function is not run again and will be unregistered.
"""
while GlobalClass.main_thread_callbacks:
main_thread_item = GlobalClass.main_thread_callbacks.popleft()
main_thread_item.execute()
if main_thread_item.exception is not MainThreadItem.not_set:
_clc, val, tb = main_thread_item.exception
msg = str(val)
detail = "\n".join(traceback.format_exception(_clc, val, tb))
dialog = QtWidgets.QMessageBox(
QtWidgets.QMessageBox.Warning,
"Error",
msg)
dialog.setMinimumWidth(500)
dialog.setDetailedText(detail)
dialog.exec_()
if not GlobalClass.is_windows:
if OpenFileCacher.opening_file:
return TIMER_INTERVAL
app = GlobalClass.app
if app._instance:
app.processEvents()
return TIMER_INTERVAL
return TIMER_INTERVAL
class LaunchQtApp(bpy.types.Operator):
"""A Base class for opertors to launch a Qt app."""
_app: QtWidgets.QApplication
_window = Union[QtWidgets.QDialog, ModuleType]
_tool_name: str = None
_init_args: Optional[List] = list()
_init_kwargs: Optional[Dict] = dict()
bl_idname: str = None
def __init__(self):
if self.bl_idname is None:
raise NotImplementedError("Attribute `bl_idname` must be set!")
print(f"Initialising {self.bl_idname}...")
self._app = BlenderApplication.get_app()
GlobalClass.app = self._app
bpy.app.timers.register(
_process_app_events,
persistent=True
)
def execute(self, context):
"""Execute the operator.
The child class must implement `execute()` where it only has to set
`self._window` to the desired Qt window and then simply run
`return super().execute(context)`.
`self._window` is expected to have a `show` method.
If the `show` method requires arguments, you can set `self._show_args`
and `self._show_kwargs`. `args` should be a list, `kwargs` a
dictionary.
"""
if self._tool_name is None:
if self._window is None:
raise AttributeError("`self._window` is not set.")
else:
window = self._app.get_window(self.bl_idname)
if window is None:
window = host_tools.get_tool_by_name(self._tool_name)
self._app.store_window(self.bl_idname, window)
self._window = window
if not isinstance(
self._window,
(QtWidgets.QMainWindow, QtWidgets.QDialog, ModuleType)
):
raise AttributeError(
"`window` should be a `QDialog or module`. Got: {}".format(
str(type(window))
)
)
self.before_window_show()
if isinstance(self._window, ModuleType):
self._window.show()
window = None
if hasattr(self._window, "window"):
window = self._window.window
elif hasattr(self._window, "_window"):
window = self._window.window
if window:
self._app.store_window(self.bl_idname, window)
else:
origin_flags = self._window.windowFlags()
on_top_flags = origin_flags | QtCore.Qt.WindowStaysOnTopHint
self._window.setWindowFlags(on_top_flags)
self._window.show()
if on_top_flags != origin_flags:
self._window.setWindowFlags(origin_flags)
self._window.show()
return {'FINISHED'}
def before_window_show(self):
return
class LaunchCreator(LaunchQtApp):
"""Launch Avalon Creator."""
bl_idname = "wm.avalon_creator"
bl_label = "Create..."
_tool_name = "creator"
def before_window_show(self):
self._window.refresh()
class LaunchLoader(LaunchQtApp):
"""Launch Avalon Loader."""
bl_idname = "wm.avalon_loader"
bl_label = "Load..."
_tool_name = "loader"
def before_window_show(self):
self._window.set_context(
{"asset": avalon.api.Session["AVALON_ASSET"]},
refresh=True
)
class LaunchPublisher(LaunchQtApp):
"""Launch Avalon Publisher."""
bl_idname = "wm.avalon_publisher"
bl_label = "Publish..."
def execute(self, context):
host_tools.show_publish()
return {"FINISHED"}
class LaunchManager(LaunchQtApp):
"""Launch Avalon Manager."""
bl_idname = "wm.avalon_manager"
bl_label = "Manage..."
_tool_name = "sceneinventory"
def before_window_show(self):
self._window.refresh()
class LaunchWorkFiles(LaunchQtApp):
"""Launch Avalon Work Files."""
bl_idname = "wm.avalon_workfiles"
bl_label = "Work Files..."
_tool_name = "workfiles"
def execute(self, context):
result = super().execute(context)
self._window.set_context({
"asset": avalon.api.Session["AVALON_ASSET"],
"silo": avalon.api.Session["AVALON_SILO"],
"task": avalon.api.Session["AVALON_TASK"]
})
return result
def before_window_show(self):
self._window.root = str(Path(
os.environ.get("AVALON_WORKDIR", ""),
os.environ.get("AVALON_SCENEDIR", ""),
))
self._window.refresh()
class TOPBAR_MT_avalon(bpy.types.Menu):
"""Avalon menu."""
bl_idname = "TOPBAR_MT_avalon"
bl_label = os.environ.get("AVALON_LABEL")
def draw(self, context):
"""Draw the menu in the UI."""
layout = self.layout
pcoll = PREVIEW_COLLECTIONS.get("avalon")
if pcoll:
pyblish_menu_icon = pcoll["pyblish_menu_icon"]
pyblish_menu_icon_id = pyblish_menu_icon.icon_id
else:
pyblish_menu_icon_id = 0
asset = avalon.api.Session['AVALON_ASSET']
task = avalon.api.Session['AVALON_TASK']
context_label = f"{asset}, {task}"
context_label_item = layout.row()
context_label_item.operator(
LaunchWorkFiles.bl_idname, text=context_label
)
context_label_item.enabled = False
layout.separator()
layout.operator(LaunchCreator.bl_idname, text="Create...")
layout.operator(LaunchLoader.bl_idname, text="Load...")
layout.operator(
LaunchPublisher.bl_idname,
text="Publish...",
icon_value=pyblish_menu_icon_id,
)
layout.operator(LaunchManager.bl_idname, text="Manage...")
layout.separator()
layout.operator(LaunchWorkFiles.bl_idname, text="Work Files...")
# TODO (jasper): maybe add 'Reload Pipeline', 'Reset Frame Range' and
# 'Reset Resolution'?
def draw_avalon_menu(self, context):
"""Draw the Avalon menu in the top bar."""
self.layout.menu(TOPBAR_MT_avalon.bl_idname)
classes = [
LaunchCreator,
LaunchLoader,
LaunchPublisher,
LaunchManager,
LaunchWorkFiles,
TOPBAR_MT_avalon,
]
def register():
"Register the operators and menu."
pcoll = bpy.utils.previews.new()
pyblish_icon_file = Path(__file__).parent / "icons" / "pyblish-32x32.png"
pcoll.load("pyblish_menu_icon", str(pyblish_icon_file.absolute()), 'IMAGE')
PREVIEW_COLLECTIONS["avalon"] = pcoll
for cls in classes:
bpy.utils.register_class(cls)
bpy.types.TOPBAR_MT_editor_menus.append(draw_avalon_menu)
def unregister():
"""Unregister the operators and menu."""
pcoll = PREVIEW_COLLECTIONS.pop("avalon")
bpy.utils.previews.remove(pcoll)
bpy.types.TOPBAR_MT_editor_menus.remove(draw_avalon_menu)
for cls in reversed(classes):
bpy.utils.unregister_class(cls)

View file

@ -0,0 +1,427 @@
import os
import sys
import importlib
import traceback
from typing import Callable, Dict, Iterator, List, Optional
import bpy
from . import lib
from . import ops
import pyblish.api
import avalon.api
from avalon import io, schema
from avalon.pipeline import AVALON_CONTAINER_ID
from openpype.api import Logger
import openpype.hosts.blender
HOST_DIR = os.path.dirname(os.path.abspath(openpype.hosts.blender.__file__))
PLUGINS_DIR = os.path.join(HOST_DIR, "plugins")
PUBLISH_PATH = os.path.join(PLUGINS_DIR, "publish")
LOAD_PATH = os.path.join(PLUGINS_DIR, "load")
CREATE_PATH = os.path.join(PLUGINS_DIR, "create")
INVENTORY_PATH = os.path.join(PLUGINS_DIR, "inventory")
ORIGINAL_EXCEPTHOOK = sys.excepthook
AVALON_INSTANCES = "AVALON_INSTANCES"
AVALON_CONTAINERS = "AVALON_CONTAINERS"
AVALON_PROPERTY = 'avalon'
IS_HEADLESS = bpy.app.background
log = Logger.get_logger(__name__)
def pype_excepthook_handler(*args):
traceback.print_exception(*args)
def install():
"""Install Blender configuration for Avalon."""
sys.excepthook = pype_excepthook_handler
pyblish.api.register_host("blender")
pyblish.api.register_plugin_path(str(PUBLISH_PATH))
avalon.api.register_plugin_path(avalon.api.Loader, str(LOAD_PATH))
avalon.api.register_plugin_path(avalon.api.Creator, str(CREATE_PATH))
lib.append_user_scripts()
avalon.api.on("new", on_new)
avalon.api.on("open", on_open)
_register_callbacks()
_register_events()
if not IS_HEADLESS:
ops.register()
def uninstall():
"""Uninstall Blender configuration for Avalon."""
sys.excepthook = ORIGINAL_EXCEPTHOOK
pyblish.api.deregister_host("blender")
pyblish.api.deregister_plugin_path(str(PUBLISH_PATH))
avalon.api.deregister_plugin_path(avalon.api.Loader, str(LOAD_PATH))
avalon.api.deregister_plugin_path(avalon.api.Creator, str(CREATE_PATH))
if not IS_HEADLESS:
ops.unregister()
def set_start_end_frames():
asset_name = io.Session["AVALON_ASSET"]
asset_doc = io.find_one({
"type": "asset",
"name": asset_name
})
scene = bpy.context.scene
# Default scene settings
frameStart = scene.frame_start
frameEnd = scene.frame_end
fps = scene.render.fps
resolution_x = scene.render.resolution_x
resolution_y = scene.render.resolution_y
# Check if settings are set
data = asset_doc.get("data")
if not data:
return
if data.get("frameStart"):
frameStart = data.get("frameStart")
if data.get("frameEnd"):
frameEnd = data.get("frameEnd")
if data.get("fps"):
fps = data.get("fps")
if data.get("resolutionWidth"):
resolution_x = data.get("resolutionWidth")
if data.get("resolutionHeight"):
resolution_y = data.get("resolutionHeight")
scene.frame_start = frameStart
scene.frame_end = frameEnd
scene.render.fps = fps
scene.render.resolution_x = resolution_x
scene.render.resolution_y = resolution_y
def on_new(arg1, arg2):
set_start_end_frames()
def on_open(arg1, arg2):
set_start_end_frames()
@bpy.app.handlers.persistent
def _on_save_pre(*args):
avalon.api.emit("before_save", args)
@bpy.app.handlers.persistent
def _on_save_post(*args):
avalon.api.emit("save", args)
@bpy.app.handlers.persistent
def _on_load_post(*args):
# Detect new file or opening an existing file
if bpy.data.filepath:
# Likely this was an open operation since it has a filepath
avalon.api.emit("open", args)
else:
avalon.api.emit("new", args)
ops.OpenFileCacher.post_load()
def _register_callbacks():
"""Register callbacks for certain events."""
def _remove_handler(handlers: List, callback: Callable):
"""Remove the callback from the given handler list."""
try:
handlers.remove(callback)
except ValueError:
pass
# TODO (jasper): implement on_init callback?
# Be sure to remove existig ones first.
_remove_handler(bpy.app.handlers.save_pre, _on_save_pre)
_remove_handler(bpy.app.handlers.save_post, _on_save_post)
_remove_handler(bpy.app.handlers.load_post, _on_load_post)
bpy.app.handlers.save_pre.append(_on_save_pre)
bpy.app.handlers.save_post.append(_on_save_post)
bpy.app.handlers.load_post.append(_on_load_post)
log.info("Installed event handler _on_save_pre...")
log.info("Installed event handler _on_save_post...")
log.info("Installed event handler _on_load_post...")
def _on_task_changed(*args):
"""Callback for when the task in the context is changed."""
# TODO (jasper): Blender has no concept of projects or workspace.
# It would be nice to override 'bpy.ops.wm.open_mainfile' so it takes the
# workdir as starting directory. But I don't know if that is possible.
# Another option would be to create a custom 'File Selector' and add the
# `directory` attribute, so it opens in that directory (does it?).
# https://docs.blender.org/api/blender2.8/bpy.types.Operator.html#calling-a-file-selector
# https://docs.blender.org/api/blender2.8/bpy.types.WindowManager.html#bpy.types.WindowManager.fileselect_add
workdir = avalon.api.Session["AVALON_WORKDIR"]
log.debug("New working directory: %s", workdir)
def _register_events():
"""Install callbacks for specific events."""
avalon.api.on("taskChanged", _on_task_changed)
log.info("Installed event callback for 'taskChanged'...")
def reload_pipeline(*args):
"""Attempt to reload pipeline at run-time.
Warning:
This is primarily for development and debugging purposes and not well
tested.
"""
avalon.api.uninstall()
for module in (
"avalon.io",
"avalon.lib",
"avalon.pipeline",
"avalon.tools.creator.app",
"avalon.tools.manager.app",
"avalon.api",
"avalon.tools",
):
module = importlib.import_module(module)
importlib.reload(module)
def _discover_gui() -> Optional[Callable]:
"""Return the most desirable of the currently registered GUIs"""
# Prefer last registered
guis = reversed(pyblish.api.registered_guis())
for gui in guis:
try:
gui = __import__(gui).show
except (ImportError, AttributeError):
continue
else:
return gui
return None
def add_to_avalon_container(container: bpy.types.Collection):
"""Add the container to the Avalon container."""
avalon_container = bpy.data.collections.get(AVALON_CONTAINERS)
if not avalon_container:
avalon_container = bpy.data.collections.new(name=AVALON_CONTAINERS)
# Link the container to the scene so it's easily visible to the artist
# and can be managed easily. Otherwise it's only found in "Blender
# File" view and it will be removed by Blenders garbage collection,
# unless you set a 'fake user'.
bpy.context.scene.collection.children.link(avalon_container)
avalon_container.children.link(container)
# Disable Avalon containers for the view layers.
for view_layer in bpy.context.scene.view_layers:
for child in view_layer.layer_collection.children:
if child.collection == avalon_container:
child.exclude = True
def metadata_update(node: bpy.types.bpy_struct_meta_idprop, data: Dict):
"""Imprint the node with metadata.
Existing metadata will be updated.
"""
if not node.get(AVALON_PROPERTY):
node[AVALON_PROPERTY] = dict()
for key, value in data.items():
if value is None:
continue
node[AVALON_PROPERTY][key] = value
def containerise(name: str,
namespace: str,
nodes: List,
context: Dict,
loader: Optional[str] = None,
suffix: Optional[str] = "CON") -> bpy.types.Collection:
"""Bundle `nodes` into an assembly and imprint it with metadata
Containerisation enables a tracking of version, author and origin
for loaded assets.
Arguments:
name: Name of resulting assembly
namespace: Namespace under which to host container
nodes: Long names of nodes to containerise
context: Asset information
loader: Name of loader used to produce this container.
suffix: Suffix of container, defaults to `_CON`.
Returns:
The container assembly
"""
node_name = f"{context['asset']['name']}_{name}"
if namespace:
node_name = f"{namespace}:{node_name}"
if suffix:
node_name = f"{node_name}_{suffix}"
container = bpy.data.collections.new(name=node_name)
# Link the children nodes
for obj in nodes:
container.objects.link(obj)
data = {
"schema": "openpype:container-2.0",
"id": AVALON_CONTAINER_ID,
"name": name,
"namespace": namespace or '',
"loader": str(loader),
"representation": str(context["representation"]["_id"]),
}
metadata_update(container, data)
add_to_avalon_container(container)
return container
def containerise_existing(
container: bpy.types.Collection,
name: str,
namespace: str,
context: Dict,
loader: Optional[str] = None,
suffix: Optional[str] = "CON") -> bpy.types.Collection:
"""Imprint or update container with metadata.
Arguments:
name: Name of resulting assembly
namespace: Namespace under which to host container
context: Asset information
loader: Name of loader used to produce this container.
suffix: Suffix of container, defaults to `_CON`.
Returns:
The container assembly
"""
node_name = container.name
if suffix:
node_name = f"{node_name}_{suffix}"
container.name = node_name
data = {
"schema": "openpype:container-2.0",
"id": AVALON_CONTAINER_ID,
"name": name,
"namespace": namespace or '',
"loader": str(loader),
"representation": str(context["representation"]["_id"]),
}
metadata_update(container, data)
add_to_avalon_container(container)
return container
def parse_container(container: bpy.types.Collection,
validate: bool = True) -> Dict:
"""Return the container node's full container data.
Args:
container: A container node name.
validate: turn the validation for the container on or off
Returns:
The container schema data for this container node.
"""
data = lib.read(container)
# Append transient data
data["objectName"] = container.name
if validate:
schema.validate(data)
return data
def ls() -> Iterator:
"""List containers from active Blender scene.
This is the host-equivalent of api.ls(), but instead of listing assets on
disk, it lists assets already loaded in Blender; once loaded they are
called containers.
"""
for container in lib.lsattr("id", AVALON_CONTAINER_ID):
yield parse_container(container)
def update_hierarchy(containers):
"""Hierarchical container support
This is the function to support Scene Inventory to draw hierarchical
view for containers.
We need both parent and children to visualize the graph.
"""
all_containers = set(ls()) # lookup set
for container in containers:
# Find parent
# FIXME (jasperge): re-evaluate this. How would it be possible
# to 'nest' assets? Collections can have several parents, for
# now assume it has only 1 parent
parent = [
coll for coll in bpy.data.collections if container in coll.children
]
for node in parent:
if node in all_containers:
container["parent"] = node
break
log.debug("Container: %s", container)
yield container
def publish():
"""Shorthand to publish from within host."""
return pyblish.util.publish()

View file

@ -5,10 +5,17 @@ from typing import Dict, List, Optional
import bpy
from avalon import api, blender
from avalon.blender import ops
from avalon.blender.pipeline import AVALON_CONTAINERS
import avalon.api
from openpype.api import PypeCreatorMixin
from .pipeline import AVALON_CONTAINERS
from .ops import (
MainThreadItem,
execute_in_main_thread
)
from .lib import (
imprint,
get_selection
)
VALID_EXTENSIONS = [".blend", ".json", ".abc", ".fbx"]
@ -42,10 +49,13 @@ def get_unique_number(
return f"{count:0>2}"
def prepare_data(data, container_name):
def prepare_data(data, container_name=None):
name = data.name
local_data = data.make_local()
local_data.name = f"{container_name}:{name}"
if container_name:
local_data.name = f"{container_name}:{name}"
else:
local_data.name = f"{name}"
return local_data
@ -119,11 +129,27 @@ def deselect_all():
bpy.context.view_layer.objects.active = active
class Creator(PypeCreatorMixin, blender.Creator):
pass
class Creator(PypeCreatorMixin, avalon.api.Creator):
"""Base class for Creator plug-ins."""
def process(self):
collection = bpy.data.collections.new(name=self.data["subset"])
bpy.context.scene.collection.children.link(collection)
imprint(collection, self.data)
if (self.options or {}).get("useSelection"):
for obj in get_selection():
collection.objects.link(obj)
return collection
class AssetLoader(api.Loader):
class Loader(avalon.api.Loader):
"""Base class for Loader plug-ins."""
hosts = ["blender"]
class AssetLoader(avalon.api.Loader):
"""A basic AssetLoader for Blender
This will implement the basic logic for linking/appending assets
@ -191,8 +217,8 @@ class AssetLoader(api.Loader):
namespace: Optional[str] = None,
options: Optional[Dict] = None) -> Optional[bpy.types.Collection]:
""" Run the loader on Blender main thread"""
mti = ops.MainThreadItem(self._load, context, name, namespace, options)
ops.execute_in_main_thread(mti)
mti = MainThreadItem(self._load, context, name, namespace, options)
execute_in_main_thread(mti)
def _load(self,
context: dict,
@ -257,8 +283,8 @@ class AssetLoader(api.Loader):
def update(self, container: Dict, representation: Dict):
""" Run the update on Blender main thread"""
mti = ops.MainThreadItem(self.exec_update, container, representation)
ops.execute_in_main_thread(mti)
mti = MainThreadItem(self.exec_update, container, representation)
execute_in_main_thread(mti)
def exec_remove(self, container: Dict) -> bool:
"""Must be implemented by a sub-class"""
@ -266,5 +292,5 @@ class AssetLoader(api.Loader):
def remove(self, container: Dict) -> bool:
""" Run the remove on Blender main thread"""
mti = ops.MainThreadItem(self.exec_remove, container)
ops.execute_in_main_thread(mti)
mti = MainThreadItem(self.exec_remove, container)
execute_in_main_thread(mti)

View file

@ -0,0 +1,90 @@
"""Host API required for Work Files."""
from pathlib import Path
from typing import List, Optional
import bpy
from avalon import api
class OpenFileCacher:
"""Store information about opening file.
When file is opening QApplcation events should not be processed.
"""
opening_file = False
@classmethod
def post_load(cls):
cls.opening_file = False
@classmethod
def set_opening(cls):
cls.opening_file = True
def open_file(filepath: str) -> Optional[str]:
"""Open the scene file in Blender."""
OpenFileCacher.set_opening()
preferences = bpy.context.preferences
load_ui = preferences.filepaths.use_load_ui
use_scripts = preferences.filepaths.use_scripts_auto_execute
result = bpy.ops.wm.open_mainfile(
filepath=filepath,
load_ui=load_ui,
use_scripts=use_scripts,
)
if result == {'FINISHED'}:
return filepath
return None
def save_file(filepath: str, copy: bool = False) -> Optional[str]:
"""Save the open scene file."""
preferences = bpy.context.preferences
compress = preferences.filepaths.use_file_compression
relative_remap = preferences.filepaths.use_relative_paths
result = bpy.ops.wm.save_as_mainfile(
filepath=filepath,
compress=compress,
relative_remap=relative_remap,
copy=copy,
)
if result == {'FINISHED'}:
return filepath
return None
def current_file() -> Optional[str]:
"""Return the path of the open scene file."""
current_filepath = bpy.data.filepath
if Path(current_filepath).is_file():
return current_filepath
return None
def has_unsaved_changes() -> bool:
"""Does the open scene file have unsaved changes?"""
return bpy.data.is_dirty
def file_extensions() -> List[str]:
"""Return the supported file extensions for Blender scene files."""
return api.HOST_WORKFILE_EXTENSIONS["blender"]
def work_root(session: dict) -> str:
"""Return the default root to browse for work files."""
work_dir = session["AVALON_WORKDIR"]
scene_dir = session.get("AVALON_SCENEDIR")
if scene_dir:
return str(Path(work_dir, scene_dir))
return work_dir

View file

@ -0,0 +1,4 @@
from avalon import pipeline
from openpype.hosts.blender import api
pipeline.install(api)

View file

@ -4,7 +4,7 @@ import bpy
from avalon import api
import openpype.hosts.blender.api.plugin
from avalon.blender import lib
from openpype.hosts.blender.api import lib
class CreateAction(openpype.hosts.blender.api.plugin.Creator):

View file

@ -3,9 +3,8 @@
import bpy
from avalon import api
from avalon.blender import lib, ops
from avalon.blender.pipeline import AVALON_INSTANCES
from openpype.hosts.blender.api import plugin
from openpype.hosts.blender.api import plugin, lib, ops
from openpype.hosts.blender.api.pipeline import AVALON_INSTANCES
class CreateAnimation(plugin.Creator):

View file

@ -3,9 +3,8 @@
import bpy
from avalon import api
from avalon.blender import lib, ops
from avalon.blender.pipeline import AVALON_INSTANCES
from openpype.hosts.blender.api import plugin
from openpype.hosts.blender.api import plugin, lib, ops
from openpype.hosts.blender.api.pipeline import AVALON_INSTANCES
class CreateCamera(plugin.Creator):

View file

@ -3,9 +3,8 @@
import bpy
from avalon import api
from avalon.blender import lib, ops
from avalon.blender.pipeline import AVALON_INSTANCES
from openpype.hosts.blender.api import plugin
from openpype.hosts.blender.api import plugin, lib, ops
from openpype.hosts.blender.api.pipeline import AVALON_INSTANCES
class CreateLayout(plugin.Creator):

View file

@ -3,9 +3,8 @@
import bpy
from avalon import api
from avalon.blender import lib, ops
from avalon.blender.pipeline import AVALON_INSTANCES
from openpype.hosts.blender.api import plugin
from openpype.hosts.blender.api import plugin, lib, ops
from openpype.hosts.blender.api.pipeline import AVALON_INSTANCES
class CreateModel(plugin.Creator):

View file

@ -3,8 +3,8 @@
import bpy
from avalon import api
from avalon.blender import lib
import openpype.hosts.blender.api.plugin
from openpype.hosts.blender.api import lib
class CreatePointcache(openpype.hosts.blender.api.plugin.Creator):

View file

@ -3,9 +3,8 @@
import bpy
from avalon import api
from avalon.blender import lib, ops
from avalon.blender.pipeline import AVALON_INSTANCES
from openpype.hosts.blender.api import plugin
from openpype.hosts.blender.api import plugin, lib, ops
from openpype.hosts.blender.api.pipeline import AVALON_INSTANCES
class CreateRig(plugin.Creator):

View file

@ -7,11 +7,12 @@ from typing import Dict, List, Optional
import bpy
from avalon import api
from avalon.blender import lib
from avalon.blender.pipeline import AVALON_CONTAINERS
from avalon.blender.pipeline import AVALON_CONTAINER_ID
from avalon.blender.pipeline import AVALON_PROPERTY
from openpype.hosts.blender.api import plugin
from openpype.hosts.blender.api.pipeline import (
AVALON_CONTAINERS,
AVALON_PROPERTY,
AVALON_CONTAINER_ID
)
from openpype.hosts.blender.api import plugin, lib
class CacheModelLoader(plugin.AssetLoader):

View file

@ -1,16 +1,11 @@
"""Load an animation in Blender."""
import logging
from typing import Dict, List, Optional
import bpy
from avalon.blender.pipeline import AVALON_PROPERTY
from openpype.hosts.blender.api import plugin
logger = logging.getLogger("openpype").getChild(
"blender").getChild("load_animation")
from openpype.hosts.blender.api.pipeline import AVALON_PROPERTY
class BlendAnimationLoader(plugin.AssetLoader):

View file

@ -7,10 +7,12 @@ from typing import Dict, List, Optional
import bpy
from avalon import api
from avalon.blender.pipeline import AVALON_CONTAINERS
from avalon.blender.pipeline import AVALON_CONTAINER_ID
from avalon.blender.pipeline import AVALON_PROPERTY
from openpype.hosts.blender.api import plugin
from openpype.hosts.blender.api.pipeline import (
AVALON_CONTAINERS,
AVALON_PROPERTY,
AVALON_CONTAINER_ID
)
class AudioLoader(plugin.AssetLoader):

View file

@ -8,10 +8,12 @@ from typing import Dict, List, Optional
import bpy
from avalon import api
from avalon.blender.pipeline import AVALON_CONTAINERS
from avalon.blender.pipeline import AVALON_CONTAINER_ID
from avalon.blender.pipeline import AVALON_PROPERTY
from openpype.hosts.blender.api import plugin
from openpype.hosts.blender.api.pipeline import (
AVALON_CONTAINERS,
AVALON_PROPERTY,
AVALON_CONTAINER_ID
)
logger = logging.getLogger("openpype").getChild(
"blender").getChild("load_camera")

View file

@ -7,11 +7,12 @@ from typing import Dict, List, Optional
import bpy
from avalon import api
from avalon.blender import lib
from avalon.blender.pipeline import AVALON_CONTAINERS
from avalon.blender.pipeline import AVALON_CONTAINER_ID
from avalon.blender.pipeline import AVALON_PROPERTY
from openpype.hosts.blender.api import plugin
from openpype.hosts.blender.api import plugin, lib
from openpype.hosts.blender.api.pipeline import (
AVALON_CONTAINERS,
AVALON_PROPERTY,
AVALON_CONTAINER_ID
)
class FbxCameraLoader(plugin.AssetLoader):

View file

@ -7,11 +7,12 @@ from typing import Dict, List, Optional
import bpy
from avalon import api
from avalon.blender import lib
from avalon.blender.pipeline import AVALON_CONTAINERS
from avalon.blender.pipeline import AVALON_CONTAINER_ID
from avalon.blender.pipeline import AVALON_PROPERTY
from openpype.hosts.blender.api import plugin
from openpype.hosts.blender.api import plugin, lib
from openpype.hosts.blender.api.pipeline import (
AVALON_CONTAINERS,
AVALON_PROPERTY,
AVALON_CONTAINER_ID
)
class FbxModelLoader(plugin.AssetLoader):

View file

@ -7,10 +7,13 @@ from typing import Dict, List, Optional
import bpy
from avalon import api
from avalon.blender.pipeline import AVALON_CONTAINERS
from avalon.blender.pipeline import AVALON_CONTAINER_ID
from avalon.blender.pipeline import AVALON_PROPERTY
from openpype import lib
from openpype.hosts.blender.api import plugin
from openpype.hosts.blender.api.pipeline import (
AVALON_CONTAINERS,
AVALON_PROPERTY,
AVALON_CONTAINER_ID
)
class BlendLayoutLoader(plugin.AssetLoader):
@ -59,7 +62,9 @@ class BlendLayoutLoader(plugin.AssetLoader):
library = bpy.data.libraries.get(bpy.path.basename(libpath))
bpy.data.libraries.remove(library)
def _process(self, libpath, asset_group, group_name, actions):
def _process(
self, libpath, asset_group, group_name, asset, representation, actions
):
with bpy.data.libraries.load(
libpath, link=True, relative=False
) as (data_from, data_to):
@ -72,7 +77,8 @@ class BlendLayoutLoader(plugin.AssetLoader):
container = None
for empty in empties:
if empty.get(AVALON_PROPERTY):
if (empty.get(AVALON_PROPERTY) and
empty.get(AVALON_PROPERTY).get('family') == 'layout'):
container = empty
break
@ -83,12 +89,16 @@ class BlendLayoutLoader(plugin.AssetLoader):
objects = []
nodes = list(container.children)
for obj in nodes:
obj.parent = asset_group
allowed_types = ['ARMATURE', 'MESH', 'EMPTY']
for obj in nodes:
objects.append(obj)
nodes.extend(list(obj.children))
if obj.type in allowed_types:
obj.parent = asset_group
for obj in nodes:
if obj.type in allowed_types:
objects.append(obj)
nodes.extend(list(obj.children))
objects.reverse()
@ -106,7 +116,7 @@ class BlendLayoutLoader(plugin.AssetLoader):
parent.objects.link(obj)
for obj in objects:
local_obj = plugin.prepare_data(obj, group_name)
local_obj = plugin.prepare_data(obj)
action = None
@ -114,7 +124,7 @@ class BlendLayoutLoader(plugin.AssetLoader):
action = actions.get(local_obj.name, None)
if local_obj.type == 'MESH':
plugin.prepare_data(local_obj.data, group_name)
plugin.prepare_data(local_obj.data)
if obj != local_obj:
for constraint in constraints:
@ -123,15 +133,18 @@ class BlendLayoutLoader(plugin.AssetLoader):
for material_slot in local_obj.material_slots:
if material_slot.material:
plugin.prepare_data(material_slot.material, group_name)
plugin.prepare_data(material_slot.material)
elif local_obj.type == 'ARMATURE':
plugin.prepare_data(local_obj.data, group_name)
plugin.prepare_data(local_obj.data)
if action is not None:
if local_obj.animation_data is None:
local_obj.animation_data_create()
local_obj.animation_data.action = action
elif local_obj.animation_data.action is not None:
elif (local_obj.animation_data and
local_obj.animation_data.action is not None):
plugin.prepare_data(
local_obj.animation_data.action, group_name)
local_obj.animation_data.action)
# Set link the drivers to the local object
if local_obj.data.animation_data:
@ -140,6 +153,21 @@ class BlendLayoutLoader(plugin.AssetLoader):
for t in v.targets:
t.id = local_obj
elif local_obj.type == 'EMPTY':
creator_plugin = lib.get_creator_by_name("CreateAnimation")
if not creator_plugin:
raise ValueError("Creator plugin \"CreateAnimation\" was "
"not found.")
api.create(
creator_plugin,
name=local_obj.name.split(':')[-1] + "_animation",
asset=asset,
options={"useSelection": False,
"asset_group": local_obj},
data={"dependencies": representation}
)
if not local_obj.get(AVALON_PROPERTY):
local_obj[AVALON_PROPERTY] = dict()
@ -148,7 +176,63 @@ class BlendLayoutLoader(plugin.AssetLoader):
objects.reverse()
bpy.data.orphans_purge(do_local_ids=False)
armatures = [
obj for obj in bpy.data.objects
if obj.type == 'ARMATURE' and obj.library is None]
arm_act = {}
# The armatures with an animation need to be at the center of the
# scene to be hooked correctly by the curves modifiers.
for armature in armatures:
if armature.animation_data and armature.animation_data.action:
arm_act[armature] = armature.animation_data.action
armature.animation_data.action = None
armature.location = (0.0, 0.0, 0.0)
for bone in armature.pose.bones:
bone.location = (0.0, 0.0, 0.0)
bone.rotation_euler = (0.0, 0.0, 0.0)
curves = [obj for obj in data_to.objects if obj.type == 'CURVE']
for curve in curves:
curve_name = curve.name.split(':')[0]
curve_obj = bpy.data.objects.get(curve_name)
local_obj = plugin.prepare_data(curve)
plugin.prepare_data(local_obj.data)
# Curves need to reset the hook, but to do that they need to be
# in the view layer.
parent.objects.link(local_obj)
plugin.deselect_all()
local_obj.select_set(True)
bpy.context.view_layer.objects.active = local_obj
if local_obj.library is None:
bpy.ops.object.mode_set(mode='EDIT')
bpy.ops.object.hook_reset()
bpy.ops.object.mode_set(mode='OBJECT')
parent.objects.unlink(local_obj)
local_obj.use_fake_user = True
for mod in local_obj.modifiers:
mod.object = bpy.data.objects.get(f"{mod.object.name}")
if not local_obj.get(AVALON_PROPERTY):
local_obj[AVALON_PROPERTY] = dict()
avalon_info = local_obj[AVALON_PROPERTY]
avalon_info.update({"container_name": group_name})
local_obj.parent = curve_obj
objects.append(local_obj)
for armature in armatures:
if arm_act.get(armature):
armature.animation_data.action = arm_act[armature]
while bpy.data.orphans_purge(do_local_ids=False):
pass
plugin.deselect_all()
@ -168,6 +252,7 @@ class BlendLayoutLoader(plugin.AssetLoader):
libpath = self.fname
asset = context["asset"]["name"]
subset = context["subset"]["name"]
representation = str(context["representation"]["_id"])
asset_name = plugin.asset_name(asset, subset)
unique_number = plugin.get_unique_number(asset, subset)
@ -183,7 +268,8 @@ class BlendLayoutLoader(plugin.AssetLoader):
asset_group.empty_display_type = 'SINGLE_ARROW'
avalon_container.objects.link(asset_group)
objects = self._process(libpath, asset_group, group_name, None)
objects = self._process(
libpath, asset_group, group_name, asset, representation, None)
for child in asset_group.children:
if child.get(AVALON_PROPERTY):

View file

@ -1,18 +1,20 @@
"""Load a layout in Blender."""
import json
from pathlib import Path
from pprint import pformat
from typing import Dict, Optional
import bpy
import json
from avalon import api
from avalon.blender.pipeline import AVALON_CONTAINERS
from avalon.blender.pipeline import AVALON_CONTAINER_ID
from avalon.blender.pipeline import AVALON_PROPERTY
from avalon.blender.pipeline import AVALON_INSTANCES
from openpype import lib
from openpype.hosts.blender.api.pipeline import (
AVALON_INSTANCES,
AVALON_CONTAINERS,
AVALON_PROPERTY,
AVALON_CONTAINER_ID
)
from openpype.hosts.blender.api import plugin
@ -92,6 +94,10 @@ class JsonLayoutLoader(plugin.AssetLoader):
'animation_asset': asset
}
if element.get('animation'):
options['animation_file'] = str(Path(libpath).with_suffix(
'')) + "." + element.get('animation')
# This should return the loaded asset, but the load call will be
# added to the queue to run in the Blender main thread, so
# at this time it will not return anything. The assets will be
@ -104,20 +110,22 @@ class JsonLayoutLoader(plugin.AssetLoader):
options=options
)
# Create the camera asset and the camera instance
creator_plugin = lib.get_creator_by_name("CreateCamera")
if not creator_plugin:
raise ValueError("Creator plugin \"CreateCamera\" was "
"not found.")
# Camera creation when loading a layout is not necessary for now,
# but the code is worth keeping in case we need it in the future.
# # Create the camera asset and the camera instance
# creator_plugin = lib.get_creator_by_name("CreateCamera")
# if not creator_plugin:
# raise ValueError("Creator plugin \"CreateCamera\" was "
# "not found.")
api.create(
creator_plugin,
name="camera",
# name=f"{unique_number}_{subset}_animation",
asset=asset,
options={"useSelection": False}
# data={"dependencies": str(context["representation"]["_id"])}
)
# api.create(
# creator_plugin,
# name="camera",
# # name=f"{unique_number}_{subset}_animation",
# asset=asset,
# options={"useSelection": False}
# # data={"dependencies": str(context["representation"]["_id"])}
# )
def process_asset(self,
context: dict,

View file

@ -8,8 +8,12 @@ import os
import json
import bpy
from avalon import api, blender
import openpype.hosts.blender.api.plugin as plugin
from avalon import api
from openpype.hosts.blender.api import plugin
from openpype.hosts.blender.api.pipeline import (
containerise_existing,
AVALON_PROPERTY
)
class BlendLookLoader(plugin.AssetLoader):
@ -105,7 +109,7 @@ class BlendLookLoader(plugin.AssetLoader):
container = bpy.data.collections.new(lib_container)
container.name = container_name
blender.pipeline.containerise_existing(
containerise_existing(
container,
name,
namespace,
@ -113,7 +117,7 @@ class BlendLookLoader(plugin.AssetLoader):
self.__class__.__name__,
)
metadata = container.get(blender.pipeline.AVALON_PROPERTY)
metadata = container.get(AVALON_PROPERTY)
metadata["libpath"] = libpath
metadata["lib_container"] = lib_container
@ -161,7 +165,7 @@ class BlendLookLoader(plugin.AssetLoader):
f"Unsupported file: {libpath}"
)
collection_metadata = collection.get(blender.pipeline.AVALON_PROPERTY)
collection_metadata = collection.get(AVALON_PROPERTY)
collection_libpath = collection_metadata["libpath"]
normalized_collection_libpath = (
@ -204,7 +208,7 @@ class BlendLookLoader(plugin.AssetLoader):
if not collection:
return False
collection_metadata = collection.get(blender.pipeline.AVALON_PROPERTY)
collection_metadata = collection.get(AVALON_PROPERTY)
for obj in collection_metadata['objects']:
for child in self.get_all_children(obj):

View file

@ -7,10 +7,12 @@ from typing import Dict, List, Optional
import bpy
from avalon import api
from avalon.blender.pipeline import AVALON_CONTAINERS
from avalon.blender.pipeline import AVALON_CONTAINER_ID
from avalon.blender.pipeline import AVALON_PROPERTY
from openpype.hosts.blender.api import plugin
from openpype.hosts.blender.api.pipeline import (
AVALON_CONTAINERS,
AVALON_PROPERTY,
AVALON_CONTAINER_ID
)
class BlendModelLoader(plugin.AssetLoader):
@ -81,7 +83,8 @@ class BlendModelLoader(plugin.AssetLoader):
plugin.prepare_data(local_obj.data, group_name)
for material_slot in local_obj.material_slots:
plugin.prepare_data(material_slot.material, group_name)
if material_slot.material:
plugin.prepare_data(material_slot.material, group_name)
if not local_obj.get(AVALON_PROPERTY):
local_obj[AVALON_PROPERTY] = dict()
@ -245,7 +248,8 @@ class BlendModelLoader(plugin.AssetLoader):
# If it is the last object to use that library, remove it
if count == 1:
library = bpy.data.libraries.get(bpy.path.basename(group_libpath))
bpy.data.libraries.remove(library)
if library:
bpy.data.libraries.remove(library)
self._process(str(libpath), asset_group, object_name)
@ -253,6 +257,7 @@ class BlendModelLoader(plugin.AssetLoader):
metadata["libpath"] = str(libpath)
metadata["representation"] = str(representation["_id"])
metadata["parent"] = str(representation["parent"])
def exec_remove(self, container: Dict) -> bool:
"""Remove an existing container from a Blender scene.

View file

@ -7,11 +7,14 @@ from typing import Dict, List, Optional
import bpy
from avalon import api
from avalon.blender.pipeline import AVALON_CONTAINERS
from avalon.blender.pipeline import AVALON_CONTAINER_ID
from avalon.blender.pipeline import AVALON_PROPERTY
from avalon.blender import lib as avalon_lib
from openpype import lib
from openpype.hosts.blender.api import plugin
from openpype.hosts.blender.api.pipeline import (
AVALON_CONTAINERS,
AVALON_PROPERTY,
AVALON_CONTAINER_ID
)
class BlendRigLoader(plugin.AssetLoader):
@ -110,6 +113,8 @@ class BlendRigLoader(plugin.AssetLoader):
plugin.prepare_data(local_obj.data, group_name)
if action is not None:
if local_obj.animation_data is None:
local_obj.animation_data_create()
local_obj.animation_data.action = action
elif (local_obj.animation_data and
local_obj.animation_data.action is not None):
@ -194,12 +199,14 @@ class BlendRigLoader(plugin.AssetLoader):
plugin.deselect_all()
create_animation = False
anim_file = None
if options is not None:
parent = options.get('parent')
transform = options.get('transform')
action = options.get('action')
create_animation = options.get('create_animation')
anim_file = options.get('animation_file')
if parent and transform:
location = transform.get('translation')
@ -252,6 +259,26 @@ class BlendRigLoader(plugin.AssetLoader):
plugin.deselect_all()
if anim_file:
bpy.ops.import_scene.fbx(filepath=anim_file, anim_offset=0.0)
imported = avalon_lib.get_selection()
armature = [
o for o in asset_group.children if o.type == 'ARMATURE'][0]
imported_group = [
o for o in imported if o.type == 'EMPTY'][0]
for obj in imported:
if obj.type == 'ARMATURE':
if not armature.animation_data:
armature.animation_data_create()
armature.animation_data.action = obj.animation_data.action
self._remove(imported_group)
bpy.data.objects.remove(imported_group)
bpy.context.scene.collection.objects.link(asset_group)
asset_group[AVALON_PROPERTY] = {
@ -348,6 +375,7 @@ class BlendRigLoader(plugin.AssetLoader):
metadata["libpath"] = str(libpath)
metadata["representation"] = str(representation["_id"])
metadata["parent"] = str(representation["parent"])
def exec_remove(self, container: Dict) -> bool:
"""Remove an existing asset group from a Blender scene.

View file

@ -1,11 +1,13 @@
import json
from typing import Generator
import bpy
import json
import pyblish.api
from avalon.blender.pipeline import AVALON_PROPERTY
from avalon.blender.pipeline import AVALON_INSTANCES
from openpype.hosts.blender.api.pipeline import (
AVALON_INSTANCES,
AVALON_PROPERTY,
)
class CollectInstances(pyblish.api.ContextPlugin):

View file

@ -1,10 +1,10 @@
import os
import bpy
from openpype import api
from openpype.hosts.blender.api import plugin
from avalon.blender.pipeline import AVALON_PROPERTY
import bpy
from openpype.hosts.blender.api.pipeline import AVALON_PROPERTY
class ExtractABC(api.Extractor):

View file

@ -2,7 +2,6 @@ import os
import bpy
# import avalon.blender.workio
import openpype.api

View file

@ -29,12 +29,13 @@ class ExtractBlendAnimation(openpype.api.Extractor):
if isinstance(obj, bpy.types.Object) and obj.type == 'EMPTY':
child = obj.children[0]
if child and child.type == 'ARMATURE':
if not obj.animation_data:
obj.animation_data_create()
obj.animation_data.action = child.animation_data.action
obj.animation_data_clear()
data_blocks.add(child.animation_data.action)
data_blocks.add(obj)
if child.animation_data and child.animation_data.action:
if not obj.animation_data:
obj.animation_data_create()
obj.animation_data.action = child.animation_data.action
obj.animation_data_clear()
data_blocks.add(child.animation_data.action)
data_blocks.add(obj)
bpy.data.libraries.write(filepath, data_blocks)

View file

@ -1,10 +1,10 @@
import os
import bpy
from openpype import api
from openpype.hosts.blender.api import plugin
import bpy
class ExtractCamera(api.Extractor):
"""Extract as the camera as FBX."""

View file

@ -1,10 +1,10 @@
import os
import bpy
from openpype import api
from openpype.hosts.blender.api import plugin
from avalon.blender.pipeline import AVALON_PROPERTY
import bpy
from openpype.hosts.blender.api.pipeline import AVALON_PROPERTY
class ExtractFBX(api.Extractor):
@ -50,6 +50,9 @@ class ExtractFBX(api.Extractor):
new_materials.append(mat)
new_materials_objs.append(obj)
scale_length = bpy.context.scene.unit_settings.scale_length
bpy.context.scene.unit_settings.scale_length = 0.01
# We export the fbx
bpy.ops.export_scene.fbx(
context,
@ -60,6 +63,8 @@ class ExtractFBX(api.Extractor):
add_leaf_bones=False
)
bpy.context.scene.unit_settings.scale_length = scale_length
plugin.deselect_all()
for mat in new_materials:

View file

@ -7,7 +7,7 @@ import bpy_extras.anim_utils
from openpype import api
from openpype.hosts.blender.api import plugin
from avalon.blender.pipeline import AVALON_PROPERTY
from openpype.hosts.blender.api.pipeline import AVALON_PROPERTY
class ExtractAnimationFBX(api.Extractor):
@ -37,13 +37,6 @@ class ExtractAnimationFBX(api.Extractor):
armature = [
obj for obj in asset_group.children if obj.type == 'ARMATURE'][0]
asset_group_name = asset_group.name
asset_group.name = asset_group.get(AVALON_PROPERTY).get("asset_name")
armature_name = armature.name
original_name = armature_name.split(':')[1]
armature.name = original_name
object_action_pairs = []
original_actions = []
@ -66,6 +59,13 @@ class ExtractAnimationFBX(api.Extractor):
self.log.info("Object have no animation.")
return
asset_group_name = asset_group.name
asset_group.name = asset_group.get(AVALON_PROPERTY).get("asset_name")
armature_name = armature.name
original_name = armature_name.split(':')[1]
armature.name = original_name
object_action_pairs.append((armature, copy_action))
original_actions.append(curr_action)
@ -123,7 +123,7 @@ class ExtractAnimationFBX(api.Extractor):
json_path = os.path.join(stagingdir, json_filename)
json_dict = {
"instance_name": asset_group.get(AVALON_PROPERTY).get("namespace")
"instance_name": asset_group.get(AVALON_PROPERTY).get("objectName")
}
# collection = instance.data.get("name")

View file

@ -2,9 +2,12 @@ import os
import json
import bpy
import bpy_extras
import bpy_extras.anim_utils
from avalon import io
from avalon.blender.pipeline import AVALON_PROPERTY
from openpype.hosts.blender.api import plugin
from openpype.hosts.blender.api.pipeline import AVALON_PROPERTY
import openpype.api
@ -16,6 +19,99 @@ class ExtractLayout(openpype.api.Extractor):
families = ["layout"]
optional = True
def _export_animation(self, asset, instance, stagingdir, fbx_count):
n = fbx_count
for obj in asset.children:
if obj.type != "ARMATURE":
continue
object_action_pairs = []
original_actions = []
starting_frames = []
ending_frames = []
# For each armature, we make a copy of the current action
curr_action = None
copy_action = None
if obj.animation_data and obj.animation_data.action:
curr_action = obj.animation_data.action
copy_action = curr_action.copy()
curr_frame_range = curr_action.frame_range
starting_frames.append(curr_frame_range[0])
ending_frames.append(curr_frame_range[1])
else:
self.log.info("Object have no animation.")
continue
asset_group_name = asset.name
asset.name = asset.get(AVALON_PROPERTY).get("asset_name")
armature_name = obj.name
original_name = armature_name.split(':')[1]
obj.name = original_name
object_action_pairs.append((obj, copy_action))
original_actions.append(curr_action)
# We compute the starting and ending frames
max_frame = min(starting_frames)
min_frame = max(ending_frames)
# We bake the copy of the current action for each object
bpy_extras.anim_utils.bake_action_objects(
object_action_pairs,
frames=range(int(min_frame), int(max_frame)),
do_object=False,
do_clean=False
)
for o in bpy.data.objects:
o.select_set(False)
asset.select_set(True)
obj.select_set(True)
fbx_filename = f"{n:03d}.fbx"
filepath = os.path.join(stagingdir, fbx_filename)
override = plugin.create_blender_context(
active=asset, selected=[asset, obj])
bpy.ops.export_scene.fbx(
override,
filepath=filepath,
use_active_collection=False,
use_selection=True,
bake_anim_use_nla_strips=False,
bake_anim_use_all_actions=False,
add_leaf_bones=False,
armature_nodetype='ROOT',
object_types={'EMPTY', 'ARMATURE'}
)
obj.name = armature_name
asset.name = asset_group_name
asset.select_set(False)
obj.select_set(False)
# We delete the baked action and set the original one back
for i in range(0, len(object_action_pairs)):
pair = object_action_pairs[i]
action = original_actions[i]
if action:
pair[0].animation_data.action = action
if pair[1]:
pair[1].user_clear()
bpy.data.actions.remove(pair[1])
return fbx_filename, n + 1
return None, n
def process(self, instance):
# Define extract output file path
stagingdir = self.staging_dir(instance)
@ -23,10 +119,16 @@ class ExtractLayout(openpype.api.Extractor):
# Perform extraction
self.log.info("Performing extraction..")
if "representations" not in instance.data:
instance.data["representations"] = []
json_data = []
fbx_files = []
asset_group = bpy.data.objects[str(instance)]
fbx_count = 0
for asset in asset_group.children:
metadata = asset.get(AVALON_PROPERTY)
@ -34,6 +136,7 @@ class ExtractLayout(openpype.api.Extractor):
family = metadata["family"]
self.log.debug("Parent: {}".format(parent))
# Get blend reference
blend = io.find_one(
{
"type": "representation",
@ -41,10 +144,39 @@ class ExtractLayout(openpype.api.Extractor):
"name": "blend"
},
projection={"_id": True})
blend_id = blend["_id"]
blend_id = None
if blend:
blend_id = blend["_id"]
# Get fbx reference
fbx = io.find_one(
{
"type": "representation",
"parent": io.ObjectId(parent),
"name": "fbx"
},
projection={"_id": True})
fbx_id = None
if fbx:
fbx_id = fbx["_id"]
# Get abc reference
abc = io.find_one(
{
"type": "representation",
"parent": io.ObjectId(parent),
"name": "abc"
},
projection={"_id": True})
abc_id = None
if abc:
abc_id = abc["_id"]
json_element = {}
json_element["reference"] = str(blend_id)
if blend_id:
json_element["reference"] = str(blend_id)
if fbx_id:
json_element["reference_fbx"] = str(fbx_id)
if abc_id:
json_element["reference_abc"] = str(abc_id)
json_element["family"] = family
json_element["instance_name"] = asset.name
json_element["asset_name"] = metadata["asset_name"]
@ -67,6 +199,16 @@ class ExtractLayout(openpype.api.Extractor):
"z": asset.scale.z
}
}
# Extract the animation as well
if family == "rig":
f, n = self._export_animation(
asset, instance, stagingdir, fbx_count)
if f:
fbx_files.append(f)
json_element["animation"] = f
fbx_count = n
json_data.append(json_element)
json_filename = "{}.json".format(instance.name)
@ -75,16 +217,32 @@ class ExtractLayout(openpype.api.Extractor):
with open(json_path, "w+") as file:
json.dump(json_data, fp=file, indent=2)
if "representations" not in instance.data:
instance.data["representations"] = []
representation = {
json_representation = {
'name': 'json',
'ext': 'json',
'files': json_filename,
"stagingDir": stagingdir,
}
instance.data["representations"].append(representation)
instance.data["representations"].append(json_representation)
self.log.debug(fbx_files)
if len(fbx_files) == 1:
fbx_representation = {
'name': 'fbx',
'ext': '000.fbx',
'files': fbx_files[0],
"stagingDir": stagingdir,
}
instance.data["representations"].append(fbx_representation)
elif len(fbx_files) > 1:
fbx_representation = {
'name': 'fbx',
'ext': 'fbx',
'files': fbx_files,
"stagingDir": stagingdir,
}
instance.data["representations"].append(fbx_representation)
self.log.info("Extracted instance '%s' to: %s",
instance.name, representation)
instance.name, json_representation)

View file

@ -1,5 +1,5 @@
import pyblish.api
import avalon.blender.workio
from openpype.hosts.blender.api.workio import save_file
class IncrementWorkfileVersion(pyblish.api.ContextPlugin):
@ -9,7 +9,7 @@ class IncrementWorkfileVersion(pyblish.api.ContextPlugin):
label = "Increment Workfile Version"
optional = True
hosts = ["blender"]
families = ["animation", "model", "rig", "action"]
families = ["animation", "model", "rig", "action", "layout"]
def process(self, context):
@ -20,6 +20,6 @@ class IncrementWorkfileVersion(pyblish.api.ContextPlugin):
path = context.data["currentFile"]
filepath = version_up(path)
avalon.blender.workio.save_file(filepath, copy=False)
save_file(filepath, copy=False)
self.log.info('Incrementing script version')

View file

@ -5,15 +5,15 @@ import openpype.hosts.blender.api.action
class ValidateObjectIsInObjectMode(pyblish.api.InstancePlugin):
"""Validate that the current object is in Object Mode."""
"""Validate that the objects in the instance are in Object Mode."""
order = pyblish.api.ValidatorOrder - 0.01
hosts = ["blender"]
families = ["model", "rig"]
families = ["model", "rig", "layout"]
category = "geometry"
label = "Object is in Object Mode"
label = "Validate Object Mode"
actions = [openpype.hosts.blender.api.action.SelectInvalidAction]
optional = True
optional = False
@classmethod
def get_invalid(cls, instance) -> List:

View file

@ -1,3 +0,0 @@
from openpype.hosts.blender import api
api.install()

View file

@ -71,7 +71,7 @@ class ExtractSubsetResources(openpype.api.Extractor):
staging_dir = self.staging_dir(instance)
# add default preset type for thumbnail and reviewable video
# update them with settings and overide in case the same
# update them with settings and override in case the same
# are found in there
export_presets = deepcopy(self.default_presets)
export_presets.update(self.export_presets_mapping)

View file

@ -4,7 +4,8 @@ import os
def add_implementation_envs(env, _app):
"""Modify environments to contain all required for implementation."""
openharmony_path = os.path.join(
os.environ["OPENPYPE_REPOS_ROOT"], "pype", "vendor", "OpenHarmony"
os.environ["OPENPYPE_REPOS_ROOT"], "openpype", "hosts",
"harmony", "vendor", "OpenHarmony"
)
# TODO check if is already set? What to do if is already set?
env["LIB_OPENHARMONY_PATH"] = openharmony_path

View file

@ -0,0 +1,655 @@
# Harmony Integration
## Setup
The easiest way to setup for using Toon Boom Harmony is to use the built-in launch:
```
python -c "import openpype.hosts.harmony.api as harmony;harmony.launch("path/to/harmony/executable")"
```
Communication with Harmony happens with a server/client relationship where the server is in the Python process and the client is in the Harmony process. Messages between Python and Harmony are required to be dictionaries, which are serialized to strings:
```
+------------+
| |
| Python |
| Process |
| |
| +--------+ |
| | | |
| | Main | |
| | Thread | |
| | | |
| +----^---+ |
| || |
| || |
| +---v----+ | +---------+
| | | | | |
| | Server +-------> Harmony |
| | Thread <-------+ Process |
| | | | | |
| +--------+ | +---------+
+------------+
```
Server/client now uses stricter protocol to handle communication. This is necessary because of precise control over data passed between server/client. Each message is prepended with 6 bytes:
```
| A | H | 0x00 | 0x00 | 0x00 | 0x00 | ...
```
First two bytes are *magic* bytes stands for **A**valon **H**armony. Next four bytes hold length of the message `...` encoded as 32bit unsigned integer. This way we know how many bytes to read from the socket and if we need more or we need to parse multiple messages.
## Usage
The integration creates an `Openpype` menu entry where all related tools are located.
**NOTE: Menu creation can be temperamental. The best way is to launch Harmony and do nothing else until Harmony is fully launched.**
### Work files
Because Harmony projects are directories, this integration uses `.zip` as work file extension. Internally the project directories are stored under `[User]/.avalon/harmony`. Whenever the user saves the `.xstage` file, the integration zips up the project directory and moves it to the Avalon project path. Zipping and moving happens in the background.
### Show Workfiles on launch
You can show the Workfiles app when Harmony launches by setting environment variable `AVALON_HARMONY_WORKFILES_ON_LAUNCH=1`.
## Developing
### Low level messaging
To send from Python to Harmony you can use the exposed method:
```python
import openpype.hosts.harmony.api as harmony
from uuid import uuid4
func = """function %s_hello(person)
{
return ("Hello " + person + "!");
}
%s_hello
""" % (uuid4(), uuid4())
print(harmony.send({"function": func, "args": ["Python"]})["result"])
```
**NOTE:** Its important to declare the function at the end of the function string. You can have multiple functions within your function string, but the function declared at the end is what gets executed.
To send a function with multiple arguments its best to declare the arguments within the function:
```python
import openpype.hosts.harmony.api as harmony
from uuid import uuid4
signature = str(uuid4()).replace("-", "_")
func = """function %s_hello(args)
{
var greeting = args[0];
var person = args[1];
return (greeting + " " + person + "!");
}
%s_hello
""" % (signature, signature)
print(harmony.send({"function": func, "args": ["Hello", "Python"]})["result"])
```
### Caution
When naming your functions be aware that they are executed in global scope. They can potentially clash with Harmony own function and object names.
For example `func` is already existing Harmony object. When you call your function `func` it will overwrite in global scope the one from Harmony, causing
erratic behavior of Harmony. Openpype is prefixing those function names with [UUID4](https://docs.python.org/3/library/uuid.html) making chance of such clash minimal.
See above examples how that works. This will result in function named `38dfcef0_a6d7_4064_8069_51fe99ab276e_hello()`.
You can find list of Harmony object and function in Harmony documentation.
### Higher level (recommended)
Instead of sending functions directly to Harmony, it is more efficient and safe to just add your code to `js/PypeHarmony.js` or utilize `{"script": "..."}` method.
#### Extending PypeHarmony.js
Add your function to `PypeHarmony.js`. For example:
```javascript
PypeHarmony.myAwesomeFunction = function() {
someCoolStuff();
};
```
Then you can call that javascript code from your Python like:
```Python
import openpype.hosts.harmony.api as harmony
harmony.send({"function": "PypeHarmony.myAwesomeFunction"});
```
#### Using Script method
You can also pass whole scripts into harmony and call their functions later as needed.
For example, you have bunch of javascript files:
```javascript
/* Master.js */
var Master = {
Foo = {};
Boo = {};
};
/* FileA.js */
var Foo = function() {};
Foo.prototype.A = function() {
someAStuff();
}
// This will construct object Foo and add it to Master namespace.
Master.Foo = new Foo();
/* FileB.js */
var Boo = function() {};
Boo.prototype.B = function() {
someBStuff();
}
// This will construct object Boo and add it to Master namespace.
Master.Boo = new Boo();
```
Now in python, just read all those files and send them to Harmony.
```python
from pathlib import Path
import openpype.hosts.harmony.api as harmony
path_to_js = Path('/path/to/my/js')
script_to_send = ""
for file in path_to_js.iterdir():
if file.suffix == ".js":
script_to_send += file.read_text()
harmony.send({"script": script_to_send})
# and use your code in Harmony
harmony.send({"function": "Master.Boo.B"})
```
### Scene Save
Instead of sending a request to Harmony with `scene.saveAll` please use:
```python
import openpype.hosts.harmony.api as harmony
harmony.save_scene()
```
<details>
<summary>Click to expand for details on scene save.</summary>
Because Openpype tools does not deal well with folders for a single entity like a Harmony scene, this integration has implemented to use zip files to encapsulate the Harmony scene folders. Saving scene in Harmony via menu or CTRL+S will not result in producing zip file, only saving it from Workfiles will. This is because
zipping process can take some time in which we cannot block user from saving again. If xstage file is changed during zipping process it will produce corrupted zip
archive.
</details>
### Plugin Examples
These plugins were made with the [polly config](https://github.com/mindbender-studio/config).
#### Creator Plugin
```python
import openpype.hosts.harmony.api as harmony
from uuid import uuid4
class CreateComposite(harmony.Creator):
"""Composite node for publish."""
name = "compositeDefault"
label = "Composite"
family = "mindbender.template"
def __init__(self, *args, **kwargs):
super(CreateComposite, self).__init__(*args, **kwargs)
```
The creator plugin can be configured to use other node types. For example here is a write node creator:
```python
import openpype.hosts.harmony.api as harmony
class CreateRender(harmony.Creator):
"""Composite node for publishing renders."""
name = "writeDefault"
label = "Write"
family = "mindbender.imagesequence"
node_type = "WRITE"
def __init__(self, *args, **kwargs):
super(CreateRender, self).__init__(*args, **kwargs)
def setup_node(self, node):
signature = str(uuid4()).replace("-", "_")
func = """function %s_func(args)
{
node.setTextAttr(args[0], "DRAWING_TYPE", 1, "PNG4");
}
%s_func
""" % (signature, signature)
harmony.send(
{"function": func, "args": [node]}
)
```
#### Collector Plugin
```python
import pyblish.api
import openpype.hosts.harmony.api as harmony
class CollectInstances(pyblish.api.ContextPlugin):
"""Gather instances by nodes metadata.
This collector takes into account assets that are associated with
a composite node and marked with a unique identifier;
Identifier:
id (str): "pyblish.avalon.instance"
"""
label = "Instances"
order = pyblish.api.CollectorOrder
hosts = ["harmony"]
def process(self, context):
nodes = harmony.send(
{"function": "node.getNodes", "args": [["COMPOSITE"]]}
)["result"]
for node in nodes:
data = harmony.read(node)
# Skip non-tagged nodes.
if not data:
continue
# Skip containers.
if "container" in data["id"]:
continue
instance = context.create_instance(node.split("/")[-1])
instance.append(node)
instance.data.update(data)
# Produce diagnostic message for any graphical
# user interface interested in visualising it.
self.log.info("Found: \"%s\" " % instance.data["name"])
```
#### Extractor Plugin
```python
import os
import pyblish.api
import openpype.hosts.harmony.api as harmony
import clique
class ExtractImage(pyblish.api.InstancePlugin):
"""Produce a flattened image file from instance.
This plug-in only takes into account the nodes connected to the composite.
"""
label = "Extract Image Sequence"
order = pyblish.api.ExtractorOrder
hosts = ["harmony"]
families = ["mindbender.imagesequence"]
def process(self, instance):
project_path = harmony.send(
{"function": "scene.currentProjectPath"}
)["result"]
# Store reference for integration
if "files" not in instance.data:
instance.data["files"] = list()
# Store display source node for later.
display_node = "Top/Display"
signature = str(uuid4()).replace("-", "_")
func = """function %s_func(display_node)
{
var source_node = null;
if (node.isLinked(display_node, 0))
{
source_node = node.srcNode(display_node, 0);
node.unlink(display_node, 0);
}
return source_node
}
%s_func
""" % (signature, signature)
display_source_node = harmony.send(
{"function": func, "args": [display_node]}
)["result"]
# Perform extraction
path = os.path.join(
os.path.normpath(
project_path
).replace("\\", "/"),
instance.data["name"]
)
if not os.path.exists(path):
os.makedirs(path)
render_func = """function frameReady(frame, celImage)
{{
var path = "{path}/{filename}" + frame + ".png";
celImage.imageFileAs(path, "", "PNG4");
}}
function %s_func(composite_node)
{{
node.link(composite_node, 0, "{display_node}", 0);
render.frameReady.connect(frameReady);
render.setRenderDisplay("{display_node}");
render.renderSceneAll();
render.frameReady.disconnect(frameReady);
}}
%s_func
""" % (signature, signature)
restore_func = """function %s_func(args)
{
var display_node = args[0];
var display_source_node = args[1];
if (node.isLinked(display_node, 0))
{
node.unlink(display_node, 0);
}
node.link(display_source_node, 0, display_node, 0);
}
%s_func
""" % (signature, signature)
with harmony.maintained_selection():
self.log.info("Extracting %s" % str(list(instance)))
harmony.send(
{
"function": render_func.format(
path=path.replace("\\", "/"),
filename=os.path.basename(path),
display_node=display_node
),
"args": [instance[0]]
}
)
# Restore display.
if display_source_node:
harmony.send(
{
"function": restore_func,
"args": [display_node, display_source_node]
}
)
files = os.listdir(path)
collections, remainder = clique.assemble(files, minimum_items=1)
assert not remainder, (
"There shouldn't have been a remainder for '%s': "
"%s" % (instance[0], remainder)
)
assert len(collections) == 1, (
"There should only be one image sequence in {}. Found: {}".format(
path, len(collections)
)
)
data = {
"subset": collections[0].head,
"isSeries": True,
"stagingDir": path,
"files": list(collections[0]),
}
instance.data.update(data)
self.log.info("Extracted {instance} to {path}".format(**locals()))
```
#### Loader Plugin
```python
import os
from avalon import api, io
import openpype.hosts.harmony.api as harmony
signature = str(uuid4()).replace("-", "_")
copy_files = """function copyFile(srcFilename, dstFilename)
{
var srcFile = new PermanentFile(srcFilename);
var dstFile = new PermanentFile(dstFilename);
srcFile.copy(dstFile);
}
"""
import_files = """function %s_import_files()
{
var PNGTransparencyMode = 0; // Premultiplied wih Black
var TGATransparencyMode = 0; // Premultiplied wih Black
var SGITransparencyMode = 0; // Premultiplied wih Black
var LayeredPSDTransparencyMode = 1; // Straight
var FlatPSDTransparencyMode = 2; // Premultiplied wih White
function getUniqueColumnName( column_prefix )
{
var suffix = 0;
// finds if unique name for a column
var column_name = column_prefix;
while(suffix < 2000)
{
if(!column.type(column_name))
break;
suffix = suffix + 1;
column_name = column_prefix + "_" + suffix;
}
return column_name;
}
function import_files(args)
{
var root = args[0];
var files = args[1];
var name = args[2];
var start_frame = args[3];
var vectorFormat = null;
var extension = null;
var filename = files[0];
var pos = filename.lastIndexOf(".");
if( pos < 0 )
return null;
extension = filename.substr(pos+1).toLowerCase();
if(extension == "jpeg")
extension = "jpg";
if(extension == "tvg")
{
vectorFormat = "TVG"
extension ="SCAN"; // element.add() will use this.
}
var elemId = element.add(
name,
"BW",
scene.numberOfUnitsZ(),
extension.toUpperCase(),
vectorFormat
);
if (elemId == -1)
{
// hum, unknown file type most likely -- let's skip it.
return null; // no read to add.
}
var uniqueColumnName = getUniqueColumnName(name);
column.add(uniqueColumnName , "DRAWING");
column.setElementIdOfDrawing(uniqueColumnName, elemId);
var read = node.add(root, name, "READ", 0, 0, 0);
var transparencyAttr = node.getAttr(
read, frame.current(), "READ_TRANSPARENCY"
);
var opacityAttr = node.getAttr(read, frame.current(), "OPACITY");
transparencyAttr.setValue(true);
opacityAttr.setValue(true);
var alignmentAttr = node.getAttr(read, frame.current(), "ALIGNMENT_RULE");
alignmentAttr.setValue("ASIS");
var transparencyModeAttr = node.getAttr(
read, frame.current(), "applyMatteToColor"
);
if (extension == "png")
transparencyModeAttr.setValue(PNGTransparencyMode);
if (extension == "tga")
transparencyModeAttr.setValue(TGATransparencyMode);
if (extension == "sgi")
transparencyModeAttr.setValue(SGITransparencyMode);
if (extension == "psd")
transparencyModeAttr.setValue(FlatPSDTransparencyMode);
node.linkAttr(read, "DRAWING.ELEMENT", uniqueColumnName);
// Create a drawing for each file.
for( var i =0; i <= files.length - 1; ++i)
{
timing = start_frame + i
// Create a drawing drawing, 'true' indicate that the file exists.
Drawing.create(elemId, timing, true);
// Get the actual path, in tmp folder.
var drawingFilePath = Drawing.filename(elemId, timing.toString());
copyFile( files[i], drawingFilePath );
column.setEntry(uniqueColumnName, 1, timing, timing.toString());
}
return read;
}
import_files();
}
%s_import_files
""" % (signature, signature)
replace_files = """function %s_replace_files(args)
{
var files = args[0];
var _node = args[1];
var start_frame = args[2];
var _column = node.linkedColumn(_node, "DRAWING.ELEMENT");
// Delete existing drawings.
var timings = column.getDrawingTimings(_column);
for( var i =0; i <= timings.length - 1; ++i)
{
column.deleteDrawingAt(_column, parseInt(timings[i]));
}
// Create new drawings.
for( var i =0; i <= files.length - 1; ++i)
{
timing = start_frame + i
// Create a drawing drawing, 'true' indicate that the file exists.
Drawing.create(node.getElementId(_node), timing, true);
// Get the actual path, in tmp folder.
var drawingFilePath = Drawing.filename(
node.getElementId(_node), timing.toString()
);
copyFile( files[i], drawingFilePath );
column.setEntry(_column, 1, timing, timing.toString());
}
}
%s_replace_files
""" % (signature, signature)
class ImageSequenceLoader(api.Loader):
"""Load images
Stores the imported asset in a container named after the asset.
"""
families = ["mindbender.imagesequence"]
representations = ["*"]
def load(self, context, name=None, namespace=None, data=None):
files = []
for f in context["version"]["data"]["files"]:
files.append(
os.path.join(
context["version"]["data"]["stagingDir"], f
).replace("\\", "/")
)
read_node = harmony.send(
{
"function": copy_files + import_files,
"args": ["Top", files, context["version"]["data"]["subset"], 1]
}
)["result"]
self[:] = [read_node]
return harmony.containerise(
name,
namespace,
read_node,
context,
self.__class__.__name__
)
def update(self, container, representation):
node = container.pop("node")
version = io.find_one({"_id": representation["parent"]})
files = []
for f in version["data"]["files"]:
files.append(
os.path.join(
version["data"]["stagingDir"], f
).replace("\\", "/")
)
harmony.send(
{
"function": copy_files + replace_files,
"args": [files, node, 1]
}
)
harmony.imprint(
node, {"representation": str(representation["_id"])}
)
def remove(self, container):
node = container.pop("node")
signature = str(uuid4()).replace("-", "_")
func = """function %s_deleteNode(_node)
{
node.deleteNode(_node, true, true);
}
%_deleteNode
""" % (signature, signature)
harmony.send(
{"function": func, "args": [node]}
)
def switch(self, container, representation):
self.update(container, representation)
```
## Resources
- https://github.com/diegogarciahuerta/tk-harmony
- https://github.com/cfourney/OpenHarmony
- [Toon Boom Discord](https://discord.gg/syAjy4H)
- [Toon Boom TD](https://discord.gg/yAjyQtZ)

View file

@ -0,0 +1,531 @@
/* global QTcpSocket, QByteArray, QDataStream, QTimer, QTextCodec, QIODevice, QApplication, include */
/* global QTcpSocket, QByteArray, QDataStream, QTimer, QTextCodec, QIODevice, QApplication, include */
/*
Avalon Harmony Integration - Client
-----------------------------------
This script implements client communication with Avalon server to bridge
gap between Python and QtScript.
*/
/* jshint proto: true */
var LD_OPENHARMONY_PATH = System.getenv('LIB_OPENHARMONY_PATH');
LD_OPENHARMONY_PATH = LD_OPENHARMONY_PATH + '/openHarmony.js';
LD_OPENHARMONY_PATH = LD_OPENHARMONY_PATH.replace(/\\/g, "/");
include(LD_OPENHARMONY_PATH);
this.__proto__['$'] = $;
function Client() {
var self = this;
/** socket */
self.socket = new QTcpSocket(this);
/** receiving data buffer */
self.received = '';
self.messageId = 1;
self.buffer = new QByteArray();
self.waitingForData = 0;
/**
* pack number into bytes.
* @function
* @param {int} num 32 bit integer
* @return {string}
*/
self.pack = function(num) {
var ascii='';
for (var i = 3; i >= 0; i--) {
ascii += String.fromCharCode((num >> (8 * i)) & 255);
}
return ascii;
};
/**
* unpack number from string.
* @function
* @param {string} numString bytes to unpack
* @return {int} 32bit unsigned integer.
*/
self.unpack = function(numString) {
var result=0;
for (var i = 3; i >= 0; i--) {
result += numString.charCodeAt(3 - i) << (8 * i);
}
return result;
};
/**
* prettify json for easier debugging
* @function
* @param {object} json json to process
* @return {string} prettified json string
*/
self.prettifyJson = function(json) {
var jsonString = JSON.stringify(json);
return JSON.stringify(JSON.parse(jsonString), null, 2);
};
/**
* Log message in debug level.
* @function
* @param {string} data - message
*/
self.logDebug = function(data) {
var message = typeof(data.message) != 'undefined' ? data.message : data;
MessageLog.trace('(DEBUG): ' + message.toString());
};
/**
* Log message in info level.
* @function
* @param {string} data - message
*/
self.logInfo = function(data) {
var message = typeof(data.message) != 'undefined' ? data.message : data;
MessageLog.trace('(DEBUG): ' + message.toString());
};
/**
* Log message in warning level.
* @function
* @param {string} data - message
*/
self.logWarning = function(data) {
var message = typeof(data.message) != 'undefined' ? data.message : data;
MessageLog.trace('(INFO): ' + message.toString());
};
/**
* Log message in error level.
* @function
* @param {string} data - message
*/
self.logError = function(data) {
var message = typeof(data.message) != 'undefined' ? data.message : data;
MessageLog.trace('(ERROR): ' +message.toString());
};
/**
* Show message in Harmony GUI as popup window.
* @function
* @param {string} msg - message
*/
self.showMessage = function(msg) {
MessageBox.information(msg);
};
/**
* Implement missing setTimeout() in Harmony.
* This calls once given function after some interval in milliseconds.
* @function
* @param {function} fc function to call.
* @param {int} interval interval in milliseconds.
* @param {boolean} single behave as setTimeout or setInterval.
*/
self.setTimeout = function(fc, interval, single) {
var timer = new QTimer();
if (!single) {
timer.singleShot = true; // in-case if setTimout and false in-case of setInterval
} else {
timer.singleShot = single;
}
timer.interval = interval; // set the time in milliseconds
timer.singleShot = true; // in-case if setTimout and false in-case of setInterval
timer.timeout.connect(this, function(){
fc.call();
});
timer.start();
};
/**
* Process recieved request. This will eval recieved function and produce
* results.
* @function
* @param {object} request - recieved request JSON
* @return {object} result of evaled function.
*/
self.processRequest = function(request) {
var mid = request.message_id;
if (typeof request.reply !== 'undefined') {
self.logDebug('['+ mid +'] *** received reply.');
return;
}
self.logDebug('['+ mid +'] - Processing: ' + self.prettifyJson(request));
var result = null;
if (typeof request.script !== 'undefined') {
self.logDebug('[' + mid + '] Injecting script.');
try {
eval.call(null, request.script);
} catch (error) {
self.logError(error);
}
} else if (typeof request["function"] !== 'undefined') {
try {
var _func = eval.call(null, request["function"]);
if (request.args == null) {
result = _func();
} else {
result = _func(request.args);
}
} catch (error) {
result = 'Error processing request.\n' +
'Request:\n' +
self.prettifyJson(request) + '\n' +
'Error:\n' + error;
}
} else {
self.logError('Command type not implemented.');
}
return result;
};
/**
* This gets called when socket received new data.
* @function
*/
self.onReadyRead = function() {
var currentSize = self.buffer.size();
self.logDebug('--- Receiving data ( '+ currentSize + ' )...');
var newData = self.socket.readAll();
var newSize = newData.size();
self.buffer.append(newData);
self.logDebug(' - got ' + newSize + ' bytes of new data.');
self.processBuffer();
};
/**
* Process data received in buffer.
* This detects messages by looking for header and message length.
* @function
*/
self.processBuffer = function() {
var length = self.waitingForData;
if (self.waitingForData == 0) {
// read header from the buffer and remove it
var header_data = self.buffer.mid(0, 6);
self.buffer = self.buffer.remove(0, 6);
// convert header to string
var header = '';
for (var i = 0; i < header_data.size(); ++i) {
// data in QByteArray come out as signed bytes.
var unsigned = header_data.at(i) & 0xff;
header = header.concat(String.fromCharCode(unsigned));
}
// skip 'AH' and read only length, unpack it to integer
header = header.substr(2);
length = self.unpack(header);
}
var data = self.buffer.mid(0, length);
self.logDebug('--- Expected: ' + length + ' | Got: ' + data.size());
if (length > data.size()) {
// we didn't received whole message.
self.waitingForData = length;
self.logDebug('... waiting for more data (' + length + ') ...');
return;
}
self.waitingForData = 0;
self.buffer.remove(0, length);
for (var j = 0; j < data.size(); ++j) {
self.received = self.received.concat(String.fromCharCode(data.at(j)));
}
// self.logDebug('--- Received: ' + self.received);
var to_parse = self.received;
var request = JSON.parse(to_parse);
var mid = request.message_id;
// self.logDebug('[' + mid + '] - Request: ' + '\n' + JSON.stringify(request));
self.logDebug('[' + mid + '] Recieved.');
request.result = self.processRequest(request);
self.logDebug('[' + mid + '] Processing done.');
self.received = '';
if (request.reply !== true) {
request.reply = true;
self.logDebug('[' + mid + '] Replying.');
self._send(JSON.stringify(request));
}
if ((length < data.size()) || (length < self.buffer.size())) {
// we've received more data.
self.logDebug('--- Got more data to process ...');
self.processBuffer();
}
};
/**
* Run when Harmony connects to server.
* @function
*/
self.onConnected = function() {
self.logDebug('Connected to server ...');
self.lock = false;
self.socket.readyRead.connect(self.onReadyRead);
var app = QCoreApplication.instance();
app.avalonClient.send(
{
'module': 'avalon.api',
'method': 'emit',
'args': ['application.launched']
}, false);
};
self._send = function(message) {
var data = new QByteArray();
var outstr = new QDataStream(data, QIODevice.WriteOnly);
outstr.writeInt(0);
data.append('UTF-8');
outstr.device().seek(0);
outstr.writeInt(data.size() - 4);
var codec = QTextCodec.codecForUtfText(data);
var msg = codec.fromUnicode(message);
var l = msg.size();
var coded = new QByteArray('AH').append(self.pack(l));
coded = coded.append(msg);
self.socket.write(new QByteArray(coded));
self.logDebug('Sent.');
};
self.waitForLock = function() {
if (self._lock === false) {
self.logDebug('>>> Unlocking ...');
return;
} else {
self.logDebug('Setting timer.');
self.setTimeout(self.waitForLock, 300);
}
};
/**
* Send request to server.
* @param {object} request - json encoded request.
*/
self.send = function(request) {
request.message_id = self.messageId;
if (typeof request.reply == 'undefined') {
self.logDebug("[" + self.messageId + "] sending:\n" + self.prettifyJson(request));
}
self._send(JSON.stringify(request));
};
/**
* Executed on disconnection.
*/
self.onDisconnected = function() {
self.socket.close();
};
/**
* Disconnect from server.
*/
self.disconnect = function() {
self.socket.close();
};
self.socket.connected.connect(self.onConnected);
self.socket.disconnected.connect(self.onDisconnected);
}
/**
* Entry point, creating Avalon Client.
*/
function start() {
var self = this;
/** hostname or ip of server - should be localhost */
var host = '127.0.0.1';
/** port of the server */
var port = parseInt(System.getenv('AVALON_HARMONY_PORT'));
// Attach the client to the QApplication to preserve.
var app = QCoreApplication.instance();
if (app.avalonClient == null) {
app.avalonClient = new Client();
app.avalonClient.socket.connectToHost(host, port);
}
var menuBar = QApplication.activeWindow().menuBar();
var actions = menuBar.actions();
app.avalonMenu = null;
for (var i = 0 ; i < actions.length; i++) {
label = System.getenv('AVALON_LABEL');
if (actions[i].text == label) {
app.avalonMenu = true;
}
}
var menu = null;
if (app.avalonMenu == null) {
menu = menuBar.addMenu(System.getenv('AVALON_LABEL'));
}
// menu = menuBar.addMenu('Avalon');
/**
* Show creator
*/
self.onCreator = function() {
app.avalonClient.send({
'module': 'openpype.hosts.harmony.api.lib',
'method': 'show',
'args': ['creator']
}, false);
};
var action = menu.addAction('Create...');
action.triggered.connect(self.onCreator);
/**
* Show Workfiles
*/
self.onWorkfiles = function() {
app.avalonClient.send({
'module': 'openpype.hosts.harmony.api.lib',
'method': 'show',
'args': ['workfiles']
}, false);
};
if (app.avalonMenu == null) {
action = menu.addAction('Workfiles...');
action.triggered.connect(self.onWorkfiles);
}
/**
* Show Loader
*/
self.onLoad = function() {
app.avalonClient.send({
'module': 'openpype.hosts.harmony.api.lib',
'method': 'show',
'args': ['loader']
}, false);
};
// add Loader item to menu
if (app.avalonMenu == null) {
action = menu.addAction('Load...');
action.triggered.connect(self.onLoad);
}
/**
* Show Publisher
*/
self.onPublish = function() {
app.avalonClient.send({
'module': 'openpype.hosts.harmony.api.lib',
'method': 'show',
'args': ['publish']
}, false);
};
// add Publisher item to menu
if (app.avalonMenu == null) {
action = menu.addAction('Publish...');
action.triggered.connect(self.onPublish);
}
/**
* Show Scene Manager
*/
self.onManage = function() {
app.avalonClient.send({
'module': 'openpype.hosts.harmony.api.lib',
'method': 'show',
'args': ['sceneinventory']
}, false);
};
// add Scene Manager item to menu
if (app.avalonMenu == null) {
action = menu.addAction('Manage...');
action.triggered.connect(self.onManage);
}
/**
* Show Subset Manager
*/
self.onSubsetManage = function() {
app.avalonClient.send({
'module': 'openpype.hosts.harmony.api.lib',
'method': 'show',
'args': ['subsetmanager']
}, false);
};
// add Subset Manager item to menu
if (app.avalonMenu == null) {
action = menu.addAction('Subset Manager...');
action.triggered.connect(self.onSubsetManage);
}
/**
* Show Experimental dialog
*/
self.onExperimentalTools = function() {
app.avalonClient.send({
'module': 'openpype.hosts.harmony.api.lib',
'method': 'show',
'args': ['experimental_tools']
}, false);
};
// add Subset Manager item to menu
if (app.avalonMenu == null) {
action = menu.addAction('Experimental Tools...');
action.triggered.connect(self.onExperimentalTools);
}
// FIXME(antirotor): We need to disable `on_file_changed` now as is wreak
// havoc when "Save" is called multiple times and zipping didn't finished yet
/*
// Watch scene file for changes.
app.onFileChanged = function(path)
{
var app = QCoreApplication.instance();
if (app.avalonOnFileChanged){
app.avalonClient.send(
{
'module': 'avalon.harmony.lib',
'method': 'on_file_changed',
'args': [path]
},
false
);
}
app.watcher.addPath(path);
};
app.watcher = new QFileSystemWatcher();
scene_path = scene.currentProjectPath() +"/" + scene.currentVersionName() + ".xstage";
app.watcher.addPath(scenePath);
app.watcher.fileChanged.connect(app.onFileChanged);
app.avalonOnFileChanged = true;
*/
app.onFileChanged = function(path) {
// empty stub
return path;
};
}
function ensureSceneSettings() {
var app = QCoreApplication.instance();
app.avalonClient.send(
{
"module": "openpype.hosts.harmony.api",
"method": "ensure_scene_settings",
"args": []
},
false
);
}
function TB_sceneOpened()
{
start();
}

View file

@ -1,209 +1,90 @@
# -*- coding: utf-8 -*-
"""Pype Harmony Host implementation."""
import os
from pathlib import Path
import logging
"""Public API
from openpype import lib
import openpype.hosts.harmony
Anything that isn't defined here is INTERNAL and unreliable for external use.
import pyblish.api
"""
from .pipeline import (
ls,
install,
list_instances,
remove_instance,
select_instance,
containerise,
set_scene_settings,
get_asset_settings,
ensure_scene_settings,
check_inventory,
application_launch,
export_template,
on_pyblish_instance_toggled,
inject_avalon_js,
)
from avalon import io, harmony
import avalon.api
from .lib import (
launch,
maintained_selection,
imprint,
read,
send,
maintained_nodes_state,
save_scene,
save_scene_as,
remove,
delete_node,
find_node_by_name,
signature,
select_nodes,
get_scene_data
)
from .workio import (
open_file,
save_file,
current_file,
has_unsaved_changes,
file_extensions,
work_root
)
log = logging.getLogger("openpype.hosts.harmony")
__all__ = [
# pipeline
"ls",
"install",
"list_instances",
"remove_instance",
"select_instance",
"containerise",
"set_scene_settings",
"get_asset_settings",
"ensure_scene_settings",
"check_inventory",
"application_launch",
"export_template",
"on_pyblish_instance_toggled",
"inject_avalon_js",
HOST_DIR = os.path.dirname(os.path.abspath(openpype.hosts.harmony.__file__))
PLUGINS_DIR = os.path.join(HOST_DIR, "plugins")
PUBLISH_PATH = os.path.join(PLUGINS_DIR, "publish")
LOAD_PATH = os.path.join(PLUGINS_DIR, "load")
CREATE_PATH = os.path.join(PLUGINS_DIR, "create")
INVENTORY_PATH = os.path.join(PLUGINS_DIR, "inventory")
# lib
"launch",
"maintained_selection",
"imprint",
"read",
"send",
"maintained_nodes_state",
"save_scene",
"save_scene_as",
"remove",
"delete_node",
"find_node_by_name",
"signature",
"select_nodes",
"get_scene_data",
# Workfiles API
"open_file",
"save_file",
"current_file",
"has_unsaved_changes",
"file_extensions",
"work_root",
]
def set_scene_settings(settings):
"""Set correct scene settings in Harmony.
Args:
settings (dict): Scene settings.
Returns:
dict: Dictionary of settings to set.
"""
harmony.send(
{"function": "PypeHarmony.setSceneSettings", "args": settings})
def get_asset_settings():
"""Get settings on current asset from database.
Returns:
dict: Scene data.
"""
asset_data = lib.get_asset()["data"]
fps = asset_data.get("fps")
frame_start = asset_data.get("frameStart")
frame_end = asset_data.get("frameEnd")
handle_start = asset_data.get("handleStart")
handle_end = asset_data.get("handleEnd")
resolution_width = asset_data.get("resolutionWidth")
resolution_height = asset_data.get("resolutionHeight")
entity_type = asset_data.get("entityType")
scene_data = {
"fps": fps,
"frameStart": frame_start,
"frameEnd": frame_end,
"handleStart": handle_start,
"handleEnd": handle_end,
"resolutionWidth": resolution_width,
"resolutionHeight": resolution_height,
"entityType": entity_type
}
return scene_data
def ensure_scene_settings():
"""Validate if Harmony scene has valid settings."""
settings = get_asset_settings()
invalid_settings = []
valid_settings = {}
for key, value in settings.items():
if value is None:
invalid_settings.append(key)
else:
valid_settings[key] = value
# Warn about missing attributes.
if invalid_settings:
msg = "Missing attributes:"
for item in invalid_settings:
msg += f"\n{item}"
harmony.send(
{"function": "PypeHarmony.message", "args": msg})
set_scene_settings(valid_settings)
def check_inventory():
"""Check is scene contains outdated containers.
If it does it will colorize outdated nodes and display warning message
in Harmony.
"""
if not lib.any_outdated():
return
host = avalon.api.registered_host()
outdated_containers = []
for container in host.ls():
representation = container['representation']
representation_doc = io.find_one(
{
"_id": io.ObjectId(representation),
"type": "representation"
},
projection={"parent": True}
)
if representation_doc and not lib.is_latest(representation_doc):
outdated_containers.append(container)
# Colour nodes.
outdated_nodes = []
for container in outdated_containers:
if container["loader"] == "ImageSequenceLoader":
outdated_nodes.append(
harmony.find_node_by_name(container["name"], "READ")
)
harmony.send({"function": "PypeHarmony.setColor", "args": outdated_nodes})
# Warn about outdated containers.
msg = "There are outdated containers in the scene."
harmony.send({"function": "PypeHarmony.message", "args": msg})
def application_launch():
"""Event that is executed after Harmony is launched."""
# FIXME: This is breaking server <-> client communication.
# It is now moved so it it manually called.
# ensure_scene_settings()
# check_inventory()
# fills OPENPYPE_HARMONY_JS
pype_harmony_path = Path(__file__).parent.parent / "js" / "PypeHarmony.js"
pype_harmony_js = pype_harmony_path.read_text()
# go through js/creators, loaders and publish folders and load all scripts
script = ""
for item in ["creators", "loaders", "publish"]:
dir_to_scan = Path(__file__).parent.parent / "js" / item
for child in dir_to_scan.iterdir():
script += child.read_text()
# send scripts to Harmony
harmony.send({"script": pype_harmony_js})
harmony.send({"script": script})
def export_template(backdrops, nodes, filepath):
"""Export Template to file.
Args:
backdrops (list): List of backdrops to export.
nodes (list): List of nodes to export.
filepath (str): Path where to save Template.
"""
harmony.send({
"function": "PypeHarmony.exportTemplate",
"args": [
backdrops,
nodes,
os.path.basename(filepath),
os.path.dirname(filepath)
]
})
def install():
"""Install Pype as host config."""
print("Installing Pype config ...")
pyblish.api.register_plugin_path(PUBLISH_PATH)
avalon.api.register_plugin_path(avalon.api.Loader, LOAD_PATH)
avalon.api.register_plugin_path(avalon.api.Creator, CREATE_PATH)
log.info(PUBLISH_PATH)
# Register callbacks.
pyblish.api.register_callback(
"instanceToggled", on_pyblish_instance_toggled
)
avalon.api.on("application.launched", application_launch)
def uninstall():
pyblish.api.deregister_plugin_path(PUBLISH_PATH)
avalon.api.deregister_plugin_path(avalon.api.Loader, LOAD_PATH)
avalon.api.deregister_plugin_path(avalon.api.Creator, CREATE_PATH)
def on_pyblish_instance_toggled(instance, old_value, new_value):
"""Toggle node enabling on instance toggles."""
node = None
if instance.data.get("setMembers"):
node = instance.data["setMembers"][0]
if node:
harmony.send(
{
"function": "PypeHarmony.toggleInstance",
"args": [node, new_value]
}
)

View file

@ -0,0 +1,117 @@
{
"env": {
"browser": true
},
"extends": "eslint:recommended",
"parserOptions": {
"ecmaVersion": 3
},
"rules": {
"indent": [
"error",
4
],
"linebreak-style": [
"error",
"unix"
],
"quotes": [
"error",
"single"
],
"semi": [
"error",
"always"
]
},
"globals": {
"Action": "readonly",
"Backdrop": "readonly",
"Button": "readonly",
"Cel": "readonly",
"Cel3d": "readonly",
"CheckBox": "readonly",
"ColorRGBA": "readonly",
"ComboBox": "readonly",
"DateEdit": "readonly",
"DateEditEnum": "readonly",
"Dialog": "readonly",
"Dir": "readonly",
"DirSpec": "readonly",
"Drawing": "readonly",
"DrawingToolParams": "readonly",
"DrawingTools": "readonly",
"EnvelopeCreator": "readonly",
"ExportVideoDlg": "readonly",
"File": "readonly",
"FileAccess": "readonly",
"FileDialog": "readonly",
"GroupBox": "readonly",
"ImportDrawingDlg": "readonly",
"Input": "readonly",
"KeyModifiers": "readonly",
"Label": "readonly",
"LayoutExports": "readonly",
"LayoutExportsParams": "readonly",
"LineEdit": "readonly",
"Matrix4x4": "readonly",
"MessageBox": "readonly",
"MessageLog": "readonly",
"Model3d": "readonly",
"MovieImport": "readonly",
"NumberEdit": "readonly",
"PaletteManager": "readonly",
"PaletteObjectManager": "readonly",
"PermanentFile": "readonly",
"Point2d": "readonly",
"Point3d": "readonly",
"Process": "readonly",
"Process2": "readonly",
"Quaternion": "readonly",
"QuicktimeExporter": "readonly",
"RadioButton": "readonly",
"RemoteCmd": "readonly",
"Scene": "readonly",
"Settings": "readonly",
"Slider": "readonly",
"SpinBox": "readonly",
"SubnodeData": "readonly",
"System": "readonly",
"TemporaryFile": "readonly",
"TextEdit": "readonly",
"TimeEdit": "readonly",
"Timeline": "readonly",
"ToolProperties": "readonly",
"UiLoader": "readonly",
"Vector2d": "readonly",
"Vector3d": "readonly",
"WebCCExporter": "readonly",
"Workspaces": "readonly",
"__scriptManager__": "readonly",
"__temporaryFileContext__": "readonly",
"about": "readonly",
"column": "readonly",
"compositionOrder": "readonly",
"copyPaste": "readonly",
"deformation": "readonly",
"drawingExport": "readonly",
"element": "readonly",
"exporter": "readonly",
"fileMapper": "readonly",
"frame": "readonly",
"func": "readonly",
"library": "readonly",
"node": "readonly",
"preferences": "readonly",
"render": "readonly",
"scene": "readonly",
"selection": "readonly",
"sound": "readonly",
"specialFolders": "readonly",
"translator": "readonly",
"view": "readonly",
"waypoint": "readonly",
"xsheet": "readonly",
"QCoreApplication": "readonly"
}
}

View file

@ -0,0 +1,226 @@
// ***************************************************************************
// * Avalon Harmony Host *
// ***************************************************************************
/**
* @namespace
* @classdesc AvalonHarmony encapsulate all Avalon related functions.
*/
var AvalonHarmony = {};
/**
* Get scene metadata from Harmony.
* @function
* @return {object} Scene metadata.
*/
AvalonHarmony.getSceneData = function() {
var metadata = scene.metadata('avalon');
if (metadata){
return JSON.parse(metadata.value);
}else {
return {};
}
};
/**
* Set scene metadata to Harmony.
* @function
* @param {object} metadata Object containing metadata.
*/
AvalonHarmony.setSceneData = function(metadata) {
scene.setMetadata({
'name' : 'avalon',
'type' : 'string',
'creator' : 'Avalon',
'version' : '1.0',
'value' : JSON.stringify(metadata)
});
};
/**
* Get selected nodes in Harmony.
* @function
* @return {array} Selected nodes paths.
*/
AvalonHarmony.getSelectedNodes = function () {
var selectionLength = selection.numberOfNodesSelected();
var selectedNodes = [];
for (var i = 0 ; i < selectionLength; i++) {
selectedNodes.push(selection.selectedNode(i));
}
return selectedNodes;
};
/**
* Set selection of nodes.
* @function
* @param {array} nodes Arrya containing node paths to add to selection.
*/
AvalonHarmony.selectNodes = function(nodes) {
selection.clearSelection();
for (var i = 0 ; i < nodes.length; i++) {
selection.addNodeToSelection(nodes[i]);
}
};
/**
* Is node enabled?
* @function
* @param {string} node Node path.
* @return {boolean} state
*/
AvalonHarmony.isEnabled = function(node) {
return node.getEnable(node);
};
/**
* Are nodes enabled?
* @function
* @param {array} nodes Array of node paths.
* @return {array} array of boolean states.
*/
AvalonHarmony.areEnabled = function(nodes) {
var states = [];
for (var i = 0 ; i < nodes.length; i++) {
states.push(node.getEnable(nodes[i]));
}
return states;
};
/**
* Set state on nodes.
* @function
* @param {array} args Array of nodes array and states array.
*/
AvalonHarmony.setState = function(args) {
var nodes = args[0];
var states = args[1];
// length of both arrays must be equal.
if (nodes.length !== states.length) {
return false;
}
for (var i = 0 ; i < nodes.length; i++) {
node.setEnable(nodes[i], states[i]);
}
return true;
};
/**
* Disable specified nodes.
* @function
* @param {array} nodes Array of nodes.
*/
AvalonHarmony.disableNodes = function(nodes) {
for (var i = 0 ; i < nodes.length; i++)
{
node.setEnable(nodes[i], false);
}
};
/**
* Save scene in Harmony.
* @function
* @return {string} Scene path.
*/
AvalonHarmony.saveScene = function() {
var app = QCoreApplication.instance();
app.avalon_on_file_changed = false;
scene.saveAll();
return (
scene.currentProjectPath() + '/' +
scene.currentVersionName() + '.xstage'
);
};
/**
* Enable Harmony file-watcher.
* @function
*/
AvalonHarmony.enableFileWather = function() {
var app = QCoreApplication.instance();
app.avalon_on_file_changed = true;
};
/**
* Add path to file-watcher.
* @function
* @param {string} path Path to watch.
*/
AvalonHarmony.addPathToWatcher = function(path) {
var app = QCoreApplication.instance();
app.watcher.addPath(path);
};
/**
* Setup node for Creator.
* @function
* @param {string} node Node path.
*/
AvalonHarmony.setupNodeForCreator = function(node) {
node.setTextAttr(node, 'COMPOSITE_MODE', 1, 'Pass Through');
};
/**
* Get node names for specified node type.
* @function
* @param {string} nodeType Node type.
* @return {array} Node names.
*/
AvalonHarmony.getNodesNamesByType = function(nodeType) {
var nodes = node.getNodes(nodeType);
var nodeNames = [];
for (var i = 0; i < nodes.length; ++i) {
nodeNames.push(node.getName(nodes[i]));
}
return nodeNames;
};
/**
* Create container node in Harmony.
* @function
* @param {array} args Arguments, see example.
* @return {string} Resulting node.
*
* @example
* // arguments are in following order:
* var args = [
* nodeName,
* nodeType,
* selection
* ];
*/
AvalonHarmony.createContainer = function(args) {
var resultNode = node.add('Top', args[0], args[1], 0, 0, 0);
if (args.length > 2) {
node.link(args[2], 0, resultNode, 0, false, true);
node.setCoord(resultNode,
node.coordX(args[2]),
node.coordY(args[2]) + 70);
}
return resultNode;
};
/**
* Delete node.
* @function
* @param {string} node Node path.
*/
AvalonHarmony.deleteNode = function(_node) {
node.deleteNode(_node, true, true);
};

View file

@ -0,0 +1,18 @@
{
"name": "avalon-harmony",
"version": "1.0.0",
"description": "Avalon Harmony Host integration",
"keywords": [
"Avalon",
"Harmony",
"pipeline"
],
"license": "MIT",
"main": "AvalonHarmony.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
},
"devDependencies": {
"eslint": "^7.11.0"
}
}

View file

@ -0,0 +1,622 @@
# -*- coding: utf-8 -*-
"""Utility functions used for Avalon - Harmony integration."""
import subprocess
import threading
import os
import random
import zipfile
import sys
import filecmp
import shutil
import logging
import contextlib
import json
import signal
import time
from uuid import uuid4
from Qt import QtWidgets, QtCore, QtGui
import collections
from .server import Server
from openpype.tools.stdout_broker.app import StdOutBroker
from openpype.tools.utils import host_tools
from openpype import style
from openpype.lib.applications import get_non_python_host_kwargs
# Setup logging.
log = logging.getLogger(__name__)
log.setLevel(logging.DEBUG)
class ProcessContext:
server = None
pid = None
process = None
application_path = None
callback_queue = collections.deque()
workfile_path = None
port = None
stdout_broker = None
workfile_tool = None
@classmethod
def execute_in_main_thread(cls, func_to_call_from_main_thread):
cls.callback_queue.append(func_to_call_from_main_thread)
@classmethod
def main_thread_listen(cls):
if cls.callback_queue:
callback = cls.callback_queue.popleft()
callback()
if cls.process is not None and cls.process.poll() is not None:
log.info("Server is not running, closing")
ProcessContext.stdout_broker.stop()
QtWidgets.QApplication.quit()
def signature(postfix="func") -> str:
"""Return random ECMA6 compatible function name.
Args:
postfix (str): name to append to random string.
Returns:
str: random function name.
"""
return "f{}_{}".format(str(uuid4()).replace("-", "_"), postfix)
class _ZipFile(zipfile.ZipFile):
"""Extended check for windows invalid characters."""
# this is extending default zipfile table for few invalid characters
# that can come from Mac
_windows_illegal_characters = ":<>|\"?*\r\n\x00"
_windows_illegal_name_trans_table = str.maketrans(
_windows_illegal_characters,
"_" * len(_windows_illegal_characters)
)
def main(*subprocess_args):
# coloring in StdOutBroker
os.environ["OPENPYPE_LOG_NO_COLORS"] = "False"
app = QtWidgets.QApplication([])
app.setQuitOnLastWindowClosed(False)
icon = QtGui.QIcon(style.get_app_icon_path())
app.setWindowIcon(icon)
ProcessContext.stdout_broker = StdOutBroker('harmony')
ProcessContext.stdout_broker.start()
launch(*subprocess_args)
loop_timer = QtCore.QTimer()
loop_timer.setInterval(20)
loop_timer.timeout.connect(ProcessContext.main_thread_listen)
loop_timer.start()
sys.exit(app.exec_())
def setup_startup_scripts():
"""Manages installation of avalon's TB_sceneOpened.js for Harmony launch.
If a studio already has defined "TOONBOOM_GLOBAL_SCRIPT_LOCATION", copies
the TB_sceneOpened.js to that location if the file is different.
Otherwise, will set the env var to point to the avalon/harmony folder.
Admins should be aware that this will overwrite TB_sceneOpened in the
"TOONBOOM_GLOBAL_SCRIPT_LOCATION", and that if they want to have additional
logic, they will need to one of the following:
* Create a Harmony package to manage startup logic
* Use TB_sceneOpenedUI.js instead to manage startup logic
* Add their startup logic to avalon/harmony/TB_sceneOpened.js
"""
avalon_dcc_dir = os.path.join(os.path.dirname(os.path.dirname(__file__)),
"api")
startup_js = "TB_sceneOpened.js"
if os.getenv("TOONBOOM_GLOBAL_SCRIPT_LOCATION"):
avalon_harmony_startup = os.path.join(avalon_dcc_dir, startup_js)
env_harmony_startup = os.path.join(
os.getenv("TOONBOOM_GLOBAL_SCRIPT_LOCATION"), startup_js)
if not filecmp.cmp(avalon_harmony_startup, env_harmony_startup):
try:
shutil.copy(avalon_harmony_startup, env_harmony_startup)
except Exception as e:
log.error(e)
log.warning(
"Failed to copy {0} to {1}! "
"Defaulting to Avalon TOONBOOM_GLOBAL_SCRIPT_LOCATION."
.format(avalon_harmony_startup, env_harmony_startup))
os.environ["TOONBOOM_GLOBAL_SCRIPT_LOCATION"] = avalon_dcc_dir
else:
os.environ["TOONBOOM_GLOBAL_SCRIPT_LOCATION"] = avalon_dcc_dir
def check_libs():
"""Check if `OpenHarmony`_ is available.
Avalon expects either path in `LIB_OPENHARMONY_PATH` or `openHarmony.js`
present in `TOONBOOM_GLOBAL_SCRIPT_LOCATION`.
Throws:
RuntimeError: If openHarmony is not found.
.. _OpenHarmony:
https://github.com/cfourney/OpenHarmony
"""
if not os.getenv("LIB_OPENHARMONY_PATH"):
if os.getenv("TOONBOOM_GLOBAL_SCRIPT_LOCATION"):
if os.path.exists(
os.path.join(
os.getenv("TOONBOOM_GLOBAL_SCRIPT_LOCATION"),
"openHarmony.js")):
os.environ["LIB_OPENHARMONY_PATH"] = \
os.getenv("TOONBOOM_GLOBAL_SCRIPT_LOCATION")
return
else:
log.error(("Cannot find OpenHarmony library. "
"Please set path to it in LIB_OPENHARMONY_PATH "
"environment variable."))
raise RuntimeError("Missing OpenHarmony library.")
def launch(application_path, *args):
"""Set Harmony for launch.
Launches Harmony and the server, then starts listening on the main thread
for callbacks from the server. This is to have Qt applications run in the
main thread.
Args:
application_path (str): Path to Harmony.
"""
from avalon import api
from openpype.hosts.harmony import api as harmony
api.install(harmony)
ProcessContext.port = random.randrange(49152, 65535)
os.environ["AVALON_HARMONY_PORT"] = str(ProcessContext.port)
ProcessContext.application_path = application_path
# Launch Harmony.
setup_startup_scripts()
check_libs()
if not os.environ.get("AVALON_HARMONY_WORKFILES_ON_LAUNCH", False):
open_empty_workfile()
return
ProcessContext.workfile_tool = host_tools.get_tool_by_name("workfiles")
host_tools.show_workfiles(save=False)
ProcessContext.execute_in_main_thread(check_workfiles_tool)
def check_workfiles_tool():
if ProcessContext.workfile_tool.isVisible():
ProcessContext.execute_in_main_thread(check_workfiles_tool)
elif not ProcessContext.workfile_path:
open_empty_workfile()
def open_empty_workfile():
zip_file = os.path.join(os.path.dirname(__file__), "temp.zip")
temp_path = get_local_harmony_path(zip_file)
if os.path.exists(temp_path):
log.info(f"removing existing {temp_path}")
try:
shutil.rmtree(temp_path)
except Exception as e:
log.critical(f"cannot clear {temp_path}")
raise Exception(f"cannot clear {temp_path}") from e
launch_zip_file(zip_file)
def get_local_harmony_path(filepath):
"""From the provided path get the equivalent local Harmony path."""
basename = os.path.splitext(os.path.basename(filepath))[0]
harmony_path = os.path.join(os.path.expanduser("~"), ".avalon", "harmony")
return os.path.join(harmony_path, basename)
def launch_zip_file(filepath):
"""Launch a Harmony application instance with the provided zip file.
Args:
filepath (str): Path to file.
"""
print(f"Localizing {filepath}")
temp_path = get_local_harmony_path(filepath)
scene_path = os.path.join(
temp_path, os.path.basename(temp_path) + ".xstage"
)
unzip = False
if os.path.exists(scene_path):
# Check remote scene is newer than local.
if os.path.getmtime(scene_path) < os.path.getmtime(filepath):
try:
shutil.rmtree(temp_path)
except Exception as e:
log.error(e)
raise Exception("Cannot delete working folder") from e
unzip = True
else:
unzip = True
if unzip:
with _ZipFile(filepath, "r") as zip_ref:
zip_ref.extractall(temp_path)
# Close existing scene.
if ProcessContext.pid:
os.kill(ProcessContext.pid, signal.SIGTERM)
# Stop server.
if ProcessContext.server:
ProcessContext.server.stop()
# Launch Avalon server.
ProcessContext.server = Server(ProcessContext.port)
ProcessContext.server.start()
# thread = threading.Thread(target=self.server.start)
# thread.daemon = True
# thread.start()
# Save workfile path for later.
ProcessContext.workfile_path = filepath
# find any xstage files is directory, prefer the one with the same name
# as directory (plus extension)
xstage_files = []
for _, _, files in os.walk(temp_path):
for file in files:
if os.path.splitext(file)[1] == ".xstage":
xstage_files.append(file)
if not os.path.basename("temp.zip"):
if not xstage_files:
ProcessContext.server.stop()
print("no xstage file was found")
return
# try to use first available
scene_path = os.path.join(
temp_path, xstage_files[0]
)
# prefer the one named as zip file
zip_based_name = "{}.xstage".format(
os.path.splitext(os.path.basename(filepath))[0])
if zip_based_name in xstage_files:
scene_path = os.path.join(
temp_path, zip_based_name
)
if not os.path.exists(scene_path):
print("error: cannot determine scene file")
ProcessContext.server.stop()
return
print("Launching {}".format(scene_path))
kwargs = get_non_python_host_kwargs({}, False)
process = subprocess.Popen(
[ProcessContext.application_path, scene_path],
**kwargs
)
ProcessContext.pid = process.pid
ProcessContext.process = process
ProcessContext.stdout_broker.host_connected()
def on_file_changed(path, threaded=True):
"""Threaded zipping and move of the project directory.
This method is called when the `.xstage` file is changed.
"""
log.debug("File changed: " + path)
if ProcessContext.workfile_path is None:
return
if threaded:
thread = threading.Thread(
target=zip_and_move,
args=(os.path.dirname(path), ProcessContext.workfile_path)
)
thread.start()
else:
zip_and_move(os.path.dirname(path), ProcessContext.workfile_path)
def zip_and_move(source, destination):
"""Zip a directory and move to `destination`.
Args:
source (str): Directory to zip and move to destination.
destination (str): Destination file path to zip file.
"""
os.chdir(os.path.dirname(source))
shutil.make_archive(os.path.basename(source), "zip", source)
with _ZipFile(os.path.basename(source) + ".zip") as zr:
if zr.testzip() is not None:
raise Exception("File archive is corrupted.")
shutil.move(os.path.basename(source) + ".zip", destination)
log.debug(f"Saved '{source}' to '{destination}'")
def show(module_name):
"""Call show on "module_name".
This allows to make a QApplication ahead of time and always "exec_" to
prevent crashing.
Args:
module_name (str): Name of module to call "show" on.
"""
# Requests often get doubled up when showing tools, so we wait a second for
# requests to be received properly.
time.sleep(1)
# Get tool name from module name
# TODO this is for backwards compatibility not sure if `TB_sceneOpened.js`
# is automatically updated.
# Previous javascript sent 'module_name' which contained whole tool import
# string e.g. "avalon.tools.workfiles" now it should be only "workfiles"
tool_name = module_name.split(".")[-1]
kwargs = {}
if tool_name == "loader":
kwargs["use_context"] = True
ProcessContext.execute_in_main_thread(
lambda: host_tools.show_tool_by_name(tool_name, **kwargs)
)
# Required return statement.
return "nothing"
def get_scene_data():
try:
return send(
{
"function": "AvalonHarmony.getSceneData"
})["result"]
except json.decoder.JSONDecodeError:
# Means no sceen metadata has been made before.
return {}
except KeyError:
# Means no existing scene metadata has been made.
return {}
def set_scene_data(data):
"""Write scene data to metadata.
Args:
data (dict): Data to write.
"""
# Write scene data.
send(
{
"function": "AvalonHarmony.setSceneData",
"args": data
})
def read(node_id):
"""Read object metadata in to a dictionary.
Args:
node_id (str): Path to node or id of object.
Returns:
dict
"""
scene_data = get_scene_data()
if node_id in scene_data:
return scene_data[node_id]
return {}
def remove(node_id):
"""
Remove node data from scene metadata.
Args:
node_id (str): full name (eg. 'Top/renderAnimation')
"""
data = get_scene_data()
del data[node_id]
set_scene_data(data)
def delete_node(node):
""" Physically delete node from scene. """
send(
{
"function": "AvalonHarmony.deleteNode",
"args": node
}
)
def imprint(node_id, data, remove=False):
"""Write `data` to the `node` as json.
Arguments:
node_id (str): Path to node or id of object.
data (dict): Dictionary of key/value pairs.
remove (bool): Removes the data from the scene.
Example:
>>> from avalon.harmony import lib
>>> node = "Top/Display"
>>> data = {"str": "someting", "int": 1, "float": 0.32, "bool": True}
>>> lib.imprint(layer, data)
"""
scene_data = get_scene_data()
if remove and (node_id in scene_data):
scene_data.pop(node_id, None)
else:
if node_id in scene_data:
scene_data[node_id].update(data)
else:
scene_data[node_id] = data
set_scene_data(scene_data)
@contextlib.contextmanager
def maintained_selection():
"""Maintain selection during context."""
selected_nodes = send(
{
"function": "AvalonHarmony.getSelectedNodes"
})["result"]
try:
yield selected_nodes
finally:
selected_nodes = send(
{
"function": "AvalonHarmony.selectNodes",
"args": selected_nodes
}
)
def send(request):
"""Public method for sending requests to Harmony."""
return ProcessContext.server.send(request)
def select_nodes(nodes):
""" Selects nodes in Node View """
_ = send(
{
"function": "AvalonHarmony.selectNodes",
"args": nodes
}
)
@contextlib.contextmanager
def maintained_nodes_state(nodes):
"""Maintain nodes states during context."""
# Collect current state.
states = send(
{
"function": "AvalonHarmony.areEnabled", "args": nodes
})["result"]
# Disable all nodes.
send(
{
"function": "AvalonHarmony.disableNodes", "args": nodes
})
try:
yield
finally:
send(
{
"function": "AvalonHarmony.setState",
"args": [nodes, states]
})
def save_scene():
"""Save the Harmony scene safely.
The built-in (to Avalon) background zip and moving of the Harmony scene
folder, interfers with server/client communication by sending two requests
at the same time. This only happens when sending "scene.saveAll()". This
method prevents this double request and safely saves the scene.
"""
# Need to turn off the backgound watcher else the communication with
# the server gets spammed with two requests at the same time.
scene_path = send(
{"function": "AvalonHarmony.saveScene"})["result"]
# Manually update the remote file.
on_file_changed(scene_path, threaded=False)
# Re-enable the background watcher.
send({"function": "AvalonHarmony.enableFileWather"})
def save_scene_as(filepath):
"""Save Harmony scene as `filepath`."""
scene_dir = os.path.dirname(filepath)
destination = os.path.join(
os.path.dirname(ProcessContext.workfile_path),
os.path.splitext(os.path.basename(filepath))[0] + ".zip"
)
if os.path.exists(scene_dir):
try:
shutil.rmtree(scene_dir)
except Exception as e:
log.error(f"Cannot remove {scene_dir}")
raise Exception(f"Cannot remove {scene_dir}") from e
send(
{"function": "scene.saveAs", "args": [scene_dir]}
)["result"]
zip_and_move(scene_dir, destination)
ProcessContext.workfile_path = destination
send(
{"function": "AvalonHarmony.addPathToWatcher", "args": filepath}
)
def find_node_by_name(name, node_type):
"""Find node by its name.
Args:
name (str): Name of the Node. (without part before '/')
node_type (str): Type of the Node.
'READ' - for loaded data with Loaders (background)
'GROUP' - for loaded data with Loaders (templates)
'WRITE' - render nodes
Returns:
str: FQ Node name.
"""
nodes = send(
{"function": "node.getNodes", "args": [[node_type]]}
)["result"]
for node in nodes:
node_name = node.split("/")[-1]
if name == node_name:
return node
return None

View file

@ -0,0 +1,351 @@
import os
from pathlib import Path
import logging
import pyblish.api
from avalon import io
import avalon.api
from avalon.pipeline import AVALON_CONTAINER_ID
from openpype import lib
import openpype.hosts.harmony
import openpype.hosts.harmony.api as harmony
log = logging.getLogger("openpype.hosts.harmony")
HOST_DIR = os.path.dirname(os.path.abspath(openpype.hosts.harmony.__file__))
PLUGINS_DIR = os.path.join(HOST_DIR, "plugins")
PUBLISH_PATH = os.path.join(PLUGINS_DIR, "publish")
LOAD_PATH = os.path.join(PLUGINS_DIR, "load")
CREATE_PATH = os.path.join(PLUGINS_DIR, "create")
INVENTORY_PATH = os.path.join(PLUGINS_DIR, "inventory")
def set_scene_settings(settings):
"""Set correct scene settings in Harmony.
Args:
settings (dict): Scene settings.
Returns:
dict: Dictionary of settings to set.
"""
harmony.send(
{"function": "PypeHarmony.setSceneSettings", "args": settings})
def get_asset_settings():
"""Get settings on current asset from database.
Returns:
dict: Scene data.
"""
asset_data = lib.get_asset()["data"]
fps = asset_data.get("fps")
frame_start = asset_data.get("frameStart")
frame_end = asset_data.get("frameEnd")
handle_start = asset_data.get("handleStart")
handle_end = asset_data.get("handleEnd")
resolution_width = asset_data.get("resolutionWidth")
resolution_height = asset_data.get("resolutionHeight")
entity_type = asset_data.get("entityType")
scene_data = {
"fps": fps,
"frameStart": frame_start,
"frameEnd": frame_end,
"handleStart": handle_start,
"handleEnd": handle_end,
"resolutionWidth": resolution_width,
"resolutionHeight": resolution_height,
"entityType": entity_type
}
return scene_data
def ensure_scene_settings():
"""Validate if Harmony scene has valid settings."""
settings = get_asset_settings()
invalid_settings = []
valid_settings = {}
for key, value in settings.items():
if value is None:
invalid_settings.append(key)
else:
valid_settings[key] = value
# Warn about missing attributes.
if invalid_settings:
msg = "Missing attributes:"
for item in invalid_settings:
msg += f"\n{item}"
harmony.send(
{"function": "PypeHarmony.message", "args": msg})
set_scene_settings(valid_settings)
def check_inventory():
"""Check is scene contains outdated containers.
If it does it will colorize outdated nodes and display warning message
in Harmony.
"""
if not lib.any_outdated():
return
host = avalon.api.registered_host()
outdated_containers = []
for container in host.ls():
representation = container['representation']
representation_doc = io.find_one(
{
"_id": io.ObjectId(representation),
"type": "representation"
},
projection={"parent": True}
)
if representation_doc and not lib.is_latest(representation_doc):
outdated_containers.append(container)
# Colour nodes.
outdated_nodes = []
for container in outdated_containers:
if container["loader"] == "ImageSequenceLoader":
outdated_nodes.append(
harmony.find_node_by_name(container["name"], "READ")
)
harmony.send({"function": "PypeHarmony.setColor", "args": outdated_nodes})
# Warn about outdated containers.
msg = "There are outdated containers in the scene."
harmony.send({"function": "PypeHarmony.message", "args": msg})
def application_launch():
"""Event that is executed after Harmony is launched."""
# FIXME: This is breaking server <-> client communication.
# It is now moved so it it manually called.
# ensure_scene_settings()
# check_inventory()
# fills OPENPYPE_HARMONY_JS
pype_harmony_path = Path(__file__).parent.parent / "js" / "PypeHarmony.js"
pype_harmony_js = pype_harmony_path.read_text()
# go through js/creators, loaders and publish folders and load all scripts
script = ""
for item in ["creators", "loaders", "publish"]:
dir_to_scan = Path(__file__).parent.parent / "js" / item
for child in dir_to_scan.iterdir():
script += child.read_text()
# send scripts to Harmony
harmony.send({"script": pype_harmony_js})
harmony.send({"script": script})
inject_avalon_js()
def export_template(backdrops, nodes, filepath):
"""Export Template to file.
Args:
backdrops (list): List of backdrops to export.
nodes (list): List of nodes to export.
filepath (str): Path where to save Template.
"""
harmony.send({
"function": "PypeHarmony.exportTemplate",
"args": [
backdrops,
nodes,
os.path.basename(filepath),
os.path.dirname(filepath)
]
})
def install():
"""Install Pype as host config."""
print("Installing Pype config ...")
pyblish.api.register_host("harmony")
pyblish.api.register_plugin_path(PUBLISH_PATH)
avalon.api.register_plugin_path(avalon.api.Loader, LOAD_PATH)
avalon.api.register_plugin_path(avalon.api.Creator, CREATE_PATH)
log.info(PUBLISH_PATH)
# Register callbacks.
pyblish.api.register_callback(
"instanceToggled", on_pyblish_instance_toggled
)
avalon.api.on("application.launched", application_launch)
def uninstall():
pyblish.api.deregister_plugin_path(PUBLISH_PATH)
avalon.api.deregister_plugin_path(avalon.api.Loader, LOAD_PATH)
avalon.api.deregister_plugin_path(avalon.api.Creator, CREATE_PATH)
def on_pyblish_instance_toggled(instance, old_value, new_value):
"""Toggle node enabling on instance toggles."""
node = None
if instance.data.get("setMembers"):
node = instance.data["setMembers"][0]
if node:
harmony.send(
{
"function": "PypeHarmony.toggleInstance",
"args": [node, new_value]
}
)
def inject_avalon_js():
"""Inject AvalonHarmony.js into Harmony."""
avalon_harmony_js = Path(__file__).parent.joinpath("js/AvalonHarmony.js")
script = avalon_harmony_js.read_text()
# send AvalonHarmony.js to Harmony
harmony.send({"script": script})
def ls():
"""Yields containers from Harmony scene.
This is the host-equivalent of api.ls(), but instead of listing
assets on disk, it lists assets already loaded in Harmony; once loaded
they are called 'containers'.
Yields:
dict: container
"""
objects = harmony.get_scene_data() or {}
for _, data in objects.items():
# Skip non-tagged objects.
if not data:
continue
# Filter to only containers.
if "container" not in data.get("id"):
continue
if not data.get("objectName"): # backward compatibility
data["objectName"] = data["name"]
yield data
def list_instances(remove_orphaned=True):
"""
List all created instances from current workfile which
will be published.
Pulls from File > File Info
For SubsetManager, by default it check if instance has matching node
in the scene, if not, instance gets deleted from metadata.
Returns:
(list) of dictionaries matching instances format
"""
objects = harmony.get_scene_data() or {}
instances = []
for key, data in objects.items():
# Skip non-tagged objects.
if not data:
continue
# Filter out containers.
if "container" in data.get("id"):
continue
data['uuid'] = key
if remove_orphaned:
node_name = key.split("/")[-1]
located_node = harmony.find_node_by_name(node_name, 'WRITE')
if not located_node:
print("Removing orphaned instance {}".format(key))
harmony.remove(key)
continue
instances.append(data)
return instances
def remove_instance(instance):
"""
Remove instance from current workfile metadata and from scene!
Updates metadata of current file in File > File Info and removes
icon highlight on group layer.
For SubsetManager
Args:
instance (dict): instance representation from subsetmanager model
"""
node = instance.get("uuid")
harmony.remove(node)
harmony.delete_node(node)
def select_instance(instance):
"""
Select instance in Node View
Args:
instance (dict): instance representation from subsetmanager model
"""
harmony.select_nodes([instance.get("uuid")])
def containerise(name,
namespace,
node,
context,
loader=None,
suffix=None,
nodes=None):
"""Imprint node with metadata.
Containerisation enables a tracking of version, author and origin
for loaded assets.
Arguments:
name (str): Name of resulting assembly.
namespace (str): Namespace under which to host container.
node (str): Node to containerise.
context (dict): Asset information.
loader (str, optional): Name of loader used to produce this container.
suffix (str, optional): Suffix of container, defaults to `_CON`.
Returns:
container (str): Path of container assembly.
"""
if not nodes:
nodes = []
data = {
"schema": "openpype:container-2.0",
"id": AVALON_CONTAINER_ID,
"name": name,
"namespace": namespace,
"loader": str(loader),
"representation": str(context["representation"]["_id"]),
"nodes": nodes
}
harmony.imprint(node, data)
return node

View file

@ -1,6 +1,70 @@
from avalon import harmony
import avalon.api
from openpype.api import PypeCreatorMixin
import openpype.hosts.harmony.api as harmony
class Creator(PypeCreatorMixin, harmony.Creator):
pass
class Creator(PypeCreatorMixin, avalon.api.Creator):
"""Creator plugin to create instances in Harmony.
By default a Composite node is created to support any number of nodes in
an instance, but any node type is supported.
If the selection is used, the selected nodes will be connected to the
created node.
"""
node_type = "COMPOSITE"
def setup_node(self, node):
"""Prepare node as container.
Args:
node (str): Path to node.
"""
harmony.send(
{
"function": "AvalonHarmony.setupNodeForCreator",
"args": node
}
)
def process(self):
"""Plugin entry point."""
existing_node_names = harmony.send(
{
"function": "AvalonHarmony.getNodesNamesByType",
"args": self.node_type
})["result"]
# Dont allow instances with the same name.
msg = "Instance with name \"{}\" already exists.".format(self.name)
for name in existing_node_names:
if self.name.lower() == name.lower():
harmony.send(
{
"function": "AvalonHarmony.message", "args": msg
}
)
return False
with harmony.maintained_selection() as selection:
node = None
if (self.options or {}).get("useSelection") and selection:
node = harmony.send(
{
"function": "AvalonHarmony.createContainer",
"args": [self.name, self.node_type, selection[-1]]
}
)["result"]
else:
node = harmony.send(
{
"function": "AvalonHarmony.createContainer",
"args": [self.name, self.node_type]
}
)["result"]
harmony.imprint(node, self.data)
self.setup_node(node)
return node

View file

@ -0,0 +1,267 @@
# -*- coding: utf-8 -*-
"""Server-side implementation of Toon Boon Harmony communication."""
import socket
import logging
import json
import traceback
import importlib
import functools
import time
import struct
from datetime import datetime
import threading
from . import lib
class Server(threading.Thread):
"""Class for communication with Toon Boon Harmony.
Attributes:
connection (Socket): connection holding object.
received (str): received data buffer.any(iterable)
port (int): port number.
message_id (int): index of last message going out.
queue (dict): dictionary holding queue of incoming messages.
"""
def __init__(self, port):
"""Constructor."""
super(Server, self).__init__()
self.daemon = True
self.connection = None
self.received = ""
self.port = port
self.message_id = 1
# Setup logging.
self.log = logging.getLogger(__name__)
self.log.setLevel(logging.DEBUG)
# Create a TCP/IP socket
self.socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
# Bind the socket to the port
server_address = ("127.0.0.1", port)
self.log.debug(
f"[{self.timestamp()}] Starting up on "
f"{server_address[0]}:{server_address[1]}")
self.socket.bind(server_address)
# Listen for incoming connections
self.socket.listen(1)
self.queue = {}
def process_request(self, request):
"""Process incoming request.
Args:
request (dict): {
"module": (str), # Module of method.
"method" (str), # Name of method in module.
"args" (list), # Arguments to pass to method.
"kwargs" (dict), # Keywork arguments to pass to method.
"reply" (bool), # Optional wait for method completion.
}
"""
pretty = self._pretty(request)
self.log.debug(
f"[{self.timestamp()}] Processing request:\n{pretty}")
try:
module = importlib.import_module(request["module"])
method = getattr(module, request["method"])
args = request.get("args", [])
kwargs = request.get("kwargs", {})
partial_method = functools.partial(method, *args, **kwargs)
lib.ProcessContext.execute_in_main_thread(partial_method)
except Exception:
self.log.error(traceback.format_exc())
def receive(self):
"""Receives data from `self.connection`.
When the data is a json serializable string, a reply is sent then
processing of the request.
"""
current_time = time.time()
while True:
# Receive the data in small chunks and retransmit it
request = None
header = self.connection.recv(6)
if len(header) == 0:
# null data received, socket is closing.
self.log.info(f"[{self.timestamp()}] Connection closing.")
break
if header[0:2] != b"AH":
self.log.error("INVALID HEADER")
length = struct.unpack(">I", header[2:])[0]
data = self.connection.recv(length)
while (len(data) < length):
# we didn't received everything in first try, lets wait for
# all data.
time.sleep(0.1)
if self.connection is None:
self.log.error(f"[{self.timestamp()}] "
"Connection is broken")
break
if time.time() > current_time + 30:
self.log.error(f"[{self.timestamp()}] Connection timeout.")
break
data += self.connection.recv(length - len(data))
self.received += data.decode("utf-8")
pretty = self._pretty(self.received)
self.log.debug(
f"[{self.timestamp()}] Received:\n{pretty}")
try:
request = json.loads(self.received)
except json.decoder.JSONDecodeError as e:
self.log.error(f"[{self.timestamp()}] "
f"Invalid message received.\n{e}",
exc_info=True)
self.received = ""
if request is None:
continue
if "message_id" in request.keys():
message_id = request["message_id"]
self.message_id = message_id + 1
self.log.debug(f"--- storing request as {message_id}")
self.queue[message_id] = request
if "reply" not in request.keys():
request["reply"] = True
self.send(request)
self.process_request(request)
if "message_id" in request.keys():
try:
self.log.debug(f"[{self.timestamp()}] "
f"Removing from the queue {message_id}")
del self.queue[message_id]
except IndexError:
self.log.debug(f"[{self.timestamp()}] "
f"{message_id} is no longer in queue")
else:
self.log.debug(f"[{self.timestamp()}] "
"received data was just a reply.")
def run(self):
"""Entry method for server.
Waits for a connection on `self.port` before going into listen mode.
"""
# Wait for a connection
timestamp = datetime.now().strftime("%H:%M:%S.%f")
self.log.debug(f"[{timestamp}] Waiting for a connection.")
self.connection, client_address = self.socket.accept()
timestamp = datetime.now().strftime("%H:%M:%S.%f")
self.log.debug(f"[{timestamp}] Connection from: {client_address}")
self.receive()
def stop(self):
"""Shutdown socket server gracefully."""
timestamp = datetime.now().strftime("%H:%M:%S.%f")
self.log.debug(f"[{timestamp}] Shutting down server.")
if self.connection is None:
self.log.debug("Connect to shutdown.")
socket.socket(
socket.AF_INET, socket.SOCK_STREAM
).connect(("localhost", self.port))
self.connection.close()
self.connection = None
self.socket.close()
def _send(self, message):
"""Send a message to Harmony.
Args:
message (str): Data to send to Harmony.
"""
# Wait for a connection.
while not self.connection:
pass
timestamp = datetime.now().strftime("%H:%M:%S.%f")
encoded = message.encode("utf-8")
coded_message = b"AH" + struct.pack('>I', len(encoded)) + encoded
pretty = self._pretty(coded_message)
self.log.debug(
f"[{timestamp}] Sending [{self.message_id}]:\n{pretty}")
self.log.debug(f"--- Message length: {len(encoded)}")
self.connection.sendall(coded_message)
self.message_id += 1
def send(self, request):
"""Send a request in dictionary to Harmony.
Waits for a reply from Harmony.
Args:
request (dict): Data to send to Harmony.
"""
request["message_id"] = self.message_id
self._send(json.dumps(request))
if request.get("reply"):
timestamp = datetime.now().strftime("%H:%M:%S.%f")
self.log.debug(
f"[{timestamp}] sent reply, not waiting for anything.")
return None
result = None
current_time = time.time()
try_index = 1
while True:
time.sleep(0.1)
if time.time() > current_time + 30:
timestamp = datetime.now().strftime("%H:%M:%S.%f")
self.log.error((f"[{timestamp}][{self.message_id}] "
"No reply from Harmony in 30s. "
f"Retrying {try_index}"))
try_index += 1
current_time = time.time()
if try_index > 30:
break
try:
result = self.queue[request["message_id"]]
timestamp = datetime.now().strftime("%H:%M:%S.%f")
self.log.debug((f"[{timestamp}] Got request "
f"id {self.message_id}, "
"removing from queue"))
del self.queue[request["message_id"]]
break
except KeyError:
# response not in received queue yey
pass
try:
result = json.loads(self.received)
break
except json.decoder.JSONDecodeError:
pass
self.received = ""
return result
def _pretty(self, message) -> str:
# result = pformat(message, indent=2)
# return result.replace("\\n", "\n")
return "{}{}".format(4 * " ", message)
def timestamp(self):
"""Return current timestamp as a string.
Returns:
str: current timestamp.
"""
return datetime.now().strftime("%H:%M:%S.%f")

Binary file not shown.

View file

@ -0,0 +1,78 @@
"""Host API required Work Files tool"""
import os
import shutil
from .lib import (
ProcessContext,
get_local_harmony_path,
zip_and_move,
launch_zip_file
)
from avalon import api
# used to lock saving until previous save is done.
save_disabled = False
def file_extensions():
return api.HOST_WORKFILE_EXTENSIONS["harmony"]
def has_unsaved_changes():
if ProcessContext.server:
return ProcessContext.server.send(
{"function": "scene.isDirty"})["result"]
return False
def save_file(filepath):
global save_disabled
if save_disabled:
return ProcessContext.server.send(
{
"function": "show_message",
"args": "Saving in progress, please wait until it finishes."
})["result"]
save_disabled = True
temp_path = get_local_harmony_path(filepath)
if ProcessContext.server:
if os.path.exists(temp_path):
try:
shutil.rmtree(temp_path)
except Exception as e:
raise Exception(f"cannot delete {temp_path}") from e
ProcessContext.server.send(
{"function": "scene.saveAs", "args": [temp_path]}
)["result"]
zip_and_move(temp_path, filepath)
ProcessContext.workfile_path = filepath
scene_path = os.path.join(
temp_path, os.path.basename(temp_path) + ".xstage"
)
ProcessContext.server.send(
{"function": "AvalonHarmony.addPathToWatcher", "args": scene_path}
)
else:
os.environ["HARMONY_NEW_WORKFILE_PATH"] = filepath.replace("\\", "/")
save_disabled = False
def open_file(filepath):
launch_zip_file(filepath)
def current_file():
"""Returning None to make Workfiles app look at first file extension."""
return None
def work_root(session):
return os.path.normpath(session["AVALON_WORKDIR"]).replace("\\", "/")

View file

@ -1,6 +1,6 @@
# -*- coding: utf-8 -*-
"""Create Composite node for render on farm."""
from avalon import harmony
import openpype.hosts.harmony.api as harmony
from openpype.hosts.harmony.api import plugin

View file

@ -1,6 +1,6 @@
# -*- coding: utf-8 -*-
"""Create render node."""
from avalon import harmony
import openpype.hosts.harmony.api as harmony
from openpype.hosts.harmony.api import plugin

View file

@ -1,4 +1,5 @@
from avalon import api, harmony
from avalon import api
import openpype.hosts.harmony.api as harmony
sig = harmony.signature()
func = """

View file

@ -1,7 +1,8 @@
import os
import json
from avalon import api, harmony
from avalon import api
import openpype.hosts.harmony.api as harmony
import openpype.lib

View file

@ -6,7 +6,8 @@ from pathlib import Path
import clique
from avalon import api, harmony
from avalon import api
import openpype.hosts.harmony.api as harmony
import openpype.lib

View file

@ -1,7 +1,8 @@
import os
import shutil
from avalon import api, harmony
from avalon import api
import openpype.hosts.harmony.api as harmony
class ImportPaletteLoader(api.Loader):
@ -41,7 +42,9 @@ class ImportPaletteLoader(api.Loader):
harmony.save_scene()
msg = "Updated {}.".format(subset_name)
msg += " You need to reload the scene to see the changes."
msg += " You need to reload the scene to see the changes.\n"
msg += "Please save workfile when ready and use Workfiles "
msg += "to reopen it."
harmony.send(
{

View file

@ -6,7 +6,8 @@ import os
import shutil
import uuid
from avalon import api, harmony
from avalon import api
import openpype.hosts.harmony.api as harmony
import openpype.lib

View file

@ -3,7 +3,8 @@ import zipfile
import os
import shutil
from avalon import api, harmony
from avalon import api
import openpype.hosts.harmony.api as harmony
class ImportTemplateLoader(api.Loader):

View file

@ -3,7 +3,7 @@
import os
import pyblish.api
from avalon import harmony
import openpype.hosts.harmony.api as harmony
class CollectCurrentFile(pyblish.api.ContextPlugin):

View file

@ -3,9 +3,10 @@
from pathlib import Path
import attr
from avalon import harmony, api
from avalon import api
import openpype.lib.abstract_collect_render
import openpype.hosts.harmony.api as harmony
from openpype.lib.abstract_collect_render import RenderInstance
import openpype.lib
@ -176,6 +177,7 @@ class CollectFarmRender(openpype.lib.abstract_collect_render.
ignoreFrameHandleCheck=True
)
render_instance.context = context
self.log.debug(render_instance)
instances.append(render_instance)

View file

@ -3,7 +3,7 @@
import json
import pyblish.api
from avalon import harmony
import openpype.hosts.harmony.api as harmony
class CollectInstances(pyblish.api.ContextPlugin):

View file

@ -5,7 +5,7 @@ import json
import re
import pyblish.api
from avalon import harmony
import openpype.hosts.harmony.api as harmony
class CollectPalettes(pyblish.api.ContextPlugin):

View file

@ -3,7 +3,7 @@
import os
import pyblish.api
from avalon import harmony
import openpype.hosts.harmony.api as harmony
class CollectScene(pyblish.api.ContextPlugin):

View file

@ -5,7 +5,7 @@ import csv
from PIL import Image, ImageDraw, ImageFont
from avalon import harmony
import openpype.hosts.harmony.api as harmony
import openpype.api

View file

@ -3,7 +3,7 @@ import tempfile
import subprocess
import pyblish.api
from avalon import harmony
import openpype.hosts.harmony.api as harmony
import openpype.lib
import clique

View file

@ -1,5 +1,5 @@
import pyblish.api
from avalon import harmony
import openpype.hosts.harmony.api as harmony
class ExtractSaveScene(pyblish.api.ContextPlugin):

View file

@ -4,7 +4,7 @@ import os
import shutil
import openpype.api
from avalon import harmony
import openpype.hosts.harmony.api as harmony
import openpype.hosts.harmony

View file

@ -3,7 +3,7 @@ import os
import pyblish.api
from openpype.action import get_errored_plugins_from_data
from openpype.lib import version_up
from avalon import harmony
import openpype.hosts.harmony.api as harmony
class IncrementWorkfile(pyblish.api.InstancePlugin):

View file

@ -2,7 +2,7 @@ import os
import pyblish.api
from avalon import harmony
import openpype.hosts.harmony.api as harmony
class ValidateAudio(pyblish.api.InstancePlugin):

View file

@ -2,7 +2,7 @@ import os
import pyblish.api
import openpype.api
from avalon import harmony
import openpype.hosts.harmony.api as harmony
class ValidateInstanceRepair(pyblish.api.Action):

View file

@ -6,7 +6,7 @@ import re
import pyblish.api
from avalon import harmony
import openpype.hosts.harmony.api as harmony
import openpype.hosts.harmony

View file

@ -0,0 +1,7 @@
{
"env": {
"browser": true
},
"extends": "eslint:recommended",
"ignorePatterns": ["**/*.js"]
}

View file

@ -0,0 +1,2 @@
node_modules/*

View file

@ -23,7 +23,7 @@ This library's aim is to create a more direct way to interact with Toonboom thro
column.setEntry (myColumnName, 0, 1, "1");
```
to simply writing :
to simply writing :
```javascript
// with openHarmony
@ -36,13 +36,17 @@ Less time spent coding, more time spent having ideas!
-----
## Do I need any knowledge of toonboom scripting to use openHarmony?
OpenHarmony aims to be self contained and to reimplement all the basic functions of the Harmony API. So, while it might help to have prior experience to understand what goes on under the hood, knowledge of the official API is not required.
OpenHarmony aims to be self contained and to reimplement all the basic functions of the Harmony API. So, while it might help to have prior experience to understand what goes on under the hood, knowledge of the official API is not required.
However, should you reach the limits of what openHarmony can offer at this time, you can always access the official API at any moment. Maybe you can submit a request and the missing parts will be added eventually, or you can even delve into the code and add the necessary functions yourself if you feel like it!
You can access a list of all the functions, how to use them, as well as examples, from the online documentation:
https://cfourney.github.io/OpenHarmony/$.html
[https://cfourney.github.io/OpenHarmony/$.html](https://cfourney.github.io/OpenHarmony/$.html)
To help you get started, here is a full example using the library to make and animate a small car, covering most of the basic features.
[https://github.com/cfourney/OpenHarmony/blob/master/examples/openHarmonyExample.js](https://github.com/cfourney/OpenHarmony/blob/master/examples/openHarmonyExample.js)
-----
## The OpenHarmony Document Object Model or DOM
@ -55,7 +59,7 @@ The openHarmony library doesn't merely provide *access* to the elements of a Too
<img src="https://raw.githubusercontent.com/cfourney/OpenHarmony/master/oH_DOM.jpg" alt="The Document ObjectModel" width="1600">
The *Document Object Model* is a way to organise the elements of the Toonboom scene by highlighting the way they interact with each other. The Scene object has a root group, which contains Nodes, which have Attributes which can be linked to Columns which contain Frames, etc. This way it's always easy to find and access the content you are looking for. The attribute system has also been streamlined and you can now set values of node properties with a simple attribution synthax.
The *Document Object Model* is a way to organise the elements of the Toonboom scene by highlighting the way they interact with each other. The Scene object has a root group, which contains Nodes, which have Attributes which can be linked to Columns which contain Frames, etc. This way it's always easy to find and access the content you are looking for. The attribute system has also been streamlined and you can now set values of node properties with a simple attribution synthax.
We implemented a global access to all elements and functions through the standard **dot notation** for the hierarchy, for ease of use, and clarity of code.
@ -68,7 +72,7 @@ On the other hand, the "o" naming scheme allows us to retain full access to the
This library is made available under the [Mozilla Public license 2.0](https://www.mozilla.org/en-US/MPL/2.0/).
OpenHarmony can be downloaded from [this repository](https://github.com/cfourney/OpenHarmony/releases/) directly. In order to make use of its functions, it needs to be unzipped next to the scripts you will be writing.
OpenHarmony can be downloaded from [this repository](https://github.com/cfourney/OpenHarmony/releases/) directly. In order to make use of its functions, it needs to be unzipped next to the scripts you will be writing.
All you have to do is call :
```javascript
@ -78,7 +82,7 @@ at the beggining of your script.
You can ask your users to download their copy of the library and store it alongside, or bundle it as you wish as long as you include the license file provided on this repository.
The entire library is documented at the address :
The entire library is documented at the address :
https://cfourney.github.io/OpenHarmony/$.html
@ -89,16 +93,58 @@ As time goes by, more functions will be added and the documentation will also ge
-----
## Installation
To install:
#### simple install:
- download the zip from [the releases page](https://github.com/cfourney/OpenHarmony/releases/),
- unzip the contents to [your scripts folder](https://docs.toonboom.com/help/harmony-17/advanced/scripting/import-script.html).
#### advanced install (for developers):
- clone the repository to the location of your choice
-- or --
- download the zip from [the releases page](https://github.com/cfourney/OpenHarmony/releases/)
- unzip the contents where you want to store the library,
-- then --
- run `install.bat`.
This last step will tell Harmony where to look to load the library, by setting the environment variable `LIB_OPENHARMONY_PATH` to the current folder.
This last step will tell Harmony where to look to load the library, by setting the environment variable `LIB_OPENHARMONY_PATH` to the current folder.
It will then create a `openHarmony.js` file into the user scripts folder which calls the files from the folder from the `LIB_OPENHARMONY_PATH` variable, so that scripts can make direct use of it without having to worry about where openHarmony is stored.
If you don't need a remote location for the library, you can also unzip the entire download into your user script folder.
##### Troubleshooting:
- to test if the library is correctly installed, open the `Script Editor` window and type:
```javascript
include ("openHarmony.js");
$.alert("hello world");
```
Run the script, and if there is an error (for ex `MAX_REENTRENCY `), check that the file `openHarmony.js` exists in the script folder, and contains only the line:
```javascript
include(System.getenv('LIB_OPENHARMONY_PATH')+'openHarmony.js');
```
Check that the environment variable `LIB_OPENHARMONY_PATH` is set correctly to the remote folder.
-----
## How to add openHarmony to vscode intellisense for autocompletion
Although not fully supported, you can get most of the autocompletion features to work by adding the following lines to a `jsconfig.json` file placed at the root of your working folder.
The paths need to be relative which means the openHarmony source code must be placed directly in your developping environnement.
For example, if your working folder contains the openHarmony source in a folder called `OpenHarmony` and your working scripts in a folder called `myScripts`, place the `jsconfig.json` file at the root of the folder and add these lines to the file:
```javascript
{
include : [
"OpenHarmony/*",
"OpenHarmony/openHarmony/*",
"myScripts/*",
"*"
]
}
```
[More information on vs code and jsconfig.json.](https://code.visualstudio.com/docs/nodejs/working-with-javascript)
-----
## Let's get technical. I can code, and want to contribute, where do I start?
@ -106,33 +152,33 @@ If you don't need a remote location for the library, you can also unzip the enti
Reading and understanding the existing code, or at least the structure of the lib, is a great start, but not a requirement. You can simply start adding your classes to the $ object that is the root of the harmony lib, and start implementing. However, try to follow these guidelines as they are the underlying principles that make the library consistent:
* There is a $ global object, which contains all the class declarations, and can be passed from one context to another to access the functions.
* Each class is an abstract representation of a core concept of Harmony, so naming and consistency (within the lib) is essential. But we are not bound by the structure or naming of Harmony if we find a better way, for example to make nomenclatures more consistent between the scripting interface and the UI.
* Each class defines a bunch of class properties with getter/setters for the values that are directly related to an entity of the scene. If you're thinking of making a getter function that doesn't require arguments, use a getter setter instead!
* Each class also defines methods which can be called on the class instances to affect its contents, or its children's contents. For example, you'd go to the scene class to add the things that live in the scene, such as elements, columns and palettes. You wouldn't go to the column class or palette class to add one, because then what are you adding it *to*?
* We use encapsulation over having to pass a function arguments every time we can. Instead of adding a node to the scene, and having to pass a group as argument, adding a node is done directly by calling a method of the parent group. This way the parent/child relationship is always clear and the arguments list kept to a minimum.
* The goal is to make the most useful set of functions we can. Instead of making a large function that does a lot, consider extracting the small useful subroutines you need in your function into the existing classes directly.
* Each method argument besides the core one (for example, for adding nodes, we have to specify the type of the new node we create) must have a default fallback to make the argument optional.
* Don't use globals ever, but maybe use a class property if you need an enum for example.
* Each method argument besides the core one (for example, for adding nodes, we have to specify the type of the new node we create) must have a default fallback to make the argument optional.
* Don't use globals ever, but maybe use a class property if you need an enum for example.
* Don't use the official API namespace, any function that exists in the official API must remain accessible otherwise things will break. Prefix your class names with "o" to avoid this and to signify this function is part of openHarmony.
* We use the official API as little as we can in the code, so that if the implementation changes, we can easily fix it in a minimal amount of places. Wrap it, then use the wrapper. (ex: oScene.name)
* Users of the lib should almost never have to use "new" to create instances of their classes. Create accessors/factories that will do that for them. For example, $.scn creates and return a oScene instance, and $.scn.nodes returns new oNodes instances, but users don't have to create them themselves, so it's like they were always there, contained within. It also lets you create different subclasses for one factory. For example, $.scn.$node("Top/myNode") will either return a oNode, oDrawingNode, oPegNode or oGroupNode object depending on the node type of the node represented by the object.
* Users of the lib should almost never have to use "new" to create instances of their classes. Create accessors/factories that will do that for them. For example, $.scn creates and return a oScene instance, and $.scn.nodes returns new oNodes instances, but users don't have to create them themselves, so it's like they were always there, contained within. It also lets you create different subclasses for one factory. For example, $.scn.$node("Top/myNode") will either return a oNode, oDrawingNode, oPegNode or oGroupNode object depending on the node type of the node represented by the object.
Exceptions are small useful value containing objects that don't belong to the Harmony hierarchy like oPoint, oBox, oColorValue, etc.
* It's a JS Library, so use camelCase naming and try to follow the google style guide for JS :
https://google.github.io/styleguide/jsguide.html
* Document your new functions using the JSDocs synthax : https://devdocs.io/jsdoc/howto-es2015-classes
* Make a branch, create a merge request when you're done, and we'll add the new stuff you added to the lib :)
@ -141,4 +187,16 @@ Reading and understanding the existing code, or at least the structure of the li
This library was created by Mathieu Chaptel and Chris Fourney.
If you're using openHarmony, and are noticing things that you would like to see in the library, please feel free to contribute to the code directly, or send us feedback through Github. This project will only be as good as people working together can make it, and we need every piece of code and feedback we can get, and would love to hear from you!
If you're using openHarmony, and are noticing things that you would like to see in the library, please feel free to contribute to the code directly, or send us feedback through Github. This project will only be as good as people working together can make it, and we need every piece of code and feedback we can get, and would love to hear from you!
-----
## Community
Join the discord community for help with the library and to contribute:
https://discord.gg/kgT38MG
-----
## Acknowledgements
* [Yu Ueda](https://github.com/yueda1984) for his help to understand Harmony coordinate systems
* [Dash](https://github.com/35743) for his help to debug, test and develop the Pie Menus widgets
* [All the contributors](https://github.com/cfourney/OpenHarmony/graphs/contributors) for their precious help.

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

Some files were not shown because too many files have changed in this diff Show more