OP-2414 - move Harmony to Openpype

This commit is contained in:
Petr Kalis 2022-01-21 12:53:28 +01:00
parent a40894fa32
commit f625e05d6e
16 changed files with 3723 additions and 201 deletions

View file

@ -0,0 +1,654 @@
# Harmony Integration
## Setup
The easiest way to setup for using Toon Boom Harmony is to use the built-in launch:
```
python -c "import avalon.harmony;avalon.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 `Avalon` menu entry where all Avalon 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
from avalon import 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
from avalon import 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. Avalon 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/AvalonHarmony.js` or utilize `{"script": "..."}` method.
#### Extending AvalonHarmony.js
Add your function to `AvalonHarmony.js`. For example:
```javascript
AvalonHarmony.myAwesomeFunction = function() {
someCoolStuff();
};
```
Then you can call that javascript code from your Python like:
```Python
from avalon import harmony
harmony.send({"function": "AvalonHarmony.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
from avalon import 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
from avalon import harmony
harmony.save_scene()
```
<details>
<summary>Click to expand for details on scene save.</summary>
Because Avalon 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
from avalon import 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
from avalon import 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
from avalon import 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
from avalon import 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, harmony, io
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.
*/
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': 'avalon.harmony.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': 'avalon.harmony.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': 'avalon.harmony.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': 'avalon.harmony.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': 'avalon.harmony.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': 'avalon.harmony.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': 'avalon.harmony.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": "pype.hosts.harmony.api",
"method": "ensure_scene_settings",
"args": []
},
false
);
}
function TB_sceneOpened()
{
start();
}

View file

@ -1,209 +1,93 @@
# -*- 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 avalon import io, harmony
import avalon.api
from .pipeline import (
ls,
install,
# Creator,
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 .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
)
log = logging.getLogger("openpype.hosts.harmony")
from .workio import (
open_file,
save_file,
current_file,
has_unsaved_changes,
file_extensions,
work_root
)
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")
__all__ = [
# pipeline
"ls",
"install",
#"Creator",
"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",
# 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"
def set_scene_settings(settings):
"""Set correct scene settings in Harmony.
# Workfiles API
"open_file",
"save_file",
"current_file",
"has_unsaved_changes",
"file_extensions",
"work_root",
]
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,512 @@
# -*- coding: utf-8 -*-
"""Utility functions used for Avalon - Harmony integration."""
import subprocess
import threading
import os
import random
import zipfile
import sys
import importlib
import shutil
import logging
import contextlib
import json
import signal
import time
from uuid import uuid4
from Qt import QtWidgets
from .server import Server
from openpype.tools.tray_app.app import ConsoleTrayApp
from openpype.tools.utils import host_tools
from openpype.hosts.harmony.api.toonboom import \
setup_startup_scripts, check_libs
self = sys.modules[__name__]
self.server = None
self.pid = None
self.application_path = None
self.callback_queue = None
self.workfile_path = None
self.port = None
# Setup logging.
self.log = logging.getLogger(__name__)
self.log.setLevel(logging.DEBUG)
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):
def is_host_connected():
# Harmony always connected, not waiting
return True
# coloring in ConsoleTrayApp
os.environ["OPENPYPE_LOG_NO_COLORS"] = "False"
app = QtWidgets.QApplication([])
app.setQuitOnLastWindowClosed(False)
ConsoleTrayApp('harmony', launch, subprocess_args, is_host_connected)
sys.exit(app.exec_())
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)
self.port = random.randrange(49152, 65535)
os.environ["AVALON_HARMONY_PORT"] = str(self.port)
self.application_path = application_path
# Launch Harmony.
setup_startup_scripts()
check_libs()
if os.environ.get("AVALON_HARMONY_WORKFILES_ON_LAUNCH", False):
host_tools.show_workfiles(save=False)
# No launch through Workfiles happened.
if not self.workfile_path:
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):
self.log.info(f"removing existing {temp_path}")
try:
shutil.rmtree(temp_path)
except Exception as e:
self.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:
self.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 self.pid:
os.kill(self.pid, signal.SIGTERM)
# Stop server.
if self.server:
self.server.stop()
# Launch Avalon server.
self.server = Server(self.port)
self.server.start()
# thread = threading.Thread(target=self.server.start)
# thread.daemon = True
# thread.start()
# Save workfile path for later.
self.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:
self.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")
self.server.stop()
return
print("Launching {}".format(scene_path))
ConsoleTrayApp.websocket_server = self.server
ConsoleTrayApp.process = subprocess.Popen(
[self.application_path, scene_path],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL
)
self.pid = ConsoleTrayApp.process.pid
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.
"""
self.log.debug("File changed: " + path)
if self.workfile_path is None:
return
if threaded:
thread = threading.Thread(
target=zip_and_move,
args=(os.path.dirname(path), self.workfile_path)
)
thread.start()
else:
zip_and_move(os.path.dirname(path), self.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)
self.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
ConsoleTrayApp.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 self.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.
self.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. """
self.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 = self.send(
{
"function": "AvalonHarmony.getSelectedNodes"
})["result"]
try:
yield selected_nodes
finally:
selected_nodes = self.send(
{
"function": "AvalonHarmony.selectNodes",
"args": selected_nodes
}
)
def send(request):
"""Public method for sending requests to Harmony."""
return self.server.send(request)
def select_nodes(nodes):
""" Selects nodes in Node View """
selected_nodes = self.send(
{
"function": "AvalonHarmony.selectNodes",
"args": nodes
}
)
@contextlib.contextmanager
def maintained_nodes_state(nodes):
"""Maintain nodes states during context."""
# Collect current state.
states = self.send(
{
"function": "AvalonHarmony.areEnabled", "args": nodes
})["result"]
# Disable all nodes.
self.send(
{
"function": "AvalonHarmony.disableNodes", "args": nodes
})
try:
yield
finally:
self.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 = self.send(
{"function": "AvalonHarmony.saveScene"})["result"]
# Manually update the remote file.
self.on_file_changed(scene_path, threaded=False)
# Re-enable the background watcher.
self.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(self.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:
self.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)
self.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,423 @@
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})
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]
}
)
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 install():
"""Install Harmony-specific functionality of avalon-core.
This function is called automatically on calling `api.install(harmony)`.
"""
print("Installing Avalon Harmony...")
pyblish.api.register_host("harmony")
avalon.api.on("application.launched", inject_avalon_js)
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 = lib.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 = lib.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")])
# class Creator(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
def containerise(name,
namespace,
node,
context,
loader=None,
suffix=None,
nodes=[]):
"""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.
"""
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

@ -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
from . import lib
import threading
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.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,32 @@
from .lib import (
launch,
on_file_changed,
open_file,
current_file,
has_unsaved_changes,
file_extensions,
work_root,
save_file,
send,
show,
save_scene,
setup_startup_scripts,
check_libs
)
__all__ = [
# Library API.
"launch",
"on_file_changed",
"open_file",
"current_file",
"has_unsaved_changes",
"file_extensions",
"work_root",
"save_file",
"send",
"show",
"save_scene",
"setup_startup_scripts",
"check_libs"
]

View file

@ -0,0 +1,262 @@
function Client()
{
var self = this;
self.socket = new QTcpSocket(this);
self.received = "";
self.log_debug = function(data)
{
message = typeof(data.message) != "undefined" ? data.message : data;
MessageLog.trace("(DEBUG): " + message.toString());
};
self.log_info = function(data)
{
message = typeof(data.message) != "undefined" ? data.message : data;
MessageLog.trace("(INFO): " + message.toString());
};
self.log_warning = function(data)
{
message = typeof(data.message) != "undefined" ? data.message : data;
MessageLog.trace("(WARNING): " + message.toString());
};
self.log_error = function(data)
{
message = typeof(data.message) != "undefined" ? data.message : data;
MessageLog.trace("(ERROR): " + message.toString());
};
self.process_request = function(request)
{
self.log_debug("Processing: " + JSON.stringify(request));
var result = null;
if (request["function"] != null)
{
try
{
var func = eval(request["function"]);
if (request.args == null)
{
result = func();
}else
{
result = func(request.args);
}
}
catch (error)
{
result = "Error processing request.\nRequest:\n" + JSON.stringify(request) + "\nError:\n" + error;
}
}
return result;
};
self.on_ready_read = function()
{
self.log_debug("Receiving data...");
data = self.socket.readAll();
if (data.size() != 0)
{
for ( var i = 0; i < data.size(); ++i)
{
self.received = self.received.concat(String.fromCharCode(data.at(i)));
}
}
self.log_debug("Received: " + self.received);
request = JSON.parse(self.received);
self.log_debug("Request: " + JSON.stringify(request));
request.result = self.process_request(request);
if (!request.reply)
{
request.reply = true;
self._send(JSON.stringify(request));
}
self.received = "";
};
self.on_connected = function()
{
self.log_debug("Connected to server.");
self.socket.readyRead.connect(self.on_ready_read);
};
self._send = function(message)
{
self.log_debug("Sending: " + message);
var data = new QByteArray();
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);
self.socket.write(codec.fromUnicode(message));
};
self.send = function(request, wait)
{
self._send(JSON.stringify(request));
while (wait)
{
try
{
JSON.parse(self.received);
break;
}
catch(err)
{
self.socket.waitForReadyRead(5000);
}
}
self.received = "";
};
self.on_disconnected = function()
{
self.socket.close();
};
self.disconnect = function()
{
self.socket.close();
};
self.socket.connected.connect(self.on_connected);
self.socket.disconnected.connect(self.on_disconnected);
}
function start()
{
var self = this;
var host = "127.0.0.1";
var port = parseInt(System.getenv("AVALON_TOONBOOM_PORT"));
// Attach the client to the QApplication to preserve.
var app = QCoreApplication.instance();
if (app.avalon_client == null)
{
app.avalon_client = new Client();
app.avalon_client.socket.connectToHost(host, port);
}
var menu_bar = QApplication.activeWindow().menuBar();
var menu = menu_bar.addMenu("Avalon");
self.on_creator = function()
{
app.avalon_client.send(
{
"module": "avalon.toonboom",
"method": "show",
"args": ["creator"]
},
false
);
};
var action = menu.addAction("Create...");
action.triggered.connect(self.on_creator);
self.on_workfiles = function()
{
app.avalon_client.send(
{
"module": "avalon.toonboom",
"method": "show",
"args": ["workfiles"]
},
false
);
};
action = menu.addAction("Workfiles");
action.triggered.connect(self.on_workfiles);
self.on_load = function()
{
app.avalon_client.send(
{
"module": "avalon.toonboom",
"method": "show",
"args": ["loader"]
},
false
);
};
action = menu.addAction("Load...");
action.triggered.connect(self.on_load);
self.on_publish = function()
{
app.avalon_client.send(
{
"module": "avalon.toonboom",
"method": "show",
"args": ["publish"]
},
false
);
};
action = menu.addAction("Publish...");
action.triggered.connect(self.on_publish);
self.on_manage = function()
{
app.avalon_client.send(
{
"module": "avalon.toonboom",
"method": "show",
"args": ["sceneinventory"]
},
false
);
};
action = menu.addAction("Manage...");
action.triggered.connect(self.on_manage);
// Watch scene file for changes.
app.on_file_changed = function(path)
{
var app = QCoreApplication.instance();
if (app.avalon_on_file_changed){
app.avalon_client.send(
{
"module": "avalon.toonboom",
"method": "on_file_changed",
"args": [path]
},
false
);
}
app.watcher.addPath(path);
};
app.watcher = new QFileSystemWatcher();
extension = ".xstage";
var product_name = about.productName();
if (product_name.toLowerCase().indexOf("storyboard") !== -1){
extension = ".sboard";
}
scene_path = scene.currentProjectPath() + "/" + scene.currentVersionName() + extension;
app.watcher.addPath(scene_path);
app.watcher.fileChanged.connect(app.on_file_changed);
app.avalon_on_file_changed = true;
}

View file

@ -0,0 +1,365 @@
import os
import random
import sys
import queue
import shutil
import zipfile
import signal
import threading
import subprocess
import importlib
import logging
import filecmp
from uuid import uuid4
from .server import Server
from openpype.tools.utils import host_tools
from Qt import QtWidgets
self = sys.modules[__name__]
self.server = None
self.pid = None
self.application_path = None
self.callback_queue = None
self.workfile_path = None
self.port = None
self.extension = None
self.application_name = None
# Setup logging.
self.log = logging.getLogger(__name__)
self.log.setLevel(logging.DEBUG)
signature = str(uuid4()).replace("-", "_")
def execute_in_main_thread(func_to_call_from_main_thread):
self.callback_queue.put(func_to_call_from_main_thread)
def main_thread_listen():
callback = self.callback_queue.get()
callback()
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__)), "harmony"
)
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:
self.log.error(e)
self.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:
self.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, zip_file):
"""Setup for Toon Boom application launch.
Launches Toon Boom application 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 application executable.
zip_file (str): Path to application scene file zipped.
application_name (str): Application identifier.
"""
self.port = random.randrange(5000, 6000)
os.environ["AVALON_TOONBOOM_PORT"] = str(self.port)
self.application_path = application_path
self.application_name = "harmony"
if "storyboard" in application_path.lower():
self.application_name = "storyboardpro"
extension_mapping = {"harmony": "xstage", "storyboardpro": "sboard"}
self.extension = extension_mapping[self.application_name]
# Launch Harmony.
setup_startup_scripts()
if os.environ.get("AVALON_TOONBOOM_WORKFILES_ON_LAUNCH", False):
host_tools.show_workfiles(save=False)
# No launch through Workfiles happened.
if not self.workfile_path:
launch_zip_file(zip_file)
self.callback_queue = queue.Queue()
while True:
main_thread_listen()
def get_local_path(filepath):
"""From the provided path get the equivalent local path."""
basename = os.path.splitext(os.path.basename(filepath))[0]
harmony_path = os.path.join(
os.path.expanduser("~"), ".avalon", self.application_name
)
return os.path.join(harmony_path, basename)
def launch_zip_file(filepath):
"""Launch a Harmony application instance with the provided zip file."""
self.log.debug("Localizing {}".format(filepath))
local_path = get_local_path(filepath)
scene_path = os.path.join(
local_path, os.path.basename(local_path) + "." + self.extension
)
extract_zip_file = False
if os.path.exists(scene_path):
# Check remote scene is newer than local.
if os.path.getmtime(scene_path) < os.path.getmtime(filepath):
shutil.rmtree(local_path)
extract_zip_file = True
else:
extract_zip_file = True
if extract_zip_file:
with zipfile.ZipFile(filepath, "r") as zip_ref:
zip_ref.extractall(local_path)
# Close existing scene.
if self.pid:
os.kill(self.pid, signal.SIGTERM)
# Stop server.
if self.server:
self.server.stop()
# Launch Avalon server.
self.server = Server(self.port)
thread = threading.Thread(target=self.server.start)
thread.daemon = True
thread.start()
# Save workfile path for later.
self.workfile_path = filepath
self.log.debug("Launching {}".format(scene_path))
process = subprocess.Popen([self.application_path, scene_path])
self.pid = process.pid
def file_extensions():
return [".zip"]
def has_unsaved_changes():
if self.server:
return self.server.send({"function": "scene.isDirty"})["result"]
return False
def save_file(filepath):
temp_path = self.get_local_path(filepath)
if os.path.exists(temp_path):
shutil.rmtree(temp_path)
self.server.send(
{"function": "scene.saveAs", "args": [temp_path]}
)["result"]
zip_and_move(temp_path, filepath)
self.workfile_path = filepath
func = """function add_path(path)
{
var app = QCoreApplication.instance();
app.watcher.addPath(path);
}
add_path
"""
scene_path = os.path.join(
temp_path, os.path.basename(temp_path) + "." + self.extension
)
self.server.send(
{"function": func, "args": [scene_path]}
)
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("\\", "/")
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)
shutil.move(os.path.basename(source) + ".zip", destination)
self.log.debug("Saved \"{}\" to \"{}\"".format(source, destination))
def on_file_changed(path):
"""Threaded zipping and move of the project directory.
This method is called when the scene file is changed.
"""
self.log.debug("File changed: " + path)
if self.workfile_path is None:
return
thread = threading.Thread(
target=zip_and_move, args=(os.path.dirname(path), self.workfile_path)
)
thread.start()
def send(request):
"""Public method for sending requests to Toon Boom application."""
return self.server.send(request)
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.
"""
# Need to have an existing QApplication.
app = QtWidgets.QApplication.instance()
if not app:
app = QtWidgets.QApplication(sys.argv)
# Get tool name from module name
# TODO this is for backwards compatibility not sure if `avalon.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]
if tool_name == "publish":
host_tools.show_tool_by_name(tool_name)
return
# Get and show tool.
# TODO convert toonboom implementation to run in Qt application as main
# thread
tool_window = host_tools.get_tool_by_name(tool_name)
tool_window.show()
# QApplication needs to always execute, except when publishing.
app.exec_()
def save_scene():
"""Saves the Toon Boom 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.
func = """function %s_func()
{
var app = QCoreApplication.instance();
app.avalon_on_file_changed = false;
scene.saveAll();
return (
scene.currentProjectPath() + "/" + scene.currentVersionName()
);
}
%s_func
""" % (signature, signature)
scene_path = self.send({"function": func})["result"] + "." + self.extension
# Manually update the remote file.
self.on_file_changed(scene_path)
# Re-enable the background watcher.
func = """function %s_func()
{
var app = QCoreApplication.instance();
app.avalon_on_file_changed = true;
}
%s_func
""" % (signature, signature)
self.send({"function": func})

View file

@ -0,0 +1,159 @@
import socket
import logging
import json
import traceback
import importlib
import functools
from . import lib
class Server(object):
def __init__(self, port):
self.connection = None
self.received = ""
self.port = port
# 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 = ("localhost", port)
self.log.debug("Starting up on {}".format(server_address))
self.socket.bind(server_address)
# Listen for incoming connections
self.socket.listen(1)
def process_request(self, 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.
}
"""
self.log.debug("Processing request: {}".format(request))
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.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.
"""
while True:
# Receive the data in small chunks and retransmit it
request = None
while True:
if self.connection is None:
break
data = self.connection.recv(4096)
if data:
self.received += data.decode("utf-8")
else:
break
self.log.debug("Received: {}".format(self.received))
try:
request = json.loads(self.received)
break
except json.decoder.JSONDecodeError:
pass
if request is None:
break
self.received = ""
self.log.debug("Request: {}".format(request))
if "reply" not in request.keys():
request["reply"] = True
self._send(json.dumps(request))
self.process_request(request)
def start(self):
"""Entry method for server.
Waits for a connection on `self.port` before going into listen mode.
"""
# Wait for a connection
self.log.debug("Waiting for a connection.")
self.connection, client_address = self.socket.accept()
self.log.debug("Connection from: {}".format(client_address))
self.receive()
def stop(self):
self.log.debug("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
self.log.debug("Sending: {}".format(message))
self.connection.sendall(message.encode("utf-8"))
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.
"""
self._send(json.dumps(request))
result = None
while True:
try:
result = json.loads(self.received)
break
except json.decoder.JSONDecodeError:
pass
self.received = ""
return result

View file

@ -0,0 +1,72 @@
"""Host API required Work Files tool"""
import os
import shutil
from . import lib
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 lib.server:
return lib.server.send({"function": "scene.isDirty"})["result"]
return False
def save_file(filepath):
global save_disabled
if save_disabled:
return lib.server.send(
{
"function": "show_message",
"args": "Saving in progress, please wait until it finishes."
})["result"]
save_disabled = True
temp_path = lib.get_local_harmony_path(filepath)
if lib.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
lib.server.send(
{"function": "scene.saveAs", "args": [temp_path]}
)["result"]
lib.zip_and_move(temp_path, filepath)
lib.workfile_path = filepath
scene_path = os.path.join(
temp_path, os.path.basename(temp_path) + ".xstage"
)
lib.server.send(
{"function": "AvalonHarmony.addPathToWatcher", "args": scene_path}
)
else:
os.environ["HARMONY_NEW_WORKFILE_PATH"] = filepath.replace("\\", "/")
save_disabled = False
def open_file(filepath):
lib.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

@ -85,7 +85,7 @@ def main(argv):
elif host_name == "aftereffects":
from openpype.hosts.aftereffects.api.lib import main
elif host_name == "harmony":
from avalon.harmony.lib import main
from openpype.hosts.harmony.api.lib import main
else:
title = "Unknown host name"
message = (