[Automated] Merged develop into main

This commit is contained in:
ynbot 2023-05-24 05:24:55 +02:00 committed by GitHub
commit f49eb5d9fb
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
66 changed files with 3695 additions and 1395 deletions

View file

@ -35,6 +35,7 @@ body:
label: Version
description: What version are you running? Look to OpenPype Tray
options:
- 3.15.8-nightly.2
- 3.15.8-nightly.1
- 3.15.7
- 3.15.7-nightly.3
@ -134,7 +135,6 @@ body:
- 3.14.2-nightly.2
- 3.14.2-nightly.1
- 3.14.1
- 3.14.1-nightly.4
validations:
required: true
- type: dropdown

View file

@ -4,9 +4,8 @@ Anything that isn't defined here is INTERNAL and unreliable for external use.
"""
from .launch_logic import (
from .ws_stub import (
get_stub,
stub,
)
from .pipeline import (
@ -18,7 +17,8 @@ from .pipeline import (
from .lib import (
maintained_selection,
get_extension_manifest_path,
get_asset_settings
get_asset_settings,
set_settings
)
from .plugin import (
@ -27,9 +27,8 @@ from .plugin import (
__all__ = [
# launch_logic
# ws_stub
"get_stub",
"stub",
# pipeline
"ls",
@ -39,6 +38,7 @@ __all__ = [
"maintained_selection",
"get_extension_manifest_path",
"get_asset_settings",
"set_settings",
# plugin
"AfterEffectsLoader"

View file

@ -1,6 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<ExtensionManifest Version="8.0" ExtensionBundleId="com.openpype.AE.panel" ExtensionBundleVersion="1.0.24"
ExtensionBundleName="openpype" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">
<ExtensionManifest Version="8.0" ExtensionBundleId="com.openpype.AE.panel" ExtensionBundleVersion="1.0.25"
ExtensionBundleName="com.openpype.AE.panel" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">
<ExtensionList>
<Extension Id="com.openpype.AE.panel" Version="1.0" />
</ExtensionList>

View file

@ -2,7 +2,7 @@
<html>
<head>
<meta charset="utf-8">
<link rel="stylesheet" href="css/topcoat-desktop-dark.min.css"/>
<link id="hostStyle" rel="stylesheet" href="css/styles.css"/>
@ -25,11 +25,11 @@
<title></title>
<script src="js/libs/jquery-2.0.2.min.js"></script>
<script type=text/javascript>
$(function() {
$("a#workfiles-button").bind("click", function() {
RPC.call('AfterEffects.workfiles_route').then(function (data) {
}, function (error) {
alert(error);
@ -37,7 +37,7 @@
});
});
</script>
<script type=text/javascript>
$(function() {
$("a#loader-button").bind("click", function() {
@ -48,7 +48,7 @@
});
});
</script>
<script type=text/javascript>
$(function() {
$("a#publish-button").bind("click", function() {
@ -59,7 +59,7 @@
});
});
</script>
<script type=text/javascript>
$(function() {
$("a#sceneinventory-button").bind("click", function() {
@ -70,7 +70,40 @@
});
});
</script>
<script type=text/javascript>
$(function() {
$("a#setresolution-button").bind("click", function() {
RPC.call('AfterEffects.setresolution_route').then(function (data) {
}, function (error) {
alert(error);
});
});
});
</script>
<script type=text/javascript>
$(function() {
$("a#setframes-button").bind("click", function() {
RPC.call('AfterEffects.setframes_route').then(function (data) {
}, function (error) {
alert(error);
});
});
});
</script>
<script type=text/javascript>
$(function() {
$("a#setall-button").bind("click", function() {
RPC.call('AfterEffects.setall_route').then(function (data) {
}, function (error) {
alert(error);
});
});
});
</script>
<script type=text/javascript>
$(function() {
$("a#experimental-button").bind("click", function() {
@ -80,25 +113,28 @@
});
});
});
</script>
</script>
</head>
<body class="hostElt">
<div id="content">
<div>
<div id="content">
<div>
<div></div><a href=# id=workfiles-button><button class="hostFontSize">Workfiles...</button></a></div>
<div><a href=# id=loader-button><button class="hostFontSize">Load...</button></a></div>
<div><a href=# id=publish-button><button class="hostFontSize">Publish...</button></a></div>
<div><a href=# id=sceneinventory-button><button class="hostFontSize">Manage...</button></a></div>
<div><a href=# id=setresolution-button><button class="hostFontSize">Set Resolution</button></a></div>
<div><a href=# id=setframes-button><button class="hostFontSize">Set Frame Range</button></a></div>
<div><a href=# id=setall-button><button class="hostFontSize">Apply All Settings</button></a></div>
<div><a href=# id=experimental-button><button class="hostFontSize">Experimental Tools...</button></a></div>
</div>
</div>
</div>
<!-- <script src="js/libs/PlayerDebugMode"></script> -->
<script src="js/libs/wsrpc.js"></script>
<script src="js/libs/loglevel.min.js"></script>
@ -107,6 +143,6 @@
<script src="js/themeManager.js"></script>
<script src="js/main.js"></script>
</body>
</html>
</html>

View file

@ -4,7 +4,7 @@ indent: 4, maxerr: 50 */
var csInterface = new CSInterface();
log.warn("script start");
WSRPC.DEBUG = false;
@ -14,7 +14,7 @@ WSRPC.TRACE = false;
async function startUp(url){
promis = runEvalScript("getEnv('" + url + "')");
var res = await promis;
var res = await promis;
log.warn("res: " + res);
promis = runEvalScript("getEnv('OPENPYPE_DEBUG')");
@ -56,7 +56,7 @@ function get_extension_version(){
}
function main(websocket_url){
// creates connection to 'websocket_url', registers routes
// creates connection to 'websocket_url', registers routes
var default_url = 'ws://localhost:8099/ws/';
if (websocket_url == ''){
@ -66,7 +66,7 @@ function main(websocket_url){
RPC.connect();
log.warn("connected");
log.warn("connected");
RPC.addRoute('AfterEffects.open', function (data) {
log.warn('Server called client route "open":', data);
@ -88,7 +88,7 @@ function main(websocket_url){
});
RPC.addRoute('AfterEffects.get_active_document_name', function (data) {
log.warn('Server called client route ' +
log.warn('Server called client route ' +
'"get_active_document_name":', data);
return runEvalScript("getActiveDocumentName()")
.then(function(result){
@ -98,7 +98,7 @@ function main(websocket_url){
});
RPC.addRoute('AfterEffects.get_active_document_full_name', function (data){
log.warn('Server called client route ' +
log.warn('Server called client route ' +
'"get_active_document_full_name":', data);
return runEvalScript("getActiveDocumentFullName()")
.then(function(result){
@ -118,7 +118,7 @@ function main(websocket_url){
});
});
RPC.addRoute('AfterEffects.get_selected_items', function (data) {
log.warn('Server called client route "get_selected_items":', data);
return runEvalScript("getSelectedItems(" + data.comps + "," +
@ -194,23 +194,25 @@ function main(websocket_url){
});
});
RPC.addRoute('AfterEffects.get_work_area', function (data) {
log.warn('Server called client route "get_work_area":', data);
return runEvalScript("getWorkArea(" + data.item_id + ")")
RPC.addRoute('AfterEffects.get_comp_properties', function (data) {
log.warn('Server called client route "get_comp_properties":', data);
return runEvalScript("getCompProperties(" + data.item_id + ")")
.then(function(result){
log.warn("getWorkArea: " + result);
log.warn("get_comp_properties: " + result);
return result;
});
});
RPC.addRoute('AfterEffects.set_work_area', function (data) {
RPC.addRoute('AfterEffects.set_comp_properties', function (data) {
log.warn('Server called client route "set_work_area":', data);
return runEvalScript("setWorkArea(" + data.item_id + ',' +
return runEvalScript("setCompProperties(" + data.item_id + ',' +
data.start + ',' +
data.duration + ',' +
data.frame_rate + ")")
data.frame_rate + ',' +
data.width + ',' +
data.height + ")")
.then(function(result){
log.warn("getWorkArea: " + result);
log.warn("set_comp_properties: " + result);
return result;
});
});
@ -255,7 +257,7 @@ function main(websocket_url){
RPC.addRoute('AfterEffects.import_background', function (data) {
log.warn('Server called client route "import_background":', data);
return runEvalScript("importBackground(" + data.comp_id + ", " +
return runEvalScript("importBackground(" + data.comp_id + ", " +
"'" + data.comp_name + "', " +
JSON.stringify(data.files) + ")")
.then(function(result){
@ -266,7 +268,7 @@ function main(websocket_url){
RPC.addRoute('AfterEffects.reload_background', function (data) {
log.warn('Server called client route "reload_background":', data);
return runEvalScript("reloadBackground(" + data.comp_id + ", " +
return runEvalScript("reloadBackground(" + data.comp_id + ", " +
"'" + data.comp_name + "', " +
JSON.stringify(data.files) + ")")
.then(function(result){
@ -314,6 +316,16 @@ function main(websocket_url){
log.warn('Server called client route "close":', data);
return runEvalScript("close()");
});
RPC.addRoute('AfterEffects.print_msg', function (data) {
log.warn('Server called client route "print_msg":', data);
var escaped_msg = EscapeStringForJSX(data.msg);
return runEvalScript("printMsg('" + escaped_msg +"')")
.then(function(result){
log.warn("print_msg: " + result);
return result;
});
});
}
/** main entry point **/
@ -323,17 +335,17 @@ startUp("WEBSOCKET_URL");
'use strict';
var csInterface = new CSInterface();
function init() {
themeManager.init();
$("#btn_test").click(function () {
csInterface.evalScript('sayHello()');
});
}
init();
}());

View file

@ -1,7 +1,7 @@
/*jslint vars: true, plusplus: true, devel: true, nomen: true, regexp: true,
indent: 4, maxerr: 50 */
/*global $, Folder*/
#include "../js/libs/json.js";
//@include "../js/libs/json.js"
/* All public API function should return JSON! */
@ -29,13 +29,13 @@ function getEnv(variable){
function getMetadata(){
/**
* Returns payload in 'Label' field of project's metadata
*
*
**/
if (ExternalObject.AdobeXMPScript === undefined){
ExternalObject.AdobeXMPScript =
new ExternalObject('lib:AdobeXMPScript');
}
var proj = app.project;
var meta = new XMPMeta(app.project.xmpPacket);
var schemaNS = XMPMeta.getNamespaceURI("xmp");
@ -53,7 +53,7 @@ function getMetadata(){
function imprint(payload){
/**
* Stores payload in 'Label' field of project's metadata
*
*
* Args:
* payload (string): json content
*/
@ -61,14 +61,14 @@ function imprint(payload){
ExternalObject.AdobeXMPScript =
new ExternalObject('lib:AdobeXMPScript');
}
var proj = app.project;
var meta = new XMPMeta(app.project.xmpPacket);
var schemaNS = XMPMeta.getNamespaceURI("xmp");
var label = "xmp:Label";
meta.setProperty(schemaNS, label, payload);
app.project.xmpPacket = meta.serialize();
}
@ -116,14 +116,14 @@ function getItems(comps, folders, footages){
/**
* Returns JSON representation of compositions and
* if 'collectLayers' then layers in comps too.
*
*
* Args:
* comps (bool): return selected compositions
* folders (bool): return folders
* footages (bool): return FootageItem
* Returns:
* (list) of JSON items
*/
*/
var items = []
for (i = 1; i <= app.project.items.length; ++i){
var item = app.project.items[i];
@ -142,14 +142,14 @@ function getItems(comps, folders, footages){
function getSelectedItems(comps, folders, footages){
/**
* Returns list of selected items from Project menu
*
*
* Args:
* comps (bool): return selected compositions
* folders (bool): return folders
* footages (bool): return FootageItem
* Returns:
* (list) of JSON items
*/
*/
var items = []
for (i = 0; i < app.project.selection.length; ++i){
var item = app.project.selection[i];
@ -166,9 +166,9 @@ function getSelectedItems(comps, folders, footages){
function _getItem(item, comps, folders, footages){
/**
* Auxiliary function as project items and selections
* Auxiliary function as project items and selections
* are indexed in different way :/
* Refactor
* Refactor
*/
var item_type = '';
if (item instanceof FolderItem){
@ -189,7 +189,7 @@ function _getItem(item, comps, folders, footages){
return "{}";
}
}
var item = {"name": item.name,
"id": item.id,
"type": item_type};
@ -200,7 +200,7 @@ function importFile(path, item_name, import_options){
/**
* Imports file (image tested for now) as a FootageItem.
* Creates new composition
*
*
* Args:
* path (string): absolute path to image file
* item_name (string): label for composition
@ -218,7 +218,7 @@ function importFile(path, item_name, import_options){
app.beginUndoGroup("Import File");
fp = new File(path);
if (fp.exists){
try {
try {
im_opt = new ImportOptions(fp);
importAsType = import_options["ImportAsType"];
@ -234,18 +234,18 @@ function importFile(path, item_name, import_options){
}
if (importAsType.indexOf('PROJECT') > 0){
im_opt.importAs = ImportAsType.PROJECT;
}
}
}
if ('sequence' in import_options){
im_opt.sequence = true;
}
comp = app.project.importFile(im_opt);
if (app.project.selection.length == 2 &&
app.project.selection[0] instanceof FolderItem){
comp.parentFolder = app.project.selection[0]
comp.parentFolder = app.project.selection[0]
}
} catch (error) {
return _prepareError(error.toString() + importOptions.file.fsName);
@ -283,14 +283,14 @@ function setLabelColor(comp_id, color_idx){
function replaceItem(comp_id, path, item_name){
/**
* Replaces loaded file with new file and updates name
*
*
* Args:
* comp_id (int): id of composition, not a index!
* path (string): absolute path to new file
* item_name (string): new composition name
*/
app.beginUndoGroup("Replace File");
fp = new File(path);
if (!fp.exists){
return _prepareError("File " + path + " not found.");
@ -303,7 +303,7 @@ function replaceItem(comp_id, path, item_name){
}else{
item.replace(fp);
}
item.name = item_name;
} catch (error) {
return _prepareError(error.toString() + path);
@ -319,7 +319,7 @@ function replaceItem(comp_id, path, item_name){
function renameItem(item_id, new_name){
/**
* Renames item with 'item_id' to 'new_name'
*
*
* Args:
* item_id (int): id to search item
* new_name (str)
@ -335,7 +335,7 @@ function renameItem(item_id, new_name){
function deleteItem(item_id){
/**
* Delete any 'item_id'
*
*
* Not restricted only to comp, it could delete
* any item with 'id'
*/
@ -347,38 +347,76 @@ function deleteItem(item_id){
}
}
function getWorkArea(comp_id){
function getCompProperties(comp_id){
/**
* Returns information about workarea - are that will be
* rendered. All calculation will be done in OpenPype,
* easier to modify without redeploy of extension.
*
* Returns information about composition - are that will be
* rendered.
*
* Returns
* (dict)
*/
var item = app.project.itemByID(comp_id);
if (item){
return JSON.stringify({
"workAreaStart": item.displayStartFrame,
"workAreaDuration": item.duration,
"frameRate": item.frameRate});
}else{
var comp = app.project.itemByID(comp_id);
if (!comp){
return _prepareError("There is no composition with "+ comp_id);
}
return JSON.stringify({
"id": comp.id,
"name": comp.name,
"frameStart": comp.displayStartFrame,
"framesDuration": comp.duration * comp.frameRate,
"frameRate": comp.frameRate,
"width": comp.width,
"height": comp.height});
}
function setWorkArea(comp_id, workAreaStart, workAreaDuration, frameRate){
function setCompProperties(comp_id, frameStart, framesCount, frameRate,
width, height){
/**
* Sets work area info from outside (from Ftrack via OpenPype)
*/
var item = app.project.itemByID(comp_id);
if (item){
item.displayStartTime = workAreaStart;
item.duration = workAreaDuration;
item.frameRate = frameRate;
}else{
var comp = app.project.itemByID(comp_id);
if (!comp){
return _prepareError("There is no composition with "+ comp_id);
}
app.beginUndoGroup('change comp properties');
if (frameStart && framesCount && frameRate){
comp.displayStartFrame = frameStart;
comp.duration = framesCount / frameRate;
comp.frameRate = frameRate;
}
if (width && height){
var widthOld = comp.width;
var widthNew = width;
var widthDelta = widthNew - widthOld;
var heightOld = comp.height;
var heightNew = height;
var heightDelta = heightNew - heightOld;
var offset = [widthDelta / 2, heightDelta / 2];
comp.width = widthNew;
comp.height = heightNew;
for (var i = 1, il = comp.numLayers; i <= il; i++) {
var layer = comp.layer(i);
var positionProperty = layer.property('ADBE Transform Group').property('ADBE Position');
if (positionProperty.numKeys > 0) {
for (var j = 1, jl = positionProperty.numKeys; j <= jl; j++) {
var keyValue = positionProperty.keyValue(j);
positionProperty.setValueAtKey(j, keyValue + offset);
}
} else {
var positionValue = positionProperty.value;
positionProperty.setValue(positionValue + offset);
}
}
}
app.endUndoGroup();
}
function save(){
@ -504,7 +542,7 @@ function addItemAsLayerToComp(comp_id, item_id, found_comp){
* Args:
* comp_id (int): id of target composition
* item_id (int): FootageItem.id
* found_comp (CompItem, optional): to limit querying if
* found_comp (CompItem, optional): to limit quering if
* comp already found previously
*/
var comp = found_comp || app.project.itemByID(comp_id);
@ -749,7 +787,7 @@ function render(target_folder, comp_id){
var om1 = app.project.renderQueue.item(i).outputModule(1);
var file_name = File.decode( om1.file.name ).replace('℗', ''); // Name contains special character, space?
var omItem1_settable_str = app.project.renderQueue.item(i).outputModule(1).getSettings( GetSettingsFormat.STRING_SETTABLE );
var targetFolder = new Folder(target_folder);
@ -763,7 +801,7 @@ function render(target_folder, comp_id){
render_item.render = false;
}
}
}
app.beginSuppressDialogs();
app.project.renderQueue.render();
@ -779,6 +817,10 @@ function getAppVersion(){
return _prepareSingleValue(app.version);
}
function printMsg(msg){
alert(msg);
}
function _prepareSingleValue(value){
return JSON.stringify({"result": value})
}

View file

@ -1,49 +1,77 @@
import os
import sys
import subprocess
import collections
import logging
import asyncio
import functools
import traceback
from wsrpc_aiohttp import (
WebSocketRoute,
WebSocketAsync
)
from qtpy import QtCore
from qtpy import QtCore, QtWidgets
from openpype.lib import Logger
from openpype.pipeline import legacy_io
from openpype.tools.utils import host_tools
from openpype.tests.lib import is_in_tests
from openpype.pipeline import install_host, legacy_io
from openpype.modules import ModulesManager
from openpype.tools.adobe_webserver.app import WebServerTool
from .ws_stub import AfterEffectsServerStub
from .ws_stub import get_stub
from .lib import set_settings
log = logging.getLogger(__name__)
log.setLevel(logging.DEBUG)
class ConnectionNotEstablishedYet(Exception):
pass
def safe_excepthook(*args):
traceback.print_exception(*args)
def get_stub():
"""
Convenience function to get server RPC stub to call methods directed
for host (Photoshop).
It expects already created connection, started from client.
Currently created when panel is opened (PS: Window>Extensions>Avalon)
:return: <PhotoshopClientStub> where functions could be called from
"""
ae_stub = AfterEffectsServerStub()
if not ae_stub.client:
raise ConnectionNotEstablishedYet("Connection is not created yet")
def main(*subprocess_args):
"""Main entrypoint to AE launching, called from pre hook."""
sys.excepthook = safe_excepthook
return ae_stub
from openpype.hosts.aftereffects.api import AfterEffectsHost
host = AfterEffectsHost()
install_host(host)
def stub():
return get_stub()
os.environ["OPENPYPE_LOG_NO_COLORS"] = "False"
app = QtWidgets.QApplication([])
app.setQuitOnLastWindowClosed(False)
launcher = ProcessLauncher(subprocess_args)
launcher.start()
if os.environ.get("HEADLESS_PUBLISH"):
manager = ModulesManager()
webpublisher_addon = manager["webpublisher"]
launcher.execute_in_main_thread(
functools.partial(
webpublisher_addon.headless_publish,
log,
"CloseAE",
is_in_tests()
)
)
elif os.environ.get("AVALON_PHOTOSHOP_WORKFILES_ON_LAUNCH", True):
save = False
if os.getenv("WORKFILES_SAVE_AS"):
save = True
launcher.execute_in_main_thread(
lambda: host_tools.show_tool_by_name("workfiles", save=save)
)
sys.exit(app.exec_())
def show_tool_by_name(tool_name):
@ -55,6 +83,7 @@ def show_tool_by_name(tool_name):
class ProcessLauncher(QtCore.QObject):
"""Launches webserver, connects to it, runs main thread."""
route_name = "AfterEffects"
_main_thread_callbacks = collections.deque()
@ -296,6 +325,15 @@ class AfterEffectsRoute(WebSocketRoute):
async def sceneinventory_route(self):
self._tool_route("sceneinventory")
async def setresolution_route(self):
self._settings_route(False, True)
async def setframes_route(self):
self._settings_route(True, False)
async def setall_route(self):
self._settings_route(True, True)
async def experimental_tools_route(self):
self._tool_route("experimental_tools")
@ -309,3 +347,13 @@ class AfterEffectsRoute(WebSocketRoute):
# Required return statement.
return "nothing"
def _settings_route(self, frames, resolution):
partial_method = functools.partial(set_settings,
frames,
resolution)
ProcessLauncher.execute_in_main_thread(partial_method)
# Required return statement.
return "nothing"

View file

@ -1,69 +1,17 @@
import os
import sys
import re
import json
import contextlib
import traceback
import logging
from functools import partial
from qtpy import QtWidgets
from openpype.pipeline import install_host
from openpype.modules import ModulesManager
from openpype.tools.utils import host_tools
from openpype.tests.lib import is_in_tests
from .launch_logic import ProcessLauncher, get_stub
from openpype.pipeline.context_tools import get_current_context
from openpype.client import get_asset_by_name
from .ws_stub import get_stub
log = logging.getLogger(__name__)
log.setLevel(logging.DEBUG)
def safe_excepthook(*args):
traceback.print_exception(*args)
def main(*subprocess_args):
sys.excepthook = safe_excepthook
from openpype.hosts.aftereffects.api import AfterEffectsHost
host = AfterEffectsHost()
install_host(host)
os.environ["OPENPYPE_LOG_NO_COLORS"] = "False"
app = QtWidgets.QApplication([])
app.setQuitOnLastWindowClosed(False)
launcher = ProcessLauncher(subprocess_args)
launcher.start()
if os.environ.get("HEADLESS_PUBLISH"):
manager = ModulesManager()
webpublisher_addon = manager["webpublisher"]
launcher.execute_in_main_thread(
partial(
webpublisher_addon.headless_publish,
log,
"CloseAE",
is_in_tests()
)
)
elif os.environ.get("AVALON_PHOTOSHOP_WORKFILES_ON_LAUNCH", True):
save = False
if os.getenv("WORKFILES_SAVE_AS"):
save = True
launcher.execute_in_main_thread(
lambda: host_tools.show_tool_by_name("workfiles", save=save)
)
sys.exit(app.exec_())
@contextlib.contextmanager
def maintained_selection():
"""Maintain selection during context."""
@ -145,13 +93,13 @@ def get_asset_settings(asset_doc):
"""
asset_data = asset_doc["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")
fps = asset_data.get("fps", 0)
frame_start = asset_data.get("frameStart", 0)
frame_end = asset_data.get("frameEnd", 0)
handle_start = asset_data.get("handleStart", 0)
handle_end = asset_data.get("handleEnd", 0)
resolution_width = asset_data.get("resolutionWidth", 0)
resolution_height = asset_data.get("resolutionHeight", 0)
duration = (frame_end - frame_start + 1) + handle_start + handle_end
return {
@ -164,3 +112,49 @@ def get_asset_settings(asset_doc):
"resolutionHeight": resolution_height,
"duration": duration
}
def set_settings(frames, resolution, comp_ids=None, print_msg=True):
"""Sets number of frames and resolution to selected comps.
Args:
frames (bool): True if set frame info
resolution (bool): True if set resolution
comp_ids (list): specific composition ids, if empty
it tries to look for currently selected
print_msg (bool): True throw JS alert with msg
"""
frame_start = frames_duration = fps = width = height = None
current_context = get_current_context()
asset_doc = get_asset_by_name(current_context["project_name"],
current_context["asset_name"])
settings = get_asset_settings(asset_doc)
msg = ''
if frames:
frame_start = settings["frameStart"] - settings["handleStart"]
frames_duration = settings["duration"]
fps = settings["fps"]
msg += f"frame start:{frame_start}, duration:{frames_duration}, "\
f"fps:{fps}"
if resolution:
width = settings["resolutionWidth"]
height = settings["resolutionHeight"]
msg += f"width:{width} and height:{height}"
stub = get_stub()
if not comp_ids:
comps = stub.get_selected_items(True, False, False)
comp_ids = [comp.id for comp in comps]
if not comp_ids:
stub.print_msg("Select at least one composition to apply settings.")
return
for comp_id in comp_ids:
msg = f"Setting for comp {comp_id} " + msg
log.debug(msg)
stub.set_comp_properties(comp_id, frame_start, frames_duration,
fps, width, height)
if print_msg:
stub.print_msg(msg)

View file

@ -8,10 +8,7 @@ from openpype.lib import Logger, register_event_callback
from openpype.pipeline import (
register_loader_plugin_path,
register_creator_plugin_path,
deregister_loader_plugin_path,
deregister_creator_plugin_path,
AVALON_CONTAINER_ID,
legacy_io,
)
from openpype.pipeline.load import any_outdated_containers
import openpype.hosts.aftereffects
@ -23,7 +20,8 @@ from openpype.host import (
IPublishHost
)
from .launch_logic import get_stub, ConnectionNotEstablishedYet
from .launch_logic import get_stub
from .ws_stub import ConnectionNotEstablishedYet
log = Logger.get_logger(__name__)

View file

@ -11,6 +11,10 @@ from wsrpc_aiohttp import WebSocketAsync
from openpype.tools.adobe_webserver.app import WebServerTool
class ConnectionNotEstablishedYet(Exception):
pass
@attr.s
class AEItem(object):
"""
@ -24,8 +28,8 @@ class AEItem(object):
# all imported elements, single for
# regular image, array for Backgrounds
members = attr.ib(factory=list)
workAreaStart = attr.ib(default=None)
workAreaDuration = attr.ib(default=None)
frameStart = attr.ib(default=None)
framesDuration = attr.ib(default=None)
frameRate = attr.ib(default=None)
file_name = attr.ib(default=None)
instance_id = attr.ib(default=None) # New Publisher
@ -355,42 +359,50 @@ class AfterEffectsServerStub():
return self._handle_return(res)
def get_work_area(self, item_id):
""" Get work are information for render purposes
def get_comp_properties(self, comp_id):
""" Get composition information for render purposes
Returns startFrame, frameDuration, fps, width, height.
Args:
item_id (int):
comp_id (int):
Returns:
(AEItem)
"""
res = self.websocketserver.call(self.client.call
('AfterEffects.get_work_area',
item_id=item_id
('AfterEffects.get_comp_properties',
item_id=comp_id
))
records = self._to_records(self._handle_return(res))
if records:
return records.pop()
def set_work_area(self, item, start, duration, frame_rate):
def set_comp_properties(self, comp_id, start, duration, frame_rate,
width, height):
"""
Set work area to predefined values (from Ftrack).
Work area directs what gets rendered.
Beware of rounding, AE expects seconds, not frames directly.
Args:
item (dict):
start (float): workAreaStart in seconds
duration (float): in seconds
comp_id (int):
start (int): workAreaStart in frames
duration (int): in frames
frame_rate (float): frames in seconds
width (int): resolution width
height (int): resolution height
"""
res = self.websocketserver.call(self.client.call
('AfterEffects.set_work_area',
item_id=item.id,
('AfterEffects.set_comp_properties',
item_id=comp_id,
start=start,
duration=duration,
frame_rate=frame_rate))
frame_rate=frame_rate,
width=width,
height=height))
return self._handle_return(res)
def save(self):
@ -554,6 +566,12 @@ class AfterEffectsServerStub():
return self._handle_return(res)
def print_msg(self, msg):
"""Triggers Javascript alert dialog."""
self.websocketserver.call(self.client.call
('AfterEffects.print_msg',
msg=msg))
def _handle_return(self, res):
"""Wraps return, throws ValueError if 'error' key is present."""
if res and isinstance(res, str) and res != "undefined":
@ -608,8 +626,8 @@ class AfterEffectsServerStub():
d.get('name'),
d.get('type'),
d.get('members'),
d.get('workAreaStart'),
d.get('workAreaDuration'),
d.get('frameStart'),
d.get('framesDuration'),
d.get('frameRate'),
d.get('file_name'),
d.get("instance_id"),
@ -618,3 +636,18 @@ class AfterEffectsServerStub():
ret.append(item)
return ret
def get_stub():
"""
Convenience function to get server RPC stub to call methods directed
for host (Photoshop).
It expects already created connection, started from client.
Currently created when panel is opened (PS: Window>Extensions>Avalon)
:return: <PhotoshopClientStub> where functions could be called from
"""
ae_stub = AfterEffectsServerStub()
if not ae_stub.client:
raise ConnectionNotEstablishedYet("Connection is not created yet")
return ae_stub

View file

@ -9,6 +9,7 @@ from openpype.pipeline import (
CreatorError
)
from openpype.hosts.aftereffects.api.pipeline import cache_and_get_instances
from openpype.hosts.aftereffects.api.lib import set_settings
from openpype.lib import prepare_template_data
from openpype.pipeline.create import SUBSET_NAME_ALLOWED_SYMBOLS
@ -32,6 +33,14 @@ class RenderCreator(Creator):
def create(self, subset_name_from_ui, data, pre_create_data):
stub = api.get_stub() # only after After Effects is up
try:
_ = stub.get_active_document_full_name()
except ValueError:
raise CreatorError(
"Please save workfile via Workfile app first!"
)
if pre_create_data.get("use_selection"):
comps = stub.get_selected_items(
comps=True, folders=False, footages=False
@ -41,8 +50,8 @@ class RenderCreator(Creator):
if not comps:
raise CreatorError(
"Nothing to create. Select composition "
"if 'useSelection' or create at least "
"Nothing to create. Select composition in Project Bin if "
"'Use selection' is toggled or create at least "
"one composition."
)
use_composition_name = (pre_create_data.get("use_composition_name") or
@ -87,10 +96,14 @@ class RenderCreator(Creator):
self._add_instance_to_context(new_instance)
stub.rename_item(comp.id, subset_name)
set_settings(True, True, [comp.id], print_msg=False)
def get_pre_create_attr_defs(self):
output = [
BoolDef("use_selection", default=True, label="Use selection"),
BoolDef("use_selection",
tooltip="Composition for publishable instance should be "
"selected by default.",
default=True, label="Use selection"),
BoolDef("use_composition_name",
label="Use composition name in subset"),
UISeparatorDef(),

View file

@ -66,19 +66,19 @@ class CollectAERender(publish.AbstractCollectRender):
comp_id = int(inst.data["members"][0])
work_area_info = CollectAERender.get_stub().get_work_area(comp_id)
comp_info = CollectAERender.get_stub().get_comp_properties(
comp_id)
if not work_area_info:
if not comp_info:
self.log.warning("Orphaned instance, deleting metadata")
inst_id = inst.get("instance_id") or str(comp_id)
inst_id = inst.data.get("instance_id") or str(comp_id)
CollectAERender.get_stub().remove_instance(inst_id)
continue
frame_start = work_area_info.workAreaStart
frame_end = round(work_area_info.workAreaStart +
float(work_area_info.workAreaDuration) *
float(work_area_info.frameRate)) - 1
fps = work_area_info.frameRate
frame_start = comp_info.frameStart
frame_end = round(comp_info.frameStart +
comp_info.framesDuration) - 1
fps = comp_info.frameRate
# TODO add resolution when supported by extension
task_name = inst.data.get("task") # legacy

View file

@ -13,6 +13,7 @@ from .lib import (
update_frame_range,
set_asset_framerange,
get_current_comp,
get_bmd_library,
comp_lock_and_undo_chunk
)

View file

@ -309,6 +309,12 @@ def get_fusion_module():
return fusion
def get_bmd_library():
"""Get bmd library"""
bmd = getattr(sys.modules["__main__"], "bmd", None)
return bmd
def get_current_comp():
"""Get current comp in this session"""
fusion = get_fusion_module()

View file

@ -40,6 +40,11 @@ class CreateSaver(NewCreator):
"{workdir}/renders/fusion/{subset}/{subset}.{frame}.{ext}")
def create(self, subset_name, instance_data, pre_create_data):
self.pass_pre_attributes_to_instance(
instance_data,
pre_create_data
)
instance_data.update({
"id": "pyblish.avalon.instance",
"subset": subset_name
@ -194,20 +199,25 @@ class CreateSaver(NewCreator):
attr_defs = [
self._get_render_target_enum(),
self._get_reviewable_bool(),
self._get_frame_range_enum()
]
return attr_defs
def get_instance_attr_defs(self):
"""Settings for publish page"""
attr_defs = [
self._get_render_target_enum(),
self._get_reviewable_bool(),
]
return attr_defs
return self.get_pre_create_attr_defs()
def pass_pre_attributes_to_instance(
self,
instance_data,
pre_create_data
):
creator_attrs = instance_data["creator_attributes"] = {}
for pass_key in pre_create_data.keys():
creator_attrs[pass_key] = pre_create_data[pass_key]
# These functions below should be moved to another file
# so it can be used by other plugins. plugin.py ?
def _get_render_target_enum(self):
rendering_targets = {
"local": "Local machine rendering",
@ -220,6 +230,19 @@ class CreateSaver(NewCreator):
"render_target", items=rendering_targets, label="Render target"
)
def _get_frame_range_enum(self):
frame_range_options = {
"asset_db": "Current asset context",
"render_range": "From viewer render in/out",
"comp_range": "From composition timeline"
}
return EnumDef(
"frame_range_source",
items=frame_range_options,
label="Frame range source"
)
def _get_reviewable_bool(self):
return BoolDef(
"review",

View file

@ -1,4 +1,3 @@
from openpype.pipeline import (
load,
get_representation_path,
@ -6,7 +5,7 @@ from openpype.pipeline import (
from openpype.hosts.fusion.api import (
imprint_container,
get_current_comp,
comp_lock_and_undo_chunk
comp_lock_and_undo_chunk,
)
@ -15,7 +14,21 @@ class FusionLoadFBXMesh(load.LoaderPlugin):
families = ["*"]
representations = ["*"]
extensions = {"fbx"}
extensions = {
"3ds",
"amc",
"aoa",
"asf",
"bvh",
"c3d",
"dae",
"dxf",
"fbx",
"htr",
"mcd",
"obj",
"trc",
}
label = "Load FBX mesh"
order = -10
@ -27,23 +40,24 @@ class FusionLoadFBXMesh(load.LoaderPlugin):
def load(self, context, name, namespace, data):
# Fallback to asset name when namespace is None
if namespace is None:
namespace = context['asset']['name']
namespace = context["asset"]["name"]
# Create the Loader with the filename path set
comp = get_current_comp()
with comp_lock_and_undo_chunk(comp, "Create tool"):
path = self.fname
args = (-32768, -32768)
tool = comp.AddTool(self.tool_type, *args)
tool["ImportFile"] = path
imprint_container(tool,
name=name,
namespace=namespace,
context=context,
loader=self.__class__.__name__)
imprint_container(
tool,
name=name,
namespace=namespace,
context=context,
loader=self.__class__.__name__,
)
def switch(self, container, representation):
self.update(container, representation)

View file

@ -3,17 +3,14 @@ import contextlib
import openpype.pipeline.load as load
from openpype.pipeline.load import (
get_representation_context,
get_representation_path_from_context
get_representation_path_from_context,
)
from openpype.hosts.fusion.api import (
imprint_container,
get_current_comp,
comp_lock_and_undo_chunk
)
from openpype.lib.transcoding import (
IMAGE_EXTENSIONS,
VIDEO_EXTENSIONS
comp_lock_and_undo_chunk,
)
from openpype.lib.transcoding import IMAGE_EXTENSIONS, VIDEO_EXTENSIONS
comp = get_current_comp()
@ -57,20 +54,23 @@ def preserve_trim(loader, log=None):
try:
yield
finally:
length = loader.GetAttrs()["TOOLIT_Clip_Length"][1] - 1
if trim_from_start > length:
trim_from_start = length
if log:
log.warning("Reducing trim in to %d "
"(because of less frames)" % trim_from_start)
log.warning(
"Reducing trim in to %d "
"(because of less frames)" % trim_from_start
)
remainder = length - trim_from_start
if trim_from_end > remainder:
trim_from_end = remainder
if log:
log.warning("Reducing trim in to %d "
"(because of less frames)" % trim_from_end)
log.warning(
"Reducing trim in to %d "
"(because of less frames)" % trim_from_end
)
loader["ClipTimeStart"][time] = trim_from_start
loader["ClipTimeEnd"][time] = length - trim_from_end
@ -109,11 +109,15 @@ def loader_shift(loader, frame, relative=True):
# Shifting global in will try to automatically compensate for the change
# in the "ClipTimeStart" and "HoldFirstFrame" inputs, so we preserve those
# input values to "just shift" the clip
with preserve_inputs(loader, inputs=["ClipTimeStart",
"ClipTimeEnd",
"HoldFirstFrame",
"HoldLastFrame"]):
with preserve_inputs(
loader,
inputs=[
"ClipTimeStart",
"ClipTimeEnd",
"HoldFirstFrame",
"HoldLastFrame",
],
):
# GlobalIn cannot be set past GlobalOut or vice versa
# so we must apply them in the order of the shift.
if shift > 0:
@ -129,7 +133,14 @@ def loader_shift(loader, frame, relative=True):
class FusionLoadSequence(load.LoaderPlugin):
"""Load image sequence into Fusion"""
families = ["imagesequence", "review", "render", "plate"]
families = [
"imagesequence",
"review",
"render",
"plate",
"image",
"onilne",
]
representations = ["*"]
extensions = set(
ext.lstrip(".") for ext in IMAGE_EXTENSIONS.union(VIDEO_EXTENSIONS)
@ -143,7 +154,7 @@ class FusionLoadSequence(load.LoaderPlugin):
def load(self, context, name, namespace, data):
# Fallback to asset name when namespace is None
if namespace is None:
namespace = context['asset']['name']
namespace = context["asset"]["name"]
# Use the first file for now
path = get_representation_path_from_context(context)
@ -151,7 +162,6 @@ class FusionLoadSequence(load.LoaderPlugin):
# Create the Loader with the filename path set
comp = get_current_comp()
with comp_lock_and_undo_chunk(comp, "Create Loader"):
args = (-32768, -32768)
tool = comp.AddTool("Loader", *args)
tool["Clip"] = path
@ -160,11 +170,13 @@ class FusionLoadSequence(load.LoaderPlugin):
start = self._get_start(context["version"], tool)
loader_shift(tool, start, relative=False)
imprint_container(tool,
name=name,
namespace=namespace,
context=context,
loader=self.__class__.__name__)
imprint_container(
tool,
name=name,
namespace=namespace,
context=context,
loader=self.__class__.__name__,
)
def switch(self, container, representation):
self.update(container, representation)
@ -222,24 +234,28 @@ class FusionLoadSequence(load.LoaderPlugin):
start = self._get_start(context["version"], tool)
with comp_lock_and_undo_chunk(comp, "Update Loader"):
# Update the loader's path whilst preserving some values
with preserve_trim(tool, log=self.log):
with preserve_inputs(tool,
inputs=("HoldFirstFrame",
"HoldLastFrame",
"Reverse",
"Depth",
"KeyCode",
"TimeCodeOffset")):
with preserve_inputs(
tool,
inputs=(
"HoldFirstFrame",
"HoldLastFrame",
"Reverse",
"Depth",
"KeyCode",
"TimeCodeOffset",
),
):
tool["Clip"] = path
# Set the global in to the start frame of the sequence
global_in_changed = loader_shift(tool, start, relative=False)
if global_in_changed:
# Log this change to the user
self.log.debug("Changed '%s' global in: %d" % (tool.Name,
start))
self.log.debug(
"Changed '%s' global in: %d" % (tool.Name, start)
)
# Update the imprinted representation
tool.SetData("avalon.representation", str(representation["_id"]))
@ -264,9 +280,11 @@ class FusionLoadSequence(load.LoaderPlugin):
# Get frame start without handles
start = data.get("frameStart")
if start is None:
self.log.warning("Missing start frame for version "
"assuming starts at frame 0 for: "
"{}".format(tool.Name))
self.log.warning(
"Missing start frame for version "
"assuming starts at frame 0 for: "
"{}".format(tool.Name)
)
return 0
# Use `handleStart` if the data is available

View file

@ -0,0 +1,32 @@
"""Import workfiles into your current comp.
As all imported nodes are free floating and will probably be changed there
is no update or reload function added for this plugin
"""
from openpype.pipeline import load
from openpype.hosts.fusion.api import (
get_current_comp,
get_bmd_library,
)
class FusionLoadWorkfile(load.LoaderPlugin):
"""Load the content of a workfile into Fusion"""
families = ["workfile"]
representations = ["*"]
extensions = {"comp"}
label = "Load Workfile"
order = -10
icon = "code-fork"
color = "orange"
def load(self, context, name, namespace, data):
# Get needed elements
bmd = get_bmd_library()
comp = get_current_comp()
# Paste the content of the file into the current comp
comp.Paste(bmd.readfile(self.fname))

View file

@ -35,9 +35,10 @@ class CollectFusionCompFrameRanges(pyblish.api.ContextPlugin):
# Store comp render ranges
start, end, global_start, global_end = get_comp_render_range(comp)
context.data["frameStart"] = int(start)
context.data["frameEnd"] = int(end)
context.data["frameStartHandle"] = int(global_start)
context.data["frameEndHandle"] = int(global_end)
context.data["handleStart"] = int(start) - int(global_start)
context.data["handleEnd"] = int(global_end) - int(end)
context.data.update({
"renderFrameStart": int(start),
"renderFrameEnd": int(end),
"compFrameStart": int(global_start),
"compFrameEnd": int(global_end)
})

View file

@ -1,50 +0,0 @@
import pyblish.api
from openpype.pipeline import publish
import os
class CollectFusionExpectedFrames(
pyblish.api.InstancePlugin, publish.ColormanagedPyblishPluginMixin
):
"""Collect all frames needed to publish expected frames"""
order = pyblish.api.CollectorOrder + 0.5
label = "Collect Expected Frames"
hosts = ["fusion"]
families = ["render"]
def process(self, instance):
context = instance.context
frame_start = context.data["frameStartHandle"]
frame_end = context.data["frameEndHandle"]
path = instance.data["path"]
output_dir = instance.data["outputDir"]
basename = os.path.basename(path)
head, ext = os.path.splitext(basename)
files = [
f"{head}{str(frame).zfill(4)}{ext}"
for frame in range(frame_start, frame_end + 1)
]
repre = {
"name": ext[1:],
"ext": ext[1:],
"frameStart": f"%0{len(str(frame_end))}d" % frame_start,
"files": files,
"stagingDir": output_dir,
}
self.set_representation_colorspace(
representation=repre,
context=context,
)
# review representation
if instance.data.get("review", False):
repre["tags"] = ["review"]
# add the repre to the instance
if "representations" not in instance.data:
instance.data["representations"] = []
instance.data["representations"].append(repre)

View file

@ -1,22 +0,0 @@
import pyblish.api
class CollectFusionVersion(pyblish.api.ContextPlugin):
"""Collect current comp"""
order = pyblish.api.CollectorOrder
label = "Collect Fusion Version"
hosts = ["fusion"]
def process(self, context):
"""Collect all image sequence tools"""
comp = context.data.get("currentComp")
if not comp:
raise RuntimeError("No comp previously collected, unable to "
"retrieve Fusion version.")
version = comp.GetApp().Version
context.data["fusionVersion"] = version
self.log.info("Fusion version: %s" % version)

View file

@ -1,5 +1,3 @@
import os
import pyblish.api
@ -24,23 +22,63 @@ class CollectInstanceData(pyblish.api.InstancePlugin):
creator_attributes = instance.data["creator_attributes"]
instance.data.update(creator_attributes)
# Include start and end render frame in label
subset = instance.data["subset"]
frame_range_source = creator_attributes.get("frame_range_source")
instance.data["frame_range_source"] = frame_range_source
# get asset frame ranges to all instances
# render family instances `asset_db` render target
start = context.data["frameStart"]
end = context.data["frameEnd"]
label = "{subset} ({start}-{end})".format(subset=subset,
start=int(start),
end=int(end))
handle_start = context.data["handleStart"]
handle_end = context.data["handleEnd"]
start_with_handle = start - handle_start
end_with_handle = end + handle_end
# conditions for render family instances
if frame_range_source == "render_range":
# set comp render frame ranges
start = context.data["renderFrameStart"]
end = context.data["renderFrameEnd"]
handle_start = 0
handle_end = 0
start_with_handle = start
end_with_handle = end
if frame_range_source == "comp_range":
comp_start = context.data["compFrameStart"]
comp_end = context.data["compFrameEnd"]
render_start = context.data["renderFrameStart"]
render_end = context.data["renderFrameEnd"]
# set comp frame ranges
start = render_start
end = render_end
handle_start = render_start - comp_start
handle_end = comp_end - render_end
start_with_handle = comp_start
end_with_handle = comp_end
# Include start and end render frame in label
subset = instance.data["subset"]
label = (
"{subset} ({start}-{end}) [{handle_start}-{handle_end}]"
).format(
subset=subset,
start=int(start),
end=int(end),
handle_start=int(handle_start),
handle_end=int(handle_end)
)
instance.data.update({
"label": label,
# todo: Allow custom frame range per instance
"frameStart": context.data["frameStart"],
"frameEnd": context.data["frameEnd"],
"frameStartHandle": context.data["frameStartHandle"],
"frameEndHandle": context.data["frameStartHandle"],
"handleStart": context.data["handleStart"],
"handleEnd": context.data["handleEnd"],
"frameStart": start,
"frameEnd": end,
"frameStartHandle": start_with_handle,
"frameEndHandle": end_with_handle,
"handleStart": handle_start,
"handleEnd": handle_end,
"fps": context.data["fps"],
})
@ -49,31 +87,3 @@ class CollectInstanceData(pyblish.api.InstancePlugin):
if instance.data.get("review", False):
self.log.info("Adding review family..")
instance.data["families"].append("review")
if instance.data["family"] == "render":
# TODO: This should probably move into a collector of
# its own for the "render" family
from openpype.hosts.fusion.api.lib import get_frame_path
comp = context.data["currentComp"]
# This is only the case for savers currently but not
# for workfile instances. So we assume saver here.
tool = instance.data["transientData"]["tool"]
path = tool["Clip"][comp.TIME_UNDEFINED]
filename = os.path.basename(path)
head, padding, tail = get_frame_path(filename)
ext = os.path.splitext(path)[1]
assert tail == ext, ("Tail does not match %s" % ext)
instance.data.update({
"path": path,
"outputDir": os.path.dirname(path),
"ext": ext, # todo: should be redundant?
# Backwards compatibility: embed tool in instance.data
"tool": tool
})
# Add tool itself as member
instance.append(tool)

View file

@ -0,0 +1,207 @@
import os
import attr
import pyblish.api
from openpype.pipeline import publish
from openpype.pipeline.publish import RenderInstance
from openpype.hosts.fusion.api.lib import get_frame_path
@attr.s
class FusionRenderInstance(RenderInstance):
# extend generic, composition name is needed
fps = attr.ib(default=None)
projectEntity = attr.ib(default=None)
stagingDir = attr.ib(default=None)
app_version = attr.ib(default=None)
tool = attr.ib(default=None)
workfileComp = attr.ib(default=None)
publish_attributes = attr.ib(default={})
class CollectFusionRender(
publish.AbstractCollectRender,
publish.ColormanagedPyblishPluginMixin
):
order = pyblish.api.CollectorOrder + 0.09
label = "Collect Fusion Render"
hosts = ["fusion"]
def get_instances(self, context):
comp = context.data.get("currentComp")
comp_frame_format_prefs = comp.GetPrefs("Comp.FrameFormat")
aspect_x = comp_frame_format_prefs["AspectX"]
aspect_y = comp_frame_format_prefs["AspectY"]
instances = []
instances_to_remove = []
current_file = context.data["currentFile"]
version = context.data["version"]
project_entity = context.data["projectEntity"]
for inst in context:
if not inst.data.get("active", True):
continue
family = inst.data["family"]
if family != "render":
continue
task_name = context.data["task"]
tool = inst.data["transientData"]["tool"]
instance_families = inst.data.get("families", [])
subset_name = inst.data["subset"]
instance = FusionRenderInstance(
family="render",
tool=tool,
workfileComp=comp,
families=instance_families,
version=version,
time="",
source=current_file,
label=inst.data["label"],
subset=subset_name,
asset=inst.data["asset"],
task=task_name,
attachTo=False,
setMembers='',
publish=True,
name=subset_name,
resolutionWidth=comp_frame_format_prefs.get("Width"),
resolutionHeight=comp_frame_format_prefs.get("Height"),
pixelAspect=aspect_x / aspect_y,
tileRendering=False,
tilesX=0,
tilesY=0,
review="review" in instance_families,
frameStart=inst.data["frameStart"],
frameEnd=inst.data["frameEnd"],
handleStart=inst.data["handleStart"],
handleEnd=inst.data["handleEnd"],
ignoreFrameHandleCheck=(
inst.data["frame_range_source"] == "render_range"),
frameStep=1,
fps=comp_frame_format_prefs.get("Rate"),
app_version=comp.GetApp().Version,
publish_attributes=inst.data.get("publish_attributes", {})
)
render_target = inst.data["creator_attributes"]["render_target"]
# Add render target family
render_target_family = f"render.{render_target}"
if render_target_family not in instance.families:
instance.families.append(render_target_family)
# Add render target specific data
if render_target in {"local", "frames"}:
instance.projectEntity = project_entity
if render_target == "farm":
fam = "render.farm"
if fam not in instance.families:
instance.families.append(fam)
instance.toBeRenderedOn = "deadline"
instance.farm = True # to skip integrate
if "review" in instance.families:
# to skip ExtractReview locally
instance.families.remove("review")
# add new instance to the list and remove the original
# instance since it is not needed anymore
instances.append(instance)
instances_to_remove.append(inst)
for instance in instances_to_remove:
context.remove(instance)
return instances
def post_collecting_action(self):
for instance in self._context:
if "render.frames" in instance.data.get("families", []):
# adding representation data to the instance
self._update_for_frames(instance)
def get_expected_files(self, render_instance):
"""
Returns list of rendered files that should be created by
Deadline. These are not published directly, they are source
for later 'submit_publish_job'.
Args:
render_instance (RenderInstance): to pull anatomy and parts used
in url
Returns:
(list) of absolute urls to rendered file
"""
start = render_instance.frameStart - render_instance.handleStart
end = render_instance.frameEnd + render_instance.handleEnd
path = (
render_instance.tool["Clip"]
[render_instance.workfileComp.TIME_UNDEFINED]
)
output_dir = os.path.dirname(path)
render_instance.outputDir = output_dir
basename = os.path.basename(path)
head, padding, ext = get_frame_path(basename)
expected_files = []
for frame in range(start, end + 1):
expected_files.append(
os.path.join(
output_dir,
f"{head}{str(frame).zfill(padding)}{ext}"
)
)
return expected_files
def _update_for_frames(self, instance):
"""Updating instance for render.frames family
Adding representation data to the instance. Also setting
colorspaceData to the representation based on file rules.
"""
expected_files = instance.data["expectedFiles"]
start = instance.data["frameStart"] - instance.data["handleStart"]
path = expected_files[0]
basename = os.path.basename(path)
staging_dir = os.path.dirname(path)
_, padding, ext = get_frame_path(basename)
repre = {
"name": ext[1:],
"ext": ext[1:],
"frameStart": f"%0{padding}d" % start,
"files": [os.path.basename(f) for f in expected_files],
"stagingDir": staging_dir,
}
self.set_representation_colorspace(
representation=repre,
context=instance.context,
)
# review representation
if instance.data.get("review", False):
repre["tags"] = ["review"]
# add the repre to the instance
if "representations" not in instance.data:
instance.data["representations"] = []
instance.data["representations"].append(repre)
return instance

View file

@ -1,25 +0,0 @@
import pyblish.api
class CollectFusionRenders(pyblish.api.InstancePlugin):
"""Collect current saver node's render Mode
Options:
local (Render locally)
frames (Use existing frames)
"""
order = pyblish.api.CollectorOrder + 0.4
label = "Collect Renders"
hosts = ["fusion"]
families = ["render"]
def process(self, instance):
render_target = instance.data["render_target"]
family = instance.data["family"]
# add targeted family to families
instance.data["families"].append(
"{}.{}".format(family, render_target)
)

View file

@ -1,8 +1,11 @@
import os
import logging
import contextlib
import pyblish.api
from openpype.hosts.fusion.api import comp_lock_and_undo_chunk
from openpype.pipeline import publish
from openpype.hosts.fusion.api import comp_lock_and_undo_chunk
from openpype.hosts.fusion.api.lib import get_frame_path
log = logging.getLogger(__name__)
@ -38,7 +41,10 @@ def enabled_savers(comp, savers):
saver.SetAttrs({"TOOLB_PassThrough": original_state})
class FusionRenderLocal(pyblish.api.InstancePlugin):
class FusionRenderLocal(
pyblish.api.InstancePlugin,
publish.ColormanagedPyblishPluginMixin
):
"""Render the current Fusion composition locally."""
order = pyblish.api.ExtractorOrder - 0.2
@ -52,6 +58,8 @@ class FusionRenderLocal(pyblish.api.InstancePlugin):
# Start render
self.render_once(context)
self._add_representation(instance)
# Log render status
self.log.info(
"Rendered '{nm}' for asset '{ast}' under the task '{tsk}'".format(
@ -71,11 +79,11 @@ class FusionRenderLocal(pyblish.api.InstancePlugin):
savers_to_render = [
# Get the saver tool from the instance
instance[0] for instance in context if
instance.data["tool"] for instance in context if
# Only active instances
instance.data.get("publish", True) and
# Only render.local instances
"render.local" in instance.data["families"]
"render.local" in instance.data.get("families", [])
]
if key not in context.data:
@ -107,3 +115,39 @@ class FusionRenderLocal(pyblish.api.InstancePlugin):
if context.data[key] is False:
raise RuntimeError("Comp render failed")
def _add_representation(self, instance):
"""Add representation to instance"""
expected_files = instance.data["expectedFiles"]
start = instance.data["frameStart"] - instance.data["handleStart"]
path = expected_files[0]
_, padding, ext = get_frame_path(path)
staging_dir = os.path.dirname(path)
repre = {
"name": ext[1:],
"ext": ext[1:],
"frameStart": f"%0{padding}d" % start,
"files": [os.path.basename(f) for f in expected_files],
"stagingDir": staging_dir,
}
self.set_representation_colorspace(
representation=repre,
context=instance.context,
)
# review representation
if instance.data.get("review", False):
repre["tags"] = ["review"]
# add the repre to the instance
if "representations" not in instance.data:
instance.data["representations"] = []
instance.data["representations"].append(repre)
return instance

View file

@ -21,7 +21,7 @@ class ValidateCreateFolderChecked(pyblish.api.InstancePlugin):
@classmethod
def get_invalid(cls, instance):
tool = instance[0]
tool = instance.data["tool"]
create_dir = tool.GetInput("CreateDir")
if create_dir == 0.0:
cls.log.error(

View file

@ -14,7 +14,7 @@ class ValidateLocalFramesExistence(pyblish.api.InstancePlugin):
order = pyblish.api.ValidatorOrder
label = "Validate Expected Frames Exists"
families = ["render"]
families = ["render.frames"]
hosts = ["fusion"]
actions = [RepairAction, SelectInvalidAction]
@ -23,31 +23,20 @@ class ValidateLocalFramesExistence(pyblish.api.InstancePlugin):
if non_existing_frames is None:
non_existing_frames = []
if instance.data.get("render_target") == "frames":
tool = instance[0]
tool = instance.data["tool"]
frame_start = instance.data["frameStart"]
frame_end = instance.data["frameEnd"]
path = instance.data["path"]
output_dir = instance.data["outputDir"]
expected_files = instance.data["expectedFiles"]
basename = os.path.basename(path)
head, ext = os.path.splitext(basename)
files = [
f"{head}{str(frame).zfill(4)}{ext}"
for frame in range(frame_start, frame_end + 1)
]
for file in expected_files:
if not os.path.exists(file):
cls.log.error(
f"Missing file: {file}"
)
non_existing_frames.append(file)
for file in files:
if not os.path.exists(os.path.join(output_dir, file)):
cls.log.error(
f"Missing file: {os.path.join(output_dir, file)}"
)
non_existing_frames.append(file)
if len(non_existing_frames) > 0:
cls.log.error(f"Some of {tool.Name}'s files does not exist")
return [tool]
if len(non_existing_frames) > 0:
cls.log.error(f"Some of {tool.Name}'s files does not exist")
return [tool]
def process(self, instance):
non_existing_frames = []
@ -67,8 +56,7 @@ class ValidateLocalFramesExistence(pyblish.api.InstancePlugin):
def repair(cls, instance):
invalid = cls.get_invalid(instance)
if invalid:
tool = invalid[0]
tool = instance.data["tool"]
# Change render target to local to render locally
tool.SetData("openpype.creator_attributes.render_target", "local")

View file

@ -30,11 +30,11 @@ class ValidateFilenameHasExtension(pyblish.api.InstancePlugin):
@classmethod
def get_invalid(cls, instance):
path = instance.data["path"]
path = instance.data["expectedFiles"][0]
fname, ext = os.path.splitext(path)
if not ext:
tool = instance[0]
tool = instance.data["tool"]
cls.log.error("%s has no extension specified" % tool.Name)
return [tool]

View file

@ -20,7 +20,7 @@ class ValidateSaverHasInput(pyblish.api.InstancePlugin):
@classmethod
def get_invalid(cls, instance):
saver = instance[0]
saver = instance.data["tool"]
if not saver.Input.GetConnectedOutput():
return [saver]

View file

@ -37,7 +37,7 @@ class ValidateSaverPassthrough(pyblish.api.ContextPlugin):
def is_invalid(self, instance):
saver = instance[0]
saver = instance.data["tool"]
attr = saver.GetAttrs()
active = not attr["TOOLB_PassThrough"]

View file

@ -133,11 +133,11 @@ class CollectNukeWrites(pyblish.api.InstancePlugin,
else:
representation['files'] = collected_frames
# inject colorspace data
self.set_representation_colorspace(
representation, instance.context,
colorspace=colorspace
)
# inject colorspace data
self.set_representation_colorspace(
representation, instance.context,
colorspace=colorspace
)
instance.data["representations"].append(representation)
self.log.info("Publishing rendered frames ...")

View file

@ -1,4 +1,5 @@
import os
from pathlib import Path
import platform
from openpype.lib import PreLaunchHook
from openpype.hosts.resolve.utils import setup
@ -6,33 +7,57 @@ from openpype.hosts.resolve.utils import setup
class ResolvePrelaunch(PreLaunchHook):
"""
This hook will check if current workfile path has Resolve
project inside. IF not, it initialize it and finally it pass
path to the project by environment variable to Premiere launcher
shell script.
This hook will set up the Resolve scripting environment as described in
Resolve's documentation found with the installed application at
{resolve}/Support/Developer/Scripting/README.txt
Prepares the following environment variables:
- `RESOLVE_SCRIPT_API`
- `RESOLVE_SCRIPT_LIB`
It adds $RESOLVE_SCRIPT_API/Modules to PYTHONPATH.
Additionally it sets up the Python home for Python 3 based on the
RESOLVE_PYTHON3_HOME in the environment (usually defined in OpenPype's
Application environment for Resolve by the admin). For this it sets
PYTHONHOME and PATH variables.
It also defines:
- `RESOLVE_UTILITY_SCRIPTS_DIR`: Destination directory for OpenPype
Fusion scripts to be copied to for Resolve to pick them up.
- `OPENPYPE_LOG_NO_COLORS` to True to ensure OP doesn't try to
use logging with terminal colors as it fails in Resolve.
"""
app_groups = ["resolve"]
def execute(self):
current_platform = platform.system().lower()
PROGRAMDATA = self.launch_context.env.get("PROGRAMDATA", "")
RESOLVE_SCRIPT_API_ = {
programdata = self.launch_context.env.get("PROGRAMDATA", "")
resolve_script_api_locations = {
"windows": (
f"{PROGRAMDATA}/Blackmagic Design/"
f"{programdata}/Blackmagic Design/"
"DaVinci Resolve/Support/Developer/Scripting"
),
"darwin": (
"/Library/Application Support/Blackmagic Design"
"/DaVinci Resolve/Developer/Scripting"
),
"linux": "/opt/resolve/Developer/Scripting"
"linux": "/opt/resolve/Developer/Scripting",
}
RESOLVE_SCRIPT_API = os.path.normpath(
RESOLVE_SCRIPT_API_[current_platform])
self.launch_context.env["RESOLVE_SCRIPT_API"] = RESOLVE_SCRIPT_API
resolve_script_api = Path(
resolve_script_api_locations[current_platform]
)
self.log.info(
f"setting RESOLVE_SCRIPT_API variable to {resolve_script_api}"
)
self.launch_context.env[
"RESOLVE_SCRIPT_API"
] = resolve_script_api.as_posix()
RESOLVE_SCRIPT_LIB_ = {
resolve_script_lib_dirs = {
"windows": (
"C:/Program Files/Blackmagic Design"
"/DaVinci Resolve/fusionscript.dll"
@ -41,61 +66,69 @@ class ResolvePrelaunch(PreLaunchHook):
"/Applications/DaVinci Resolve/DaVinci Resolve.app"
"/Contents/Libraries/Fusion/fusionscript.so"
),
"linux": "/opt/resolve/libs/Fusion/fusionscript.so"
"linux": "/opt/resolve/libs/Fusion/fusionscript.so",
}
RESOLVE_SCRIPT_LIB = os.path.normpath(
RESOLVE_SCRIPT_LIB_[current_platform])
self.launch_context.env["RESOLVE_SCRIPT_LIB"] = RESOLVE_SCRIPT_LIB
resolve_script_lib = Path(resolve_script_lib_dirs[current_platform])
self.launch_context.env[
"RESOLVE_SCRIPT_LIB"
] = resolve_script_lib.as_posix()
self.log.info(
f"setting RESOLVE_SCRIPT_LIB variable to {resolve_script_lib}"
)
# TODO: add OTIO installation from `openpype/requirements.py`
# TODO: add OTIO installation from `openpype/requirements.py`
# making sure python <3.9.* is installed at provided path
python3_home = os.path.normpath(
self.launch_context.env.get("RESOLVE_PYTHON3_HOME", ""))
python3_home = Path(
self.launch_context.env.get("RESOLVE_PYTHON3_HOME", "")
)
assert os.path.isdir(python3_home), (
assert python3_home.is_dir(), (
"Python 3 is not installed at the provided folder path. Either "
"make sure the `environments\resolve.json` is having correctly "
"set `RESOLVE_PYTHON3_HOME` or make sure Python 3 is installed "
f"in given path. \nRESOLVE_PYTHON3_HOME: `{python3_home}`"
)
self.launch_context.env["PYTHONHOME"] = python3_home
self.log.info(f"Path to Resolve Python folder: `{python3_home}`...")
# add to the python path to path
env_path = self.launch_context.env["PATH"]
self.launch_context.env["PATH"] = os.pathsep.join([
python3_home,
os.path.join(python3_home, "Scripts")
] + env_path.split(os.pathsep))
self.log.debug(f"PATH: {self.launch_context.env['PATH']}")
python3_home_str = python3_home.as_posix()
self.launch_context.env["PYTHONHOME"] = python3_home_str
self.log.info(f"Path to Resolve Python folder: `{python3_home_str}`")
# add to the PYTHONPATH
env_pythonpath = self.launch_context.env["PYTHONPATH"]
self.launch_context.env["PYTHONPATH"] = os.pathsep.join([
os.path.join(python3_home, "Lib", "site-packages"),
os.path.join(RESOLVE_SCRIPT_API, "Modules"),
] + env_pythonpath.split(os.pathsep))
modules_path = Path(resolve_script_api, "Modules").as_posix()
self.launch_context.env[
"PYTHONPATH"
] = f"{modules_path}{os.pathsep}{env_pythonpath}"
self.log.debug(f"PYTHONPATH: {self.launch_context.env['PYTHONPATH']}")
RESOLVE_UTILITY_SCRIPTS_DIR_ = {
# add the pythonhome folder to PATH because on Windows
# this is needed for Py3 to be correctly detected within Resolve
env_path = self.launch_context.env["PATH"]
self.log.info(f"Adding `{python3_home_str}` to the PATH variable")
self.launch_context.env[
"PATH"
] = f"{python3_home_str}{os.pathsep}{env_path}"
self.log.debug(f"PATH: {self.launch_context.env['PATH']}")
resolve_utility_scripts_dirs = {
"windows": (
f"{PROGRAMDATA}/Blackmagic Design"
f"{programdata}/Blackmagic Design"
"/DaVinci Resolve/Fusion/Scripts/Comp"
),
"darwin": (
"/Library/Application Support/Blackmagic Design"
"/DaVinci Resolve/Fusion/Scripts/Comp"
),
"linux": "/opt/resolve/Fusion/Scripts/Comp"
"linux": "/opt/resolve/Fusion/Scripts/Comp",
}
RESOLVE_UTILITY_SCRIPTS_DIR = os.path.normpath(
RESOLVE_UTILITY_SCRIPTS_DIR_[current_platform]
resolve_utility_scripts_dir = Path(
resolve_utility_scripts_dirs[current_platform]
)
# setting utility scripts dir for scripts syncing
self.launch_context.env["RESOLVE_UTILITY_SCRIPTS_DIR"] = (
RESOLVE_UTILITY_SCRIPTS_DIR)
self.launch_context.env[
"RESOLVE_UTILITY_SCRIPTS_DIR"
] = resolve_utility_scripts_dir.as_posix()
# remove terminal coloring tags
self.launch_context.env["OPENPYPE_LOG_NO_COLORS"] = "True"

View file

@ -8,30 +8,30 @@ RESOLVE_ROOT_DIR = os.path.dirname(os.path.abspath(__file__))
def setup(env):
log = Logger.get_logger("ResolveSetup")
scripts = {}
us_env = env.get("RESOLVE_UTILITY_SCRIPTS_SOURCE_DIR")
us_dir = env["RESOLVE_UTILITY_SCRIPTS_DIR"]
util_scripts_env = env.get("RESOLVE_UTILITY_SCRIPTS_SOURCE_DIR")
util_scripts_dir = env["RESOLVE_UTILITY_SCRIPTS_DIR"]
us_paths = [os.path.join(
util_scripts_paths = [os.path.join(
RESOLVE_ROOT_DIR,
"utility_scripts"
)]
# collect script dirs
if us_env:
log.info("Utility Scripts Env: `{}`".format(us_env))
us_paths = us_env.split(
os.pathsep) + us_paths
if util_scripts_env:
log.info("Utility Scripts Env: `{}`".format(util_scripts_env))
util_scripts_paths = util_scripts_env.split(
os.pathsep) + util_scripts_paths
# collect scripts from dirs
for path in us_paths:
for path in util_scripts_paths:
scripts.update({path: os.listdir(path)})
log.info("Utility Scripts Dir: `{}`".format(us_paths))
log.info("Utility Scripts Dir: `{}`".format(util_scripts_paths))
log.info("Utility Scripts: `{}`".format(scripts))
# make sure no script file is in folder
for s in os.listdir(us_dir):
path = os.path.join(us_dir, s)
for script in os.listdir(util_scripts_dir):
path = os.path.join(util_scripts_dir, script)
log.info("Removing `{}`...".format(path))
if os.path.isdir(path):
shutil.rmtree(path, onerror=None)
@ -39,12 +39,10 @@ def setup(env):
os.remove(path)
# copy scripts into Resolve's utility scripts dir
for d, sl in scripts.items():
# directory and scripts list
for s in sl:
# script in script list
src = os.path.join(d, s)
dst = os.path.join(us_dir, s)
for directory, scripts in scripts.items():
for script in scripts:
src = os.path.join(directory, script)
dst = os.path.join(util_scripts_dir, script)
log.info("Copying `{}` to `{}`...".format(src, dst))
if os.path.isdir(src):
shutil.copytree(

View file

@ -286,6 +286,26 @@ class CameraLoader(plugin.Loader):
self.fname
)
# Set range of all sections
# Changing the range of the section is not enough. We need to change
# the frame of all the keys in the section.
for possessable in cam_seq.get_possessables():
for tracks in possessable.get_tracks():
for section in tracks.get_sections():
section.set_range(
data.get('clipIn'),
data.get('clipOut') + 1)
for channel in section.get_all_channels():
for key in channel.get_keys():
old_time = key.get_time().get_editor_property(
'frame_number')
old_time_value = old_time.get_editor_property(
'value')
new_time = old_time_value + (
data.get('clipIn') - data.get('frameStart')
)
key.set_time(unreal.FrameNumber(value=new_time))
# Create Asset Container
unreal_pipeline.create_container(
container=container_name, path=asset_dir)

View file

@ -7,9 +7,19 @@ import requests
import pyblish.api
from openpype.pipeline import legacy_io
from openpype.pipeline.publish import (
OpenPypePyblishPluginMixin
)
from openpype.lib import (
BoolDef,
NumberDef
)
class FusionSubmitDeadline(pyblish.api.InstancePlugin):
class FusionSubmitDeadline(
pyblish.api.InstancePlugin,
OpenPypePyblishPluginMixin
):
"""Submit current Comp to Deadline
Renders are submitted to a Deadline Web Service as
@ -17,12 +27,62 @@ class FusionSubmitDeadline(pyblish.api.InstancePlugin):
"""
label = "Submit to Deadline"
label = "Submit Fusion to Deadline"
order = pyblish.api.IntegratorOrder
hosts = ["fusion"]
families = ["render.farm"]
families = ["render"]
targets = ["local"]
# presets
priority = 50
chunk_size = 1
concurrent_tasks = 1
group = ""
@classmethod
def get_attribute_defs(cls):
return [
NumberDef(
"priority",
label="Priority",
default=cls.priority,
decimals=0
),
NumberDef(
"chunk",
label="Frames Per Task",
default=cls.chunk_size,
decimals=0,
minimum=1,
maximum=1000
),
NumberDef(
"concurrency",
label="Concurrency",
default=cls.concurrent_tasks,
decimals=0,
minimum=1,
maximum=10
),
BoolDef(
"suspend_publish",
default=False,
label="Suspend publish"
)
]
def process(self, instance):
if not instance.data.get("farm"):
self.log.info("Skipping local instance.")
return
attribute_values = self.get_attr_values_from_data(
instance.data)
# add suspend_publish attributeValue to instance data
instance.data["suspend_publish"] = attribute_values[
"suspend_publish"]
context = instance.context
key = "__hasRun{}".format(self.__class__.__name__)
@ -33,36 +93,55 @@ class FusionSubmitDeadline(pyblish.api.InstancePlugin):
from openpype.hosts.fusion.api.lib import get_frame_path
deadline_url = (
context.data["system_settings"]
["modules"]
["deadline"]
["DEADLINE_REST_URL"]
)
assert deadline_url, "Requires DEADLINE_REST_URL"
# get default deadline webservice url from deadline module
deadline_url = instance.context.data["defaultDeadline"]
# if custom one is set in instance, use that
if instance.data.get("deadlineUrl"):
deadline_url = instance.data.get("deadlineUrl")
assert deadline_url, "Requires Deadline Webservice URL"
# Collect all saver instances in context that are to be rendered
saver_instances = []
for instance in context[:]:
if not self.families[0] in instance.data.get("families"):
for instance in context:
if instance.data["family"] != "render":
# Allow only saver family instances
continue
if not instance.data.get("publish", True):
# Skip inactive instances
continue
self.log.debug(instance.data["name"])
saver_instances.append(instance)
if not saver_instances:
raise RuntimeError("No instances found for Deadline submittion")
raise RuntimeError("No instances found for Deadline submission")
fusion_version = int(context.data["fusionVersion"])
filepath = context.data["currentFile"]
filename = os.path.basename(filepath)
comment = context.data.get("comment", "")
comment = instance.data.get("comment", "")
deadline_user = context.data.get("deadlineUser", getpass.getuser())
script_path = context.data["currentFile"]
for item in context:
if "workfile" in item.data["families"]:
msg = "Workfile (scene) must be published along"
assert item.data["publish"] is True, msg
template_data = item.data.get("anatomyData")
rep = item.data.get("representations")[0].get("name")
template_data["representation"] = rep
template_data["ext"] = rep
template_data["comment"] = None
anatomy_filled = context.data["anatomy"].format(template_data)
template_filled = anatomy_filled["publish"]["path"]
script_path = os.path.normpath(template_filled)
self.log.info(
"Using published scene for render {}".format(script_path)
)
filename = os.path.basename(script_path)
# Documentation for keys available at:
# https://docs.thinkboxsoftware.com
# /products/deadline/8.0/1_User%20Manual/manual
@ -73,31 +152,41 @@ class FusionSubmitDeadline(pyblish.api.InstancePlugin):
"BatchName": filename,
# Asset dependency to wait for at least the scene file to sync.
"AssetDependency0": filepath,
"AssetDependency0": script_path,
# Job name, as seen in Monitor
"Name": filename,
"Priority": attribute_values.get(
"priority", self.priority),
"ChunkSize": attribute_values.get(
"chunk", self.chunk_size),
"ConcurrentTasks": attribute_values.get(
"concurrency",
self.concurrent_tasks
),
# User, as seen in Monitor
"UserName": deadline_user,
# Use a default submission pool for Fusion
"Pool": "fusion",
"Pool": instance.data.get("primaryPool"),
"SecondaryPool": instance.data.get("secondaryPool"),
"Group": self.group,
"Plugin": "Fusion",
"Frames": "{start}-{end}".format(
start=int(context.data["frameStart"]),
end=int(context.data["frameEnd"])
start=int(instance.data["frameStartHandle"]),
end=int(instance.data["frameEndHandle"])
),
"Comment": comment,
},
"PluginInfo": {
# Input
"FlowFile": filepath,
"FlowFile": script_path,
# Mandatory for Deadline
"Version": str(fusion_version),
"Version": str(instance.data["app_version"]),
# Render in high quality
"HighQuality": True,
@ -108,7 +197,7 @@ class FusionSubmitDeadline(pyblish.api.InstancePlugin):
# Proxy: higher numbers smaller images for faster test renders
# 1 = no proxy quality
"Proxy": 1,
"Proxy": 1
},
# Mandatory for Deadline, may be empty
@ -117,7 +206,9 @@ class FusionSubmitDeadline(pyblish.api.InstancePlugin):
# Enable going to rendered frames from Deadline Monitor
for index, instance in enumerate(saver_instances):
head, padding, tail = get_frame_path(instance.data["path"])
head, padding, tail = get_frame_path(
instance.data["expectedFiles"][0]
)
path = "{}{}{}".format(head, "#" * padding, tail)
folder, filename = os.path.split(path)
payload["JobInfo"]["OutputDirectory%d" % index] = folder

View file

@ -36,6 +36,9 @@ from .lib import (
context_plugin_should_run,
get_instance_staging_dir,
get_publish_repre_path,
apply_plugin_settings_automatically,
get_plugin_settings,
)
from .abstract_expected_files import ExpectedFiles
@ -80,6 +83,9 @@ __all__ = (
"get_instance_staging_dir",
"get_publish_repre_path",
"apply_plugin_settings_automatically",
"get_plugin_settings",
"ExpectedFiles",
"RenderInstance",

View file

@ -320,6 +320,14 @@ def publish_plugins_discover(paths=None):
continue
for plugin in pyblish.plugin.plugins_from_module(module):
# Ignore base plugin classes
# NOTE 'pyblish.api.discover' does not ignore them!
if (
plugin is pyblish.api.Plugin
or plugin is pyblish.api.ContextPlugin
or plugin is pyblish.api.InstancePlugin
):
continue
if not allow_duplicates and plugin.__name__ in plugin_names:
result.duplicated_plugins.append(plugin)
log.debug("Duplicate plug-in found: %s", plugin)
@ -355,29 +363,55 @@ def publish_plugins_discover(paths=None):
return result
def _get_plugin_settings(host_name, project_settings, plugin, log):
def get_plugin_settings(plugin, project_settings, log, category=None):
"""Get plugin settings based on host name and plugin name.
Note:
Default implementation of automated settings is passing host name
into 'category'.
Args:
host_name (str): Name of host.
plugin (pyblish.Plugin): Plugin where settings are applied.
project_settings (dict[str, Any]): Project settings.
plugin (pyliblish.Plugin): Plugin where settings are applied.
log (logging.Logger): Logger to log messages.
category (Optional[str]): Settings category key where to look
for plugin settings.
Returns:
dict[str, Any]: Plugin settings {'attribute': 'value'}.
"""
# Use project settings from host name category when available
try:
return (
project_settings
[host_name]
["publish"]
[plugin.__name__]
)
except KeyError:
pass
# Plugin can define settings category by class attribute
# - it's impossible to set `settings_category` via settings because
# obviously settings are not applied before it.
# - if `settings_category` is set the fallback category method is ignored
settings_category = getattr(plugin, "settings_category", None)
if settings_category:
try:
return (
project_settings
[settings_category]
["publish"]
[plugin.__name__]
)
except KeyError:
log.warning((
"Couldn't find plugin '{}' settings"
" under settings category '{}'"
).format(plugin.__name__, settings_category))
return {}
# Use project settings based on a category name
if category:
try:
return (
project_settings
[category]
["publish"]
[plugin.__name__]
)
except KeyError:
pass
# Settings category determined from path
# - usually path is './<category>/plugins/publish/<plugin file>'
@ -386,9 +420,10 @@ def _get_plugin_settings(host_name, project_settings, plugin, log):
split_path = filepath.rsplit(os.path.sep, 5)
if len(split_path) < 4:
log.warning(
'plugin path too short to extract host {}'.format(filepath)
)
log.debug((
"Plugin path is too short to automatically"
" extract settings category. {}"
).format(filepath))
return {}
category_from_file = split_path[-4]
@ -410,6 +445,28 @@ def _get_plugin_settings(host_name, project_settings, plugin, log):
return {}
def apply_plugin_settings_automatically(plugin, settings, logger=None):
"""Automatically apply plugin settings to a plugin object.
Note:
This function was created to be able to use it in custom overrides of
'apply_settings' class method.
Args:
plugin (type[pyblish.api.Plugin]): Class of a plugin.
settings (dict[str, Any]): Plugin specific settings.
logger (Optional[logging.Logger]): Logger to log debug messages about
applied settings values.
"""
for option, value in settings.items():
if logger:
logger.debug("Plugin {} - Attr: {} -> {}".format(
option, value, plugin.__name__
))
setattr(plugin, option, value)
def filter_pyblish_plugins(plugins):
"""Pyblish plugin filter which applies OpenPype settings.
@ -453,13 +510,10 @@ def filter_pyblish_plugins(plugins):
)
else:
# Automated
plugin_settins = _get_plugin_settings(
host_name, project_settings, plugin, log
plugin_settins = get_plugin_settings(
plugin, project_settings, log, host_name
)
for option, value in plugin_settins.items():
log.info("setting {}:{} on plugin {}".format(
option, value, plugin.__name__))
setattr(plugin, option, value)
apply_plugin_settings_automatically(plugin, plugin_settins, log)
# Remove disabled plugins
if getattr(plugin, "enabled", True) is False:

View file

@ -19,12 +19,13 @@ class OpenInDJV(load.LoaderPlugin):
djv_list = existing_djv_path()
families = ["*"] if djv_list else []
representations = [
representations = ["*"]
extensions = {
"cin", "dpx", "avi", "dv", "gif", "flv", "mkv", "mov", "mpg", "mpeg",
"mp4", "m4v", "mxf", "iff", "z", "ifl", "jpeg", "jpg", "jfif", "lut",
"1dl", "exr", "pic", "png", "ppm", "pnm", "pgm", "pbm", "rla", "rpf",
"sgi", "rgba", "rgb", "bw", "tga", "tiff", "tif", "img", "h264",
]
}
label = "Open in DJV"
order = -10

View file

@ -81,9 +81,10 @@ def main(argv):
host_name = os.environ["AVALON_APP"].lower()
if host_name == "photoshop":
# TODO refactor launch logic according to AE
from openpype.hosts.photoshop.api.lib import main
elif host_name == "aftereffects":
from openpype.hosts.aftereffects.api.lib import main
from openpype.hosts.aftereffects.api.launch_logic import main
elif host_name == "harmony":
from openpype.hosts.harmony.api.lib import main
else:

View file

@ -45,6 +45,15 @@
"chunk_size": 10,
"group": "none"
},
"FusionSubmitDeadline": {
"enabled": true,
"optional": false,
"active": true,
"priority": 50,
"chunk_size": 10,
"concurrent_tasks": 1,
"group": ""
},
"NukeSubmitDeadline": {
"enabled": true,
"optional": false,
@ -114,6 +123,9 @@
],
"max": [
".*"
],
"fusion": [
".*"
]
}
}

View file

@ -1069,8 +1069,8 @@
"RESOLVE_UTILITY_SCRIPTS_SOURCE_DIR": [],
"RESOLVE_PYTHON3_HOME": {
"windows": "{LOCALAPPDATA}/Programs/Python/Python36",
"darwin": "~/Library/Python/3.6/bin",
"linux": "/opt/Python/3.6/bin"
"darwin": "/Library/Frameworks/Python.framework/Versions/3.6",
"linux": "/opt/Python/3.6"
}
},
"variants": {

View file

@ -248,6 +248,50 @@
}
]
},
{
"type": "dict",
"collapsible": true,
"key": "FusionSubmitDeadline",
"label": "Fusion submit to Deadline",
"checkbox_key": "enabled",
"children": [
{
"type": "boolean",
"key": "enabled",
"label": "Enabled"
},
{
"type": "boolean",
"key": "optional",
"label": "Optional"
},
{
"type": "boolean",
"key": "active",
"label": "Active"
},
{
"type": "number",
"key": "priority",
"label": "Priority"
},
{
"type": "number",
"key": "chunk_size",
"label": "Frame per Task"
},
{
"type": "number",
"key": "concurrent_tasks",
"label": "Number of concurrent tasks"
},
{
"type": "text",
"key": "group",
"label": "Group Name"
}
]
},
{
"type": "dict",
"collapsible": true,

View file

@ -26,8 +26,8 @@
"bg": "#2C313A",
"bg-inputs": "#21252B",
"bg-buttons": "#434a56",
"bg-button-hover": "rgb(81, 86, 97)",
"bg-buttons": "rgb(67, 74, 86)",
"bg-buttons-hover": "rgb(81, 86, 97)",
"bg-inputs-disabled": "#2C313A",
"bg-buttons-disabled": "#434a56",
@ -66,7 +66,9 @@
"bg-success": "#458056",
"bg-success-hover": "#55a066",
"bg-error": "#AD2E2E",
"bg-error-hover": "#C93636"
"bg-error-hover": "#C93636",
"bg-info": "rgb(63, 98, 121)",
"bg-info-hover": "rgb(81, 146, 181)"
},
"tab-widget": {
"bg": "#21252B",
@ -94,6 +96,7 @@
"crash": "#FF6432",
"success": "#458056",
"warning": "#ffc671",
"progress": "rgb(194, 226, 236)",
"tab-bg": "#16191d",
"list-view-group": {
"bg": "#434a56",

View file

@ -136,7 +136,7 @@ QPushButton {
}
QPushButton:hover {
background: {color:bg-button-hover};
background: {color:bg-buttons-hover};
color: {color:font-hover};
}
@ -166,7 +166,7 @@ QToolButton {
}
QToolButton:hover {
background: {color:bg-button-hover};
background: {color:bg-buttons-hover};
color: {color:font-hover};
}
@ -722,6 +722,13 @@ OverlayMessageWidget[type="error"]:hover {
background: {color:overlay-messages:bg-error-hover};
}
OverlayMessageWidget[type="info"] {
background: {color:overlay-messages:bg-info};
}
OverlayMessageWidget[type="info"]:hover {
background: {color:overlay-messages:bg-info-hover};
}
OverlayMessageWidget QWidget {
background: transparent;
}
@ -749,10 +756,11 @@ OverlayMessageWidget QWidget {
}
#InfoText {
padding-left: 30px;
padding-top: 20px;
padding-left: 0px;
padding-top: 0px;
padding-right: 20px;
background: transparent;
border: 1px solid {color:border};
border: none;
}
#TypeEditor, #ToolEditor, #NameEditor, #NumberEditor {
@ -914,7 +922,7 @@ PixmapButton{
background: {color:bg-buttons};
}
PixmapButton:hover {
background: {color:bg-button-hover};
background: {color:bg-buttons-hover};
}
PixmapButton:disabled {
background: {color:bg-buttons-disabled};
@ -925,7 +933,7 @@ PixmapButton:disabled {
background: {color:bg-view};
}
#ThumbnailPixmapHoverButton:hover {
background: {color:bg-button-hover};
background: {color:bg-buttons-hover};
}
#CreatorDetailedDescription {
@ -946,7 +954,7 @@ PixmapButton:disabled {
}
#CreateDialogHelpButton:hover {
background: {color:bg-button-hover};
background: {color:bg-buttons-hover};
}
#CreateDialogHelpButton QWidget {
background: transparent;
@ -1005,7 +1013,7 @@ PixmapButton:disabled {
border-radius: 0.2em;
}
#CardViewWidget:hover {
background: {color:bg-button-hover};
background: {color:bg-buttons-hover};
}
#CardViewWidget[state="selected"] {
background: {color:bg-view-selection};
@ -1032,7 +1040,7 @@ PixmapButton:disabled {
}
#PublishInfoFrame[state="3"], #PublishInfoFrame[state="4"] {
background: rgb(194, 226, 236);
background: {color:publisher:progress};
}
#PublishInfoFrame QLabel {
@ -1040,6 +1048,11 @@ PixmapButton:disabled {
font-style: bold;
}
#PublishReportHeader {
font-size: 14pt;
font-weight: bold;
}
#PublishInfoMainLabel {
font-size: 12pt;
}
@ -1060,7 +1073,7 @@ ValidationArtistMessage QLabel {
}
#ValidationActionButton:hover {
background: {color:bg-button-hover};
background: {color:bg-buttons-hover};
color: {color:font-hover};
}
@ -1090,6 +1103,35 @@ ValidationArtistMessage QLabel {
border-left: 1px solid {color:border};
}
#PublishInstancesDetails {
border: 1px solid {color:border};
border-radius: 0.3em;
}
#InstancesLogsView {
border: 1px solid {color:border};
background: {color:bg-view};
border-radius: 0.3em;
}
#PublishLogMessage {
font-family: "Noto Sans Mono";
}
#PublishInstanceLogsLabel {
font-weight: bold;
}
#PublishCrashMainLabel{
font-weight: bold;
font-size: 16pt;
}
#PublishCrashReportLabel {
font-weight: bold;
font-size: 13pt;
}
#AssetNameInputWidget {
background: {color:bg-inputs};
border: 1px solid {color:border};

View file

@ -198,29 +198,33 @@ class DropEmpty(QtWidgets.QWidget):
def paintEvent(self, event):
super(DropEmpty, self).paintEvent(event)
painter = QtGui.QPainter(self)
pen = QtGui.QPen()
pen.setWidth(1)
pen.setBrush(QtCore.Qt.darkGray)
pen.setStyle(QtCore.Qt.DashLine)
painter.setPen(pen)
content_margins = self.layout().contentsMargins()
pen.setWidth(1)
left_m = content_margins.left()
top_m = content_margins.top()
rect = QtCore.QRect(
content_margins = self.layout().contentsMargins()
rect = self.rect()
left_m = content_margins.left() + pen.width()
top_m = content_margins.top() + pen.width()
new_rect = QtCore.QRect(
left_m,
top_m,
(
self.rect().width()
rect.width()
- (left_m + content_margins.right() + pen.width())
),
(
self.rect().height()
rect.height()
- (top_m + content_margins.bottom() + pen.width())
)
)
painter.drawRect(rect)
painter = QtGui.QPainter(self)
painter.setRenderHint(QtGui.QPainter.Antialiasing)
painter.setPen(pen)
painter.drawRect(new_rect)
class FilesModel(QtGui.QStandardItemModel):

View file

@ -35,9 +35,13 @@ ResetKeySequence = QtGui.QKeySequence(
__all__ = (
"CONTEXT_ID",
"CONTEXT_LABEL",
"VARIANT_TOOLTIP",
"INPUTS_LAYOUT_HSPACING",
"INPUTS_LAYOUT_VSPACING",
"INSTANCE_ID_ROLE",
"SORT_VALUE_ROLE",
"IS_GROUP_ROLE",
@ -47,4 +51,6 @@ __all__ = (
"FAMILY_ROLE",
"GROUP_ROLE",
"CONVERTER_IDENTIFIER_ROLE",
"ResetKeySequence",
)

View file

@ -47,6 +47,7 @@ PLUGIN_ORDER_OFFSET = 0.5
class CardMessageTypes:
standard = None
info = "info"
error = "error"
@ -220,7 +221,12 @@ class PublishReportMaker:
def _add_plugin_data_item(self, plugin):
if plugin in self._stored_plugins:
raise ValueError("Plugin is already stored")
# A plugin would be processed more than once. What can cause it:
# - there is a bug in controller
# - plugin class is imported into multiple files
# - this can happen even with base classes from 'pyblish'
raise ValueError(
"Plugin '{}' is already stored".format(str(plugin)))
self._stored_plugins.append(plugin)
@ -239,6 +245,7 @@ class PublishReportMaker:
label = plugin.label
return {
"id": plugin.id,
"name": plugin.__name__,
"label": label,
"order": plugin.order,
@ -324,7 +331,7 @@ class PublishReportMaker:
"instances": instances_details,
"context": self._extract_context_data(self._current_context),
"crashed_file_paths": crashed_file_paths,
"id": str(uuid.uuid4()),
"id": uuid.uuid4().hex,
"report_version": "1.0.0"
}
@ -342,7 +349,9 @@ class PublishReportMaker:
"label": instance.data.get("label"),
"family": instance.data["family"],
"families": instance.data.get("families") or [],
"exists": exists
"exists": exists,
"creator_identifier": instance.data.get("creator_identifier"),
"instance_id": instance.data.get("instance_id"),
}
def _extract_instance_log_items(self, result):
@ -388,8 +397,11 @@ class PublishReportMaker:
exception = result.get("error")
if exception:
fname, line_no, func, exc = exception.traceback
# Action result does not have 'is_validation_error'
is_validation_error = result.get("is_validation_error", False)
output.append({
"type": "error",
"is_validation_error": is_validation_error,
"msg": str(exception),
"filename": str(fname),
"lineno": str(line_no),
@ -426,13 +438,15 @@ class PublishPluginsProxy:
plugin_id = plugin.id
plugins_by_id[plugin_id] = plugin
action_ids = set()
action_ids = []
action_ids_by_plugin_id[plugin_id] = action_ids
actions = getattr(plugin, "actions", None) or []
for action in actions:
action_id = action.id
action_ids.add(action_id)
if action_id in actions_by_id:
continue
action_ids.append(action_id)
actions_by_id[action_id] = action
self._plugins_by_id = plugins_by_id
@ -461,7 +475,7 @@ class PublishPluginsProxy:
return plugin.id
def get_plugin_action_items(self, plugin_id):
"""Get plugin action items for plugin by it's id.
"""Get plugin action items for plugin by its id.
Args:
plugin_id (str): Publish plugin id.
@ -568,7 +582,7 @@ class ValidationErrorItem:
context_validation,
title,
description,
detail,
detail
):
self.instance_id = instance_id
self.instance_label = instance_label
@ -677,6 +691,8 @@ class PublishValidationErrorsReport:
for title in titles:
grouped_error_items.append({
"id": uuid.uuid4().hex,
"plugin_id": plugin_id,
"plugin_action_items": list(plugin_action_items),
"error_items": error_items_by_title[title],
"title": title
@ -2379,7 +2395,8 @@ class PublisherController(BasePublisherController):
yield MainThreadItem(self.stop_publish)
# Add plugin to publish report
self._publish_report.add_plugin_iter(plugin, self._publish_context)
self._publish_report.add_plugin_iter(
plugin, self._publish_context)
# WARNING This is hack fix for optional plugins
if not self._is_publish_plugin_active(plugin):
@ -2461,14 +2478,14 @@ class PublisherController(BasePublisherController):
plugin, self._publish_context, instance
)
self._publish_report.add_result(result)
exception = result.get("error")
if exception:
has_validation_error = False
if (
isinstance(exception, PublishValidationError)
and not self.publish_has_validated
):
has_validation_error = True
self._add_validation_error(result)
else:
@ -2482,6 +2499,10 @@ class PublisherController(BasePublisherController):
self.publish_error_msg = msg
self.publish_has_crashed = True
result["is_validation_error"] = has_validation_error
self._publish_report.add_result(result)
self._publish_next_process()

View file

@ -163,7 +163,11 @@ class ZoomPlainText(QtWidgets.QPlainTextEdit):
super(ZoomPlainText, self).wheelEvent(event)
return
degrees = float(event.delta()) / 8
if hasattr(event, "angleDelta"):
delta = event.angleDelta().y()
else:
delta = event.delta()
degrees = float(delta) / 8
steps = int(ceil(degrees / 5))
self._scheduled_scalings += steps
if (self._scheduled_scalings * steps < 0):

View file

@ -18,7 +18,7 @@ from .help_widget import (
from .publish_frame import PublishFrame
from .tabs_widget import PublisherTabsWidget
from .overview_widget import OverviewWidget
from .validations_widget import ValidationsWidget
from .report_page import ReportPageWidget
__all__ = (
@ -40,5 +40,5 @@ __all__ = (
"PublisherTabsWidget",
"OverviewWidget",
"ValidationsWidget",
"ReportPageWidget",
)

View file

@ -93,7 +93,7 @@ class BaseGroupWidget(QtWidgets.QWidget):
return self._group
def get_widget_by_item_id(self, item_id):
"""Get instance widget by it's id."""
"""Get instance widget by its id."""
return self._widgets_by_id.get(item_id)
@ -702,8 +702,8 @@ class InstanceCardView(AbstractInstanceView):
for group_name in sorted_group_names:
group_icons = {
idenfier: self._controller.get_creator_icon(idenfier)
for idenfier in identifiers_by_group[group_name]
identifier: self._controller.get_creator_icon(identifier)
for identifier in identifiers_by_group[group_name]
}
if group_name in self._widgets_by_group:
group_widget = self._widgets_by_group[group_name]

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.5 KiB

After

Width:  |  Height:  |  Size: 11 KiB

Before After
Before After

View file

@ -468,45 +468,14 @@ class PublishFrame(QtWidgets.QWidget):
widget.setProperty("state", state)
widget.style().polish(widget)
def _copy_report(self):
logs = self._controller.get_publish_report()
logs_string = json.dumps(logs, indent=4)
mime_data = QtCore.QMimeData()
mime_data.setText(logs_string)
QtWidgets.QApplication.instance().clipboard().setMimeData(
mime_data
)
def _export_report(self):
default_filename = "publish-report-{}".format(
time.strftime("%y%m%d-%H-%M")
)
default_filepath = os.path.join(
os.path.expanduser("~"),
default_filename
)
new_filepath, ext = QtWidgets.QFileDialog.getSaveFileName(
self, "Save report", default_filepath, ".json"
)
if not ext or not new_filepath:
return
logs = self._controller.get_publish_report()
full_path = new_filepath + ext
dir_path = os.path.dirname(full_path)
if not os.path.exists(dir_path):
os.makedirs(dir_path)
with open(full_path, "w") as file_stream:
json.dump(logs, file_stream)
def _on_report_triggered(self, identifier):
if identifier == "export_report":
self._export_report()
self._controller.event_system.emit(
"export_report.request", {}, "publish_frame")
elif identifier == "copy_report":
self._copy_report()
self._controller.event_system.emit(
"copy_report.request", {}, "publish_frame")
elif identifier == "go_to_report":
self.details_page_requested.emit()

File diff suppressed because it is too large Load diff

View file

@ -75,6 +75,7 @@ class ThumbnailPainterWidget(QtWidgets.QWidget):
painter = QtGui.QPainter()
painter.begin(self)
painter.setRenderHint(QtGui.QPainter.Antialiasing)
painter.drawPixmap(0, 0, self._cached_pix)
painter.end()
@ -183,6 +184,18 @@ class ThumbnailPainterWidget(QtWidgets.QWidget):
backgrounded_images.append(new_pix)
return backgrounded_images
def _paint_dash_line(self, painter, rect):
pen = QtGui.QPen()
pen.setWidth(1)
pen.setBrush(QtCore.Qt.darkGray)
pen.setStyle(QtCore.Qt.DashLine)
new_rect = rect.adjusted(1, 1, -1, -1)
painter.setPen(pen)
painter.setBrush(QtCore.Qt.transparent)
# painter.drawRect(rect)
painter.drawRect(new_rect)
def _cache_pix(self):
rect = self.rect()
rect_width = rect.width()
@ -264,13 +277,7 @@ class ThumbnailPainterWidget(QtWidgets.QWidget):
# Draw drop enabled dashes
if used_default_pix:
pen = QtGui.QPen()
pen.setWidth(1)
pen.setBrush(QtCore.Qt.darkGray)
pen.setStyle(QtCore.Qt.DashLine)
final_painter.setPen(pen)
final_painter.setBrush(QtCore.Qt.transparent)
final_painter.drawRect(rect)
self._paint_dash_line(final_painter, rect)
final_painter.end()

View file

@ -1,715 +0,0 @@
# -*- coding: utf-8 -*-
try:
import commonmark
except Exception:
commonmark = None
from qtpy import QtWidgets, QtCore, QtGui
from openpype.tools.utils import BaseClickableFrame, ClickableFrame
from .widgets import (
IconValuePixmapLabel
)
from ..constants import (
INSTANCE_ID_ROLE
)
class ValidationErrorInstanceList(QtWidgets.QListView):
"""List of publish instances that caused a validation error.
Instances are collected per plugin's validation error title.
"""
def __init__(self, *args, **kwargs):
super(ValidationErrorInstanceList, self).__init__(*args, **kwargs)
self.setObjectName("ValidationErrorInstanceList")
self.setHorizontalScrollBarPolicy(QtCore.Qt.ScrollBarAlwaysOff)
self.setSelectionMode(QtWidgets.QAbstractItemView.ExtendedSelection)
def minimumSizeHint(self):
return self.sizeHint()
def sizeHint(self):
result = super(ValidationErrorInstanceList, self).sizeHint()
row_count = self.model().rowCount()
height = 0
if row_count > 0:
height = self.sizeHintForRow(0) * row_count
result.setHeight(height)
return result
class ValidationErrorTitleWidget(QtWidgets.QWidget):
"""Title of validation error.
Widget is used as radio button so requires clickable functionality and
changing style on selection/deselection.
Has toggle button to show/hide instances on which validation error happened
if there is a list (Valdation error may happen on context).
"""
selected = QtCore.Signal(int)
instance_changed = QtCore.Signal(int)
def __init__(self, index, error_info, parent):
super(ValidationErrorTitleWidget, self).__init__(parent)
self._index = index
self._error_info = error_info
self._selected = False
title_frame = ClickableFrame(self)
title_frame.setObjectName("ValidationErrorTitleFrame")
toggle_instance_btn = QtWidgets.QToolButton(title_frame)
toggle_instance_btn.setObjectName("ArrowBtn")
toggle_instance_btn.setArrowType(QtCore.Qt.RightArrow)
toggle_instance_btn.setMaximumWidth(14)
label_widget = QtWidgets.QLabel(error_info["title"], title_frame)
title_frame_layout = QtWidgets.QHBoxLayout(title_frame)
title_frame_layout.addWidget(label_widget, 1)
title_frame_layout.addWidget(toggle_instance_btn, 0)
instances_model = QtGui.QStandardItemModel()
help_text_by_instance_id = {}
items = []
context_validation = False
for error_item in error_info["error_items"]:
context_validation = error_item.context_validation
if context_validation:
toggle_instance_btn.setArrowType(QtCore.Qt.NoArrow)
description = self._prepare_description(error_item)
help_text_by_instance_id[None] = description
# Add fake item to have minimum size hint of view widget
items.append(QtGui.QStandardItem("Context"))
continue
label = error_item.instance_label
item = QtGui.QStandardItem(label)
item.setFlags(
QtCore.Qt.ItemIsEnabled | QtCore.Qt.ItemIsSelectable
)
item.setData(label, QtCore.Qt.ToolTipRole)
item.setData(error_item.instance_id, INSTANCE_ID_ROLE)
items.append(item)
description = self._prepare_description(error_item)
help_text_by_instance_id[error_item.instance_id] = description
if items:
root_item = instances_model.invisibleRootItem()
root_item.appendRows(items)
instances_view = ValidationErrorInstanceList(self)
instances_view.setModel(instances_model)
self.setLayoutDirection(QtCore.Qt.LeftToRight)
view_widget = QtWidgets.QWidget(self)
view_layout = QtWidgets.QHBoxLayout(view_widget)
view_layout.setContentsMargins(0, 0, 0, 0)
view_layout.setSpacing(0)
view_layout.addSpacing(14)
view_layout.addWidget(instances_view, 0)
layout = QtWidgets.QVBoxLayout(self)
layout.setSpacing(0)
layout.setContentsMargins(0, 0, 0, 0)
layout.addWidget(title_frame, 0)
layout.addWidget(view_widget, 0)
view_widget.setVisible(False)
if not context_validation:
toggle_instance_btn.clicked.connect(self._on_toggle_btn_click)
title_frame.clicked.connect(self._mouse_release_callback)
instances_view.selectionModel().selectionChanged.connect(
self._on_seleciton_change
)
self._title_frame = title_frame
self._toggle_instance_btn = toggle_instance_btn
self._view_widget = view_widget
self._instances_model = instances_model
self._instances_view = instances_view
self._context_validation = context_validation
self._help_text_by_instance_id = help_text_by_instance_id
self._expanded = False
def sizeHint(self):
result = super(ValidationErrorTitleWidget, self).sizeHint()
expected_width = max(
self._view_widget.minimumSizeHint().width(),
self._view_widget.sizeHint().width()
)
if expected_width < 200:
expected_width = 200
if result.width() < expected_width:
result.setWidth(expected_width)
return result
def minimumSizeHint(self):
return self.sizeHint()
def _prepare_description(self, error_item):
"""Prepare description text for detail intput.
Args:
error_item (ValidationErrorItem): Item which hold information about
validation error.
Returns:
str: Prepared detailed description.
"""
dsc = error_item.description
detail = error_item.detail
if detail:
dsc += "<br/><br/>{}".format(detail)
description = dsc
if commonmark:
description = commonmark.commonmark(dsc)
return description
def _mouse_release_callback(self):
"""Mark this widget as selected on click."""
self.set_selected(True)
def current_description_text(self):
if self._context_validation:
return self._help_text_by_instance_id[None]
index = self._instances_view.currentIndex()
# TODO make sure instance is selected
if not index.isValid():
index = self._instances_model.index(0, 0)
indence_id = index.data(INSTANCE_ID_ROLE)
return self._help_text_by_instance_id[indence_id]
@property
def is_selected(self):
"""Is widget marked a selected.
Returns:
bool: Item is selected or not.
"""
return self._selected
@property
def index(self):
"""Widget's index set by parent.
Returns:
int: Index of widget.
"""
return self._index
def set_index(self, index):
"""Set index of widget (called by parent).
Args:
int: New index of widget.
"""
self._index = index
def _change_style_property(self, selected):
"""Change style of widget based on selection."""
value = "1" if selected else ""
self._title_frame.setProperty("selected", value)
self._title_frame.style().polish(self._title_frame)
def set_selected(self, selected=None):
"""Change selected state of widget."""
if selected is None:
selected = not self._selected
# Clear instance view selection on deselect
if not selected:
self._instances_view.clearSelection()
# Skip if has same value
if selected == self._selected:
return
self._selected = selected
self._change_style_property(selected)
if selected:
self.selected.emit(self._index)
self._set_expanded(True)
def _on_toggle_btn_click(self):
"""Show/hide instances list."""
self._set_expanded()
def _set_expanded(self, expanded=None):
if expanded is None:
expanded = not self._expanded
elif expanded is self._expanded:
return
if expanded and self._context_validation:
return
self._expanded = expanded
self._view_widget.setVisible(expanded)
if expanded:
self._toggle_instance_btn.setArrowType(QtCore.Qt.DownArrow)
else:
self._toggle_instance_btn.setArrowType(QtCore.Qt.RightArrow)
def _on_seleciton_change(self):
sel_model = self._instances_view.selectionModel()
if sel_model.selectedIndexes():
self.instance_changed.emit(self._index)
class ActionButton(BaseClickableFrame):
"""Plugin's action callback button.
Action may have label or icon or both.
Args:
plugin_action_item (PublishPluginActionItem): Action item that can be
triggered by it's id.
"""
action_clicked = QtCore.Signal(str, str)
def __init__(self, plugin_action_item, parent):
super(ActionButton, self).__init__(parent)
self.setObjectName("ValidationActionButton")
self.plugin_action_item = plugin_action_item
action_label = plugin_action_item.label
action_icon = plugin_action_item.icon
label_widget = QtWidgets.QLabel(action_label, self)
icon_label = None
if action_icon:
icon_label = IconValuePixmapLabel(action_icon, self)
layout = QtWidgets.QHBoxLayout(self)
layout.setContentsMargins(5, 0, 5, 0)
layout.addWidget(label_widget, 1)
if icon_label:
layout.addWidget(icon_label, 0)
self.setSizePolicy(
QtWidgets.QSizePolicy.Minimum,
self.sizePolicy().verticalPolicy()
)
def _mouse_release_callback(self):
self.action_clicked.emit(
self.plugin_action_item.plugin_id,
self.plugin_action_item.action_id
)
class ValidateActionsWidget(QtWidgets.QFrame):
"""Wrapper widget for plugin actions.
Change actions based on selected validation error.
"""
def __init__(self, controller, parent):
super(ValidateActionsWidget, self).__init__(parent)
self.setAttribute(QtCore.Qt.WA_TranslucentBackground)
content_widget = QtWidgets.QWidget(self)
content_layout = QtWidgets.QVBoxLayout(content_widget)
layout = QtWidgets.QHBoxLayout(self)
layout.setContentsMargins(0, 0, 0, 0)
layout.addWidget(content_widget)
self._controller = controller
self._content_widget = content_widget
self._content_layout = content_layout
self._actions_mapping = {}
def clear(self):
"""Remove actions from widget."""
while self._content_layout.count():
item = self._content_layout.takeAt(0)
widget = item.widget()
if widget:
widget.setVisible(False)
widget.deleteLater()
self._actions_mapping = {}
def set_error_item(self, error_item):
"""Set selected plugin and show it's actions.
Clears current actions from widget and recreate them from the plugin.
Args:
Dict[str, Any]: Object holding error items, title and possible
actions to run.
"""
self.clear()
if not error_item:
self.setVisible(False)
return
plugin_action_items = error_item["plugin_action_items"]
for plugin_action_item in plugin_action_items:
if not plugin_action_item.active:
continue
if plugin_action_item.on_filter not in ("failed", "all"):
continue
action_id = plugin_action_item.action_id
self._actions_mapping[action_id] = plugin_action_item
action_btn = ActionButton(plugin_action_item, self._content_widget)
action_btn.action_clicked.connect(self._on_action_click)
self._content_layout.addWidget(action_btn)
if self._content_layout.count() > 0:
self.setVisible(True)
self._content_layout.addStretch(1)
else:
self.setVisible(False)
def _on_action_click(self, plugin_id, action_id):
self._controller.run_action(plugin_id, action_id)
class VerticallScrollArea(QtWidgets.QScrollArea):
"""Scroll area for validation error titles.
The biggest difference is that the scroll area has scroll bar on left side
and resize of content will also resize scrollarea itself.
Resize if deferred by 100ms because at the moment of resize are not yet
propagated sizes and visibility of scroll bars.
"""
def __init__(self, *args, **kwargs):
super(VerticallScrollArea, self).__init__(*args, **kwargs)
self.setHorizontalScrollBarPolicy(QtCore.Qt.ScrollBarAlwaysOff)
self.setVerticalScrollBarPolicy(QtCore.Qt.ScrollBarAsNeeded)
self.setLayoutDirection(QtCore.Qt.RightToLeft)
self.setAttribute(QtCore.Qt.WA_TranslucentBackground)
# Background of scrollbar will be transparent
scrollbar_bg = self.verticalScrollBar().parent()
if scrollbar_bg:
scrollbar_bg.setAttribute(QtCore.Qt.WA_TranslucentBackground)
self.setViewportMargins(0, 0, 0, 0)
self.verticalScrollBar().installEventFilter(self)
# Timer with 100ms offset after changing size
size_changed_timer = QtCore.QTimer()
size_changed_timer.setInterval(100)
size_changed_timer.setSingleShot(True)
size_changed_timer.timeout.connect(self._on_timer_timeout)
self._size_changed_timer = size_changed_timer
def setVerticalScrollBar(self, widget):
old_widget = self.verticalScrollBar()
if old_widget:
old_widget.removeEventFilter(self)
super(VerticallScrollArea, self).setVerticalScrollBar(widget)
if widget:
widget.installEventFilter(self)
def setWidget(self, widget):
old_widget = self.widget()
if old_widget:
old_widget.removeEventFilter(self)
super(VerticallScrollArea, self).setWidget(widget)
if widget:
widget.installEventFilter(self)
def _on_timer_timeout(self):
width = self.widget().width()
if self.verticalScrollBar().isVisible():
width += self.verticalScrollBar().width()
self.setMinimumWidth(width)
def eventFilter(self, obj, event):
if (
event.type() == QtCore.QEvent.Resize
and (obj is self.widget() or obj is self.verticalScrollBar())
):
self._size_changed_timer.start()
return super(VerticallScrollArea, self).eventFilter(obj, event)
class ValidationArtistMessage(QtWidgets.QWidget):
def __init__(self, message, parent):
super(ValidationArtistMessage, self).__init__(parent)
artist_msg_label = QtWidgets.QLabel(message, self)
artist_msg_label.setAlignment(QtCore.Qt.AlignCenter)
main_layout = QtWidgets.QHBoxLayout(self)
main_layout.setContentsMargins(0, 0, 0, 0)
main_layout.addWidget(
artist_msg_label, 1, QtCore.Qt.AlignCenter
)
class ValidationsWidget(QtWidgets.QFrame):
"""Widgets showing validation error.
This widget is shown if validation error/s happened during validation part.
Shows validation error titles with instances on which happened and
validation error detail with possible actions (repair).
titles actions
Error detail
"""
def __init__(self, controller, parent):
super(ValidationsWidget, self).__init__(parent)
# Before publishing
before_publish_widget = ValidationArtistMessage(
"Nothing to report until you run publish", self
)
# After success publishing
publish_started_widget = ValidationArtistMessage(
"So far so good", self
)
# After success publishing
publish_stop_ok_widget = ValidationArtistMessage(
"Publishing finished successfully", self
)
# After failed publishing (not with validation error)
publish_stop_fail_widget = ValidationArtistMessage(
"This is not your fault...", self
)
# Validation errors
validations_widget = QtWidgets.QWidget(self)
content_widget = QtWidgets.QWidget(validations_widget)
errors_scroll = VerticallScrollArea(content_widget)
errors_scroll.setWidgetResizable(True)
errors_widget = QtWidgets.QWidget(errors_scroll)
errors_widget.setAttribute(QtCore.Qt.WA_TranslucentBackground)
errors_layout = QtWidgets.QVBoxLayout(errors_widget)
errors_layout.setContentsMargins(0, 0, 0, 0)
errors_scroll.setWidget(errors_widget)
error_details_frame = QtWidgets.QFrame(content_widget)
error_details_input = QtWidgets.QTextEdit(error_details_frame)
error_details_input.setObjectName("InfoText")
error_details_input.setTextInteractionFlags(
QtCore.Qt.TextBrowserInteraction
)
actions_widget = ValidateActionsWidget(controller, content_widget)
actions_widget.setMinimumWidth(140)
error_details_layout = QtWidgets.QHBoxLayout(error_details_frame)
error_details_layout.addWidget(error_details_input, 1)
error_details_layout.addWidget(actions_widget, 0)
content_layout = QtWidgets.QHBoxLayout(content_widget)
content_layout.setSpacing(0)
content_layout.setContentsMargins(0, 0, 0, 0)
content_layout.addWidget(errors_scroll, 0)
content_layout.addWidget(error_details_frame, 1)
top_label = QtWidgets.QLabel(
"Publish validation report", content_widget
)
top_label.setObjectName("PublishInfoMainLabel")
top_label.setAlignment(QtCore.Qt.AlignCenter)
validation_layout = QtWidgets.QVBoxLayout(validations_widget)
validation_layout.setContentsMargins(0, 0, 0, 0)
validation_layout.addWidget(top_label, 0)
validation_layout.addWidget(content_widget, 1)
main_layout = QtWidgets.QStackedLayout(self)
main_layout.addWidget(before_publish_widget)
main_layout.addWidget(publish_started_widget)
main_layout.addWidget(publish_stop_ok_widget)
main_layout.addWidget(publish_stop_fail_widget)
main_layout.addWidget(validations_widget)
main_layout.setCurrentWidget(before_publish_widget)
controller.event_system.add_callback(
"publish.process.started", self._on_publish_start
)
controller.event_system.add_callback(
"publish.reset.finished", self._on_publish_reset
)
controller.event_system.add_callback(
"publish.process.stopped", self._on_publish_stop
)
self._main_layout = main_layout
self._before_publish_widget = before_publish_widget
self._publish_started_widget = publish_started_widget
self._publish_stop_ok_widget = publish_stop_ok_widget
self._publish_stop_fail_widget = publish_stop_fail_widget
self._validations_widget = validations_widget
self._top_label = top_label
self._errors_widget = errors_widget
self._errors_layout = errors_layout
self._error_details_frame = error_details_frame
self._error_details_input = error_details_input
self._actions_widget = actions_widget
self._title_widgets = {}
self._error_info = {}
self._previous_select = None
self._controller = controller
def clear(self):
"""Delete all dynamic widgets and hide all wrappers."""
self._title_widgets = {}
self._error_info = {}
self._previous_select = None
while self._errors_layout.count():
item = self._errors_layout.takeAt(0)
widget = item.widget()
if widget:
widget.deleteLater()
self._top_label.setVisible(False)
self._error_details_frame.setVisible(False)
self._errors_widget.setVisible(False)
self._actions_widget.setVisible(False)
def _set_errors(self, validation_error_report):
"""Set errors into context and created titles.
Args:
validation_error_report (PublishValidationErrorsReport): Report
with information about validation errors and publish plugin
actions.
"""
self.clear()
if not validation_error_report:
return
self._top_label.setVisible(True)
self._error_details_frame.setVisible(True)
self._errors_widget.setVisible(True)
grouped_error_items = validation_error_report.group_items_by_title()
for idx, error_info in enumerate(grouped_error_items):
widget = ValidationErrorTitleWidget(idx, error_info, self)
widget.selected.connect(self._on_select)
widget.instance_changed.connect(self._on_instance_change)
self._errors_layout.addWidget(widget)
self._title_widgets[idx] = widget
self._error_info[idx] = error_info
self._errors_layout.addStretch(1)
if self._title_widgets:
self._title_widgets[0].set_selected(True)
self.updateGeometry()
def _set_current_widget(self, widget):
self._main_layout.setCurrentWidget(widget)
def _on_publish_start(self):
self._set_current_widget(self._publish_started_widget)
def _on_publish_reset(self):
self._set_current_widget(self._before_publish_widget)
def _on_publish_stop(self):
if self._controller.publish_has_crashed:
self._set_current_widget(self._publish_stop_fail_widget)
return
if self._controller.publish_has_validation_errors:
validation_errors = self._controller.get_validation_errors()
self._set_current_widget(self._validations_widget)
self._set_errors(validation_errors)
return
if self._controller.publish_has_finished:
self._set_current_widget(self._publish_stop_ok_widget)
return
self._set_current_widget(self._publish_started_widget)
def _on_select(self, index):
if self._previous_select:
if self._previous_select.index == index:
return
self._previous_select.set_selected(False)
self._previous_select = self._title_widgets[index]
error_item = self._error_info[index]
self._actions_widget.set_error_item(error_item)
self._update_description()
def _on_instance_change(self, index):
if self._previous_select and self._previous_select.index != index:
self._title_widgets[index].set_selected(True)
else:
self._update_description()
def _update_description(self):
description = self._previous_select.current_description_text()
if commonmark:
html = commonmark.commonmark(description)
self._error_details_input.setHtml(html)
elif hasattr(self._error_details_input, "setMarkdown"):
self._error_details_input.setMarkdown(description)
else:
self._error_details_input.setText(description)

View file

@ -40,6 +40,41 @@ from ..constants import (
INPUTS_LAYOUT_VSPACING,
)
FA_PREFIXES = ["", "fa.", "fa5.", "fa5b.", "fa5s.", "ei.", "mdi."]
def parse_icon_def(
icon_def, default_width=None, default_height=None, color=None
):
if not icon_def:
return None
if isinstance(icon_def, QtGui.QPixmap):
return icon_def
color = color or "white"
default_width = default_width or 512
default_height = default_height or 512
if isinstance(icon_def, QtGui.QIcon):
return icon_def.pixmap(default_width, default_height)
try:
if os.path.exists(icon_def):
return QtGui.QPixmap(icon_def)
except Exception:
# TODO logging
pass
for prefix in FA_PREFIXES:
try:
icon_name = "{}{}".format(prefix, icon_def)
icon = qtawesome.icon(icon_name, color=color)
return icon.pixmap(default_width, default_height)
except Exception:
# TODO logging
continue
class PublishPixmapLabel(PixmapLabel):
def _get_pix_size(self):
@ -54,7 +89,6 @@ class IconValuePixmapLabel(PublishPixmapLabel):
Handle icon parsing from creators/instances. Using of QAwesome module
of path to images.
"""
fa_prefixes = ["", "fa."]
default_size = 200
def __init__(self, icon_def, parent):
@ -77,31 +111,9 @@ class IconValuePixmapLabel(PublishPixmapLabel):
return pix
def _parse_icon_def(self, icon_def):
if not icon_def:
return self._default_pixmap()
if isinstance(icon_def, QtGui.QPixmap):
return icon_def
if isinstance(icon_def, QtGui.QIcon):
return icon_def.pixmap(self.default_size, self.default_size)
try:
if os.path.exists(icon_def):
return QtGui.QPixmap(icon_def)
except Exception:
# TODO logging
pass
for prefix in self.fa_prefixes:
try:
icon_name = "{}{}".format(prefix, icon_def)
icon = qtawesome.icon(icon_name, color="white")
return icon.pixmap(self.default_size, self.default_size)
except Exception:
# TODO logging
continue
icon = parse_icon_def(icon_def, self.default_size, self.default_size)
if icon:
return icon
return self._default_pixmap()
@ -692,6 +704,7 @@ class TasksCombobox(QtWidgets.QComboBox):
style.drawControl(
QtWidgets.QStyle.CE_ComboBoxLabel, opt, painter, self
)
painter.end()
def is_valid(self):
"""Are all selected items valid."""

View file

@ -1,3 +1,6 @@
import os
import json
import time
import collections
import copy
from qtpy import QtWidgets, QtCore, QtGui
@ -15,10 +18,11 @@ from openpype.tools.utils import (
from .constants import ResetKeySequence
from .publish_report_viewer import PublishReportViewerWidget
from .control import CardMessageTypes
from .control_qt import QtPublisherController
from .widgets import (
OverviewWidget,
ValidationsWidget,
ReportPageWidget,
PublishFrame,
PublisherTabsWidget,
@ -182,7 +186,7 @@ class PublisherWindow(QtWidgets.QDialog):
controller, content_stacked_widget
)
report_widget = ValidationsWidget(controller, parent)
report_widget = ReportPageWidget(controller, parent)
# Details - Publish details
publish_details_widget = PublishReportViewerWidget(
@ -313,6 +317,13 @@ class PublisherWindow(QtWidgets.QDialog):
controller.event_system.add_callback(
"convertors.find.failed", self._on_convertor_error
)
controller.event_system.add_callback(
"export_report.request", self._export_report
)
controller.event_system.add_callback(
"copy_report.request", self._copy_report
)
# Store extra header widget for TrayPublisher
# - can be used to add additional widgets to header between context
@ -825,6 +836,9 @@ class PublisherWindow(QtWidgets.QDialog):
self._validate_btn.setEnabled(validate_enabled)
self._publish_btn.setEnabled(publish_enabled)
if not publish_enabled:
self._publish_frame.set_shrunk_state(True)
self._update_publish_details_widget()
def _validate_create_instances(self):
@ -941,6 +955,46 @@ class PublisherWindow(QtWidgets.QDialog):
under_mouse = widget_x < global_pos.x()
self._create_overlay_button.set_under_mouse(under_mouse)
def _copy_report(self):
logs = self._controller.get_publish_report()
logs_string = json.dumps(logs, indent=4)
mime_data = QtCore.QMimeData()
mime_data.setText(logs_string)
QtWidgets.QApplication.instance().clipboard().setMimeData(
mime_data
)
self._controller.emit_card_message(
"Report added to clipboard",
CardMessageTypes.info)
def _export_report(self):
default_filename = "publish-report-{}".format(
time.strftime("%y%m%d-%H-%M")
)
default_filepath = os.path.join(
os.path.expanduser("~"),
default_filename
)
new_filepath, ext = QtWidgets.QFileDialog.getSaveFileName(
self, "Save report", default_filepath, ".json"
)
if not ext or not new_filepath:
return
logs = self._controller.get_publish_report()
full_path = new_filepath + ext
dir_path = os.path.dirname(full_path)
if not os.path.exists(dir_path):
os.makedirs(dir_path)
with open(full_path, "w") as file_stream:
json.dump(logs, file_stream)
self._controller.emit_card_message(
"Report saved",
CardMessageTypes.info)
class ErrorsMessageBox(ErrorMessageBox):
def __init__(self, error_title, failed_info, message_start, parent):

View file

@ -1,13 +1,16 @@
from .layouts import FlowLayout
from .widgets import (
FocusSpinBox,
FocusDoubleSpinBox,
ComboBox,
CustomTextComboBox,
PlaceholderLineEdit,
ExpandingTextEdit,
BaseClickableFrame,
ClickableFrame,
ClickableLabel,
ExpandBtn,
ClassicExpandBtn,
PixmapLabel,
IconButton,
PixmapButton,
@ -37,15 +40,19 @@ from .overlay_messages import (
__all__ = (
"FlowLayout",
"FocusSpinBox",
"FocusDoubleSpinBox",
"ComboBox",
"CustomTextComboBox",
"PlaceholderLineEdit",
"ExpandingTextEdit",
"BaseClickableFrame",
"ClickableFrame",
"ClickableLabel",
"ExpandBtn",
"ClassicExpandBtn",
"PixmapLabel",
"IconButton",
"PixmapButton",

View file

@ -0,0 +1,150 @@
from qtpy import QtWidgets, QtCore
class FlowLayout(QtWidgets.QLayout):
"""Layout that organize widgets by minimum size into a flow layout.
Layout is putting widget from left to right and top to bottom. When widget
can't fit a row it is added to next line. Minimum size matches widget with
biggest 'sizeHint' width and height using calculated geometry.
Content margins are part of calculations. It is possible to define
horizontal and vertical spacing.
Layout does not support stretch and spacing items.
Todos:
Unified width concept -> use width of largest item so all of them are
same. This could allow to have minimum columns option too.
"""
def __init__(self, parent=None):
super(FlowLayout, self).__init__(parent)
# spaces between each item
self._horizontal_spacing = 5
self._vertical_spacing = 5
self._items = []
def __del__(self):
while self.count():
self.takeAt(0, False)
def isEmpty(self):
for item in self._items:
if not item.isEmpty():
return False
return True
def setSpacing(self, spacing):
self._horizontal_spacing = spacing
self._vertical_spacing = spacing
self.invalidate()
def setHorizontalSpacing(self, spacing):
self._horizontal_spacing = spacing
self.invalidate()
def setVerticalSpacing(self, spacing):
self._vertical_spacing = spacing
self.invalidate()
def addItem(self, item):
self._items.append(item)
self.invalidate()
def count(self):
return len(self._items)
def itemAt(self, index):
if 0 <= index < len(self._items):
return self._items[index]
return None
def takeAt(self, index, invalidate=True):
if 0 <= index < len(self._items):
item = self._items.pop(index)
if invalidate:
self.invalidate()
return item
return None
def expandingDirections(self):
return QtCore.Qt.Orientations(QtCore.Qt.Vertical)
def hasHeightForWidth(self):
return True
def heightForWidth(self, width):
return self._setup_geometry(QtCore.QRect(0, 0, width, 0), True)
def setGeometry(self, rect):
super(FlowLayout, self).setGeometry(rect)
self._setup_geometry(rect)
def sizeHint(self):
return self.minimumSize()
def minimumSize(self):
size = QtCore.QSize(0, 0)
for item in self._items:
widget = item.widget()
if widget is not None:
parent = widget.parent()
if not widget.isVisibleTo(parent):
continue
size = size.expandedTo(item.minimumSize())
if size.width() < 1 or size.height() < 1:
return size
l_margin, t_margin, r_margin, b_margin = self.getContentsMargins()
size += QtCore.QSize(l_margin + r_margin, t_margin + b_margin)
return size
def _setup_geometry(self, rect, only_calculate=False):
h_spacing = self._horizontal_spacing
v_spacing = self._vertical_spacing
l_margin, t_margin, r_margin, b_margin = self.getContentsMargins()
left_x = rect.x() + l_margin
top_y = rect.y() + t_margin
pos_x = left_x
pos_y = top_y
row_height = 0
for item in self._items:
item_hint = item.sizeHint()
item_width = item_hint.width()
item_height = item_hint.height()
if item_width < 1 or item_height < 1:
continue
end_x = pos_x + item_width
wrap = (
row_height > 0
and (
end_x > rect.right()
or (end_x + r_margin) > rect.right()
)
)
if not wrap:
next_pos_x = end_x + h_spacing
else:
pos_x = left_x
pos_y += row_height + v_spacing
next_pos_x = pos_x + item_width + h_spacing
row_height = 0
if not only_calculate:
item.setGeometry(
QtCore.QRect(pos_x, pos_y, item_width, item_height)
)
pos_x = next_pos_x
row_height = max(row_height, item_height)
height = (pos_y - top_y) + row_height
if height > 0:
height += b_margin
return height

View file

@ -101,6 +101,46 @@ class PlaceholderLineEdit(QtWidgets.QLineEdit):
self.setPalette(filter_palette)
class ExpandingTextEdit(QtWidgets.QTextEdit):
"""QTextEdit which does not have sroll area but expands height."""
def __init__(self, parent=None):
super(ExpandingTextEdit, self).__init__(parent)
size_policy = self.sizePolicy()
size_policy.setHeightForWidth(True)
size_policy.setVerticalPolicy(QtWidgets.QSizePolicy.Preferred)
self.setSizePolicy(size_policy)
self.setHorizontalScrollBarPolicy(QtCore.Qt.ScrollBarAlwaysOff)
self.setVerticalScrollBarPolicy(QtCore.Qt.ScrollBarAlwaysOff)
doc = self.document()
doc.contentsChanged.connect(self._on_doc_change)
def _on_doc_change(self):
self.updateGeometry()
def hasHeightForWidth(self):
return True
def heightForWidth(self, width):
margins = self.contentsMargins()
document_width = 0
if width >= margins.left() + margins.right():
document_width = width - margins.left() - margins.right()
document = self.document().clone()
document.setTextWidth(document_width)
return margins.top() + document.size().height() + margins.bottom()
def sizeHint(self):
width = super(ExpandingTextEdit, self).sizeHint().width()
return QtCore.QSize(width, self.heightForWidth(width))
class BaseClickableFrame(QtWidgets.QFrame):
"""Widget that catch left mouse click and can trigger a callback.
@ -161,19 +201,34 @@ class ClickableLabel(QtWidgets.QLabel):
class ExpandBtnLabel(QtWidgets.QLabel):
"""Label showing expand icon meant for ExpandBtn."""
state_changed = QtCore.Signal()
def __init__(self, parent):
super(ExpandBtnLabel, self).__init__(parent)
self._source_collapsed_pix = QtGui.QPixmap(
get_style_image_path("branch_closed")
)
self._source_expanded_pix = QtGui.QPixmap(
get_style_image_path("branch_open")
)
self._source_collapsed_pix = self._create_collapsed_pixmap()
self._source_expanded_pix = self._create_expanded_pixmap()
self._current_image = self._source_collapsed_pix
self._collapsed = True
def set_collapsed(self, collapsed):
def _create_collapsed_pixmap(self):
return QtGui.QPixmap(
get_style_image_path("branch_closed")
)
def _create_expanded_pixmap(self):
return QtGui.QPixmap(
get_style_image_path("branch_open")
)
@property
def collapsed(self):
return self._collapsed
def set_collapsed(self, collapsed=None):
if collapsed is None:
collapsed = not self._collapsed
if self._collapsed == collapsed:
return
self._collapsed = collapsed
@ -182,6 +237,7 @@ class ExpandBtnLabel(QtWidgets.QLabel):
else:
self._current_image = self._source_expanded_pix
self._set_resized_pix()
self.state_changed.emit()
def resizeEvent(self, event):
self._set_resized_pix()
@ -203,21 +259,55 @@ class ExpandBtnLabel(QtWidgets.QLabel):
class ExpandBtn(ClickableFrame):
state_changed = QtCore.Signal()
def __init__(self, parent=None):
super(ExpandBtn, self).__init__(parent)
pixmap_label = ExpandBtnLabel(self)
pixmap_label = self._create_pix_widget(self)
layout = QtWidgets.QHBoxLayout(self)
layout.setContentsMargins(0, 0, 0, 0)
layout.addWidget(pixmap_label)
pixmap_label.state_changed.connect(self.state_changed)
self._pixmap_label = pixmap_label
def set_collapsed(self, collapsed):
def _create_pix_widget(self, parent=None):
if parent is None:
parent = self
return ExpandBtnLabel(parent)
@property
def collapsed(self):
return self._pixmap_label.collapsed
def set_collapsed(self, collapsed=None):
self._pixmap_label.set_collapsed(collapsed)
class ClassicExpandBtnLabel(ExpandBtnLabel):
def _create_collapsed_pixmap(self):
return QtGui.QPixmap(
get_style_image_path("right_arrow")
)
def _create_expanded_pixmap(self):
return QtGui.QPixmap(
get_style_image_path("down_arrow")
)
class ClassicExpandBtn(ExpandBtn):
"""Same as 'ExpandBtn' but with arrow images."""
def _create_pix_widget(self, parent=None):
if parent is None:
parent = self
return ClassicExpandBtnLabel(parent)
class ImageButton(QtWidgets.QPushButton):
"""PushButton with icon and size of font.

View file

@ -15,18 +15,18 @@ sidebar_label: AfterEffects
## Setup
To install the extension, download, install [Anastasyi's Extension Manager](https://install.anastasiy.com/). Open Anastasyi's Extension Manager and select AfterEffects in menu. Then go to `{path to pype}hosts/aftereffects/api/extension.zxp`.
To install the extension, download, install [Anastasyi's Extension Manager](https://install.anastasiy.com/). Open Anastasyi's Extension Manager and select AfterEffects in menu. Then go to `{path to pype}hosts/aftereffects/api/extension.zxp`.
Drag extension.zxp and drop it to Anastasyi's Extension Manager. The extension will install itself.
Drag extension.zxp and drop it to Anastasyi's Extension Manager. The extension will install itself.
## Implemented functionality
AfterEffects implementation currently allows you to import and add various media to composition (image plates, renders, audio files, video files etc.)
and send prepared composition for rendering to Deadline or render locally.
and send prepared composition for rendering to Deadline or render locally.
## Usage
When you launch AfterEffects you will be met with the Workfiles app. If don't
When you launch AfterEffects you will be met with the Workfiles app. If don't
have any previous workfiles, you can just close this window.
Workfiles tools takes care of saving your .AEP files in the correct location and under
@ -34,7 +34,7 @@ a correct name. You should use it instead of standard file saving dialog.
In AfterEffects you'll find the tools in the `OpenPype` extension:
![Extension](assets/photoshop_extension.png) <!-- same menu as in PS -->
![Extension](assets/photoshop_extension.png)
You can show the extension panel by going to `Window` > `Extensions` > `OpenPype`.
@ -58,6 +58,9 @@ Name of publishable instance (eg. subset name) could be configured with a templa
Trash icon under the list of instances allows to delete any selected `render` instance.
Frame information (frame start, duration, fps) and resolution (width and height) is applied to selected composition from Asset Management System (Ftrack or DB) automatically!
(Eg. number of rendered frames is controlled by settings inserted from supervisor. Artist can override this by disabling validation only in special cases.)
Workfile instance will be automatically recreated though. If you do not want to publish it, use pill toggle on the instance item.
If you would like to modify publishable instance, click on `Publish` tab at the top. This would allow you to change name of publishable
@ -67,7 +70,7 @@ Publisher allows publishing into different context, just click on any instance,
#### RenderQueue
AE's Render Queue is required for publishing locally or on a farm. Artist needs to configure expected result format (extension, resolution) in the Render Queue in an Output module.
AE's Render Queue is required for publishing locally or on a farm. Artist needs to configure expected result format (extension, resolution) in the Render Queue in an Output module.
Currently its expected to have only single render item per composition in the Render Queue.
@ -151,3 +154,25 @@ You can switch to a previous version of the image or update to the latest.
![Loader](assets/photoshop_manage_switch.gif)
![Loader](assets/photoshop_manage_update.gif)
### Setting section
Composition properties should be controlled by state in Asset Management System (Ftrack etc). Extension provides couple of buttons to trigger this propagation.
#### Set Resolution
Set width and height from AMS to composition.
#### Set Frame Range
Start frame and duration in workarea is set according to the settings in AMS. Handles are incorporated (not inclusive).
It is expected that composition(s) is selected first before pushing this button!
#### Apply All Settings
Both previous settings are triggered at same time.
### Experimental tools
Currently empty. Could contain special tools available only for specific hosts for early access testing.

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

View file

@ -506,6 +506,67 @@ or the scene file was copy pasted from different context.
#### *Known errors*
When there is a known error that can't be fixed by the user (e.g. can't connect to deadline service, etc.) `KnownPublishError` should be raised. The only difference is that its message is shown in UI to the artist otherwise a neutral message without context is shown.
### Plugins
Plugin is a single processing unit that can work with publish context and instances.
#### Plugin types
There are 2 types of plugins - `InstancePlugin` and `ContextPlugin`. Be aware that inheritance of plugin from `InstancePlugin` or `ContextPlugin` actually does not affect if plugin is instance or context plugin, that is affected by argument name in `process` method.
```python
import pyblish.api
# Context plugin
class MyContextPlugin(pyblish.api.ContextPlugin):
def process(self, context):
...
# Instance plugin
class MyInstancePlugin(pyblish.api.InstancePlugin):
def process(self, instance):
...
# Still an instance plugin
class MyOtherInstancePlugin(pyblish.api.ContextPlugin):
def process(self, instance):
...
```
#### Plugin filtering
By pyblish logic, plugins have predefined filtering class attributes `hosts`, `targets` and `families`. Filter by `hosts` and `targets` are filters that are applied for current publishing process. Both filters are registered in `pyblish` module, `hosts` filtering may not match OpenPype host name (e.g. farm publishing uses `shell` in pyblish). Filter `families` works only on instance plugins and is dynamic during publish process by changing families of an instance.
All filters are list of a strings `families = ["image"]`. Empty list is invalid filter and plugin will be skipped, to allow plugin for all values use a start `families = ["*"]`. For more detailed filtering options check [pyblish documentation](https://api.pyblish.com/pluginsystem).
Each plugin must have order, there are 4 order milestones - Collect, Validate, Extract, Integration. Any plugin below collection order won't be processed. for more details check [pyblish documentation](https://api.pyblish.com/ordering).
#### Plugin settings
Pyblish plugins may have settings. There are 2 ways how settings are applied, first is automated, and it's logic is based on function `filter_pyblish_plugins` in `./openpype/pipeline/publish/lib.py`, second is explicit by implementing class method `apply_settings` on a plugin.
Automated logic is expecting specific structure of project settings `project_settings[{category}]["plugins"]["publish"][{plugin class name}]`. The category is a key in root of project settings. There are currently 3 ways how the category key is received.
1. Use `settings_category` class attribute value from plugin. If `settings_category` is not `None` there is not any fallback to other way.
2. Use currently registered pyblish host. This will be probably deprecated soon.
3. Use 3rd folder name from a plugin filepath. From path `./maya/plugins/publish/collect_render.py` is used `maya` as the key.
For any other use-case is recommended to use explicit approach by implementing `apply_settings` method. Must use `@classmethod` decorator and expect arguments for project settings and system settings. We're planning to support single argument with only project settings.
```python
import pyblish.api
class MyPlugin(pyblish.api.InstancePlugin):
profiles = []
@classmethod
def apply_settings(cls, project_settings, system_settings):
cls.profiles = (
project_settings
["addon"]
["plugins"]
["publish"]
["vfx_profiles"]
)
```
### Plugin extension
Publish plugins can be extended by additional logic when inheriting from `OpenPypePyblishPluginMixin` which can be used as mixin (additional inheritance of class). Publish plugins that inherit from this mixin can define attributes that will be shown in **CreatedInstance**. One of the most important usages is to be able turn on/off optional plugins.
@ -596,4 +657,4 @@ Publish attributes work the same way as create attributes but the source of attr
### Create dialog
![Publisher UI - Create dialog](assets/publisher_create_dialog.png)
Create dialog is used by artist to create new instances in a context. The context selection can be enabled/disabled by changing `create_allow_context_change` on [creator plugin](#creator). In the middle part the artist selects what will be created and what variant it is. On the right side is information about the selected creator and its pre-create attributes. There is also a question mark button which extends the window and displays more detailed information about the creator.
Create dialog is used by artist to create new instances in a context. The context selection can be enabled/disabled by changing `create_allow_context_change` on [creator plugin](#creator). In the middle part the artist selects what will be created and what variant it is. On the right side is information about the selected creator and its pre-create attributes. There is also a question mark button which extends the window and displays more detailed information about the creator.