Merge pull request #2769 from BigRoy/fusion_integration_v2

Fusion: Tweak Fusion integration
This commit is contained in:
Jakub Trllo 2022-09-21 19:08:18 +02:00 committed by GitHub
commit 70c24babe6
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
31 changed files with 690 additions and 563 deletions

View file

@ -19,6 +19,7 @@ class AddLastWorkfileToLaunchArgs(PreLaunchHook):
"hiero",
"houdini",
"nukestudio",
"fusion",
"blender",
"photoshop",
"tvpaint",

View file

@ -19,5 +19,14 @@ class FusionAddon(OpenPypeModule, IHostAddon):
os.path.join(FUSION_HOST_DIR, "hooks")
]
def add_implementation_envs(self, env, _app):
# Set default values if are not already set via settings
defaults = {
"OPENPYPE_LOG_NO_COLORS": "Yes"
}
for key, value in defaults.items():
if not env.get(key):
env[key] = value
def get_workfile_extensions(self):
return [".comp"]

View file

@ -5,10 +5,7 @@ from .pipeline import (
ls,
imprint_container,
parse_container,
get_current_comp,
comp_lock_and_undo_chunk
parse_container
)
from .workio import (
@ -22,8 +19,10 @@ from .workio import (
from .lib import (
maintained_selection,
get_additional_data,
update_frame_range
update_frame_range,
set_asset_framerange,
get_current_comp,
comp_lock_and_undo_chunk
)
from .menu import launch_openpype_menu
@ -38,9 +37,6 @@ __all__ = [
"imprint_container",
"parse_container",
"get_current_comp",
"comp_lock_and_undo_chunk",
# workio
"open_file",
"save_file",
@ -51,8 +47,10 @@ __all__ = [
# lib
"maintained_selection",
"get_additional_data",
"update_frame_range",
"set_asset_framerange",
"get_current_comp",
"comp_lock_and_undo_chunk",
# menu
"launch_openpype_menu",

View file

@ -5,6 +5,7 @@ import contextlib
from Qt import QtGui
from openpype.lib import Logger
from openpype.client import (
get_asset_by_name,
get_subset_by_name,
@ -17,13 +18,14 @@ from openpype.pipeline import (
switch_container,
legacy_io,
)
from .pipeline import get_current_comp, comp_lock_and_undo_chunk
from openpype.pipeline.context_tools import get_current_project_asset
self = sys.modules[__name__]
self._project = None
def update_frame_range(start, end, comp=None, set_render_range=True):
def update_frame_range(start, end, comp=None, set_render_range=True,
handle_start=0, handle_end=0):
"""Set Fusion comp's start and end frame range
Args:
@ -32,6 +34,8 @@ def update_frame_range(start, end, comp=None, set_render_range=True):
comp (object, Optional): comp object from fusion
set_render_range (bool, Optional): When True this will also set the
composition's render start and end frame.
handle_start (float, int, Optional): frame handles before start frame
handle_end (float, int, Optional): frame handles after end frame
Returns:
None
@ -41,11 +45,16 @@ def update_frame_range(start, end, comp=None, set_render_range=True):
if not comp:
comp = get_current_comp()
# Convert any potential none type to zero
handle_start = handle_start or 0
handle_end = handle_end or 0
attrs = {
"COMPN_GlobalStart": start,
"COMPN_GlobalEnd": end
"COMPN_GlobalStart": start - handle_start,
"COMPN_GlobalEnd": end + handle_end
}
# set frame range
if set_render_range:
attrs.update({
"COMPN_RenderStart": start,
@ -56,24 +65,116 @@ def update_frame_range(start, end, comp=None, set_render_range=True):
comp.SetAttrs(attrs)
def get_additional_data(container):
"""Get Fusion related data for the container
def set_asset_framerange():
"""Set Comp's frame range based on current asset"""
asset_doc = get_current_project_asset()
start = asset_doc["data"]["frameStart"]
end = asset_doc["data"]["frameEnd"]
handle_start = asset_doc["data"]["handleStart"]
handle_end = asset_doc["data"]["handleEnd"]
update_frame_range(start, end, set_render_range=True,
handle_start=handle_start,
handle_end=handle_end)
Args:
container(dict): the container found by the ls() function
Returns:
dict
def set_asset_resolution():
"""Set Comp's resolution width x height default based on current asset"""
asset_doc = get_current_project_asset()
width = asset_doc["data"]["resolutionWidth"]
height = asset_doc["data"]["resolutionHeight"]
comp = get_current_comp()
print("Setting comp frame format resolution to {}x{}".format(width,
height))
comp.SetPrefs({
"Comp.FrameFormat.Width": width,
"Comp.FrameFormat.Height": height,
})
def validate_comp_prefs(comp=None):
"""Validate current comp defaults with asset settings.
Validates fps, resolutionWidth, resolutionHeight, aspectRatio.
This does *not* validate frameStart, frameEnd, handleStart and handleEnd.
"""
tool = container["_tool"]
tile_color = tool.TileColor
if tile_color is None:
return {}
if comp is None:
comp = get_current_comp()
return {"color": QtGui.QColor.fromRgbF(tile_color["R"],
tile_color["G"],
tile_color["B"])}
log = Logger.get_logger("validate_comp_prefs")
fields = [
"name",
"data.fps",
"data.resolutionWidth",
"data.resolutionHeight",
"data.pixelAspect"
]
asset_doc = get_current_project_asset(fields=fields)
asset_data = asset_doc["data"]
comp_frame_format_prefs = comp.GetPrefs("Comp.FrameFormat")
# Pixel aspect ratio in Fusion is set as AspectX and AspectY so we convert
# the data to something that is more sensible to Fusion
asset_data["pixelAspectX"] = asset_data.pop("pixelAspect")
asset_data["pixelAspectY"] = 1.0
validations = [
("fps", "Rate", "FPS"),
("resolutionWidth", "Width", "Resolution Width"),
("resolutionHeight", "Height", "Resolution Height"),
("pixelAspectX", "AspectX", "Pixel Aspect Ratio X"),
("pixelAspectY", "AspectY", "Pixel Aspect Ratio Y")
]
invalid = []
for key, comp_key, label in validations:
asset_value = asset_data[key]
comp_value = comp_frame_format_prefs.get(comp_key)
if asset_value != comp_value:
# todo: Actually show dialog to user instead of just logging
log.warning(
"Comp {pref} {value} does not match asset "
"'{asset_name}' {pref} {asset_value}".format(
pref=label,
value=comp_value,
asset_name=asset_doc["name"],
asset_value=asset_value)
)
invalid_msg = "{} {} should be {}".format(label,
comp_value,
asset_value)
invalid.append(invalid_msg)
if invalid:
def _on_repair():
attributes = dict()
for key, comp_key, _label in validations:
value = asset_data[key]
comp_key_full = "Comp.FrameFormat.{}".format(comp_key)
attributes[comp_key_full] = value
comp.SetPrefs(attributes)
from . import menu
from openpype.widgets import popup
from openpype.style import load_stylesheet
dialog = popup.Popup(parent=menu.menu)
dialog.setWindowTitle("Fusion comp has invalid configuration")
msg = "Comp preferences mismatches '{}'".format(asset_doc["name"])
msg += "\n" + "\n".join(invalid)
dialog.setMessage(msg)
dialog.setButtonText("Repair")
dialog.on_clicked.connect(_on_repair)
dialog.show()
dialog.raise_()
dialog.activateWindow()
dialog.setStyleSheet(load_stylesheet())
def switch_item(container,
@ -195,3 +296,21 @@ def get_frame_path(path):
padding = 4 # default Fusion padding
return filename, padding, ext
def get_current_comp():
"""Hack to get current comp in this session"""
fusion = getattr(sys.modules["__main__"], "fusion", None)
return fusion.CurrentComp if fusion else None
@contextlib.contextmanager
def comp_lock_and_undo_chunk(comp, undo_queue_name="Script CMD"):
"""Lock comp and open an undo chunk during the context"""
try:
comp.Lock()
comp.StartUndo(undo_queue_name)
yield
finally:
comp.Unlock()
comp.EndUndo()

View file

@ -1,43 +1,25 @@
import os
import sys
from Qt import QtWidgets, QtCore
from Qt import QtWidgets, QtCore, QtGui
from openpype import style
from openpype.tools.utils import host_tools
from openpype.style import load_stylesheet
from openpype.lib import register_event_callback
from openpype.hosts.fusion.scripts import (
set_rendermode,
duplicate_with_inputs
)
from openpype.hosts.fusion.api.lib import (
set_asset_framerange,
set_asset_resolution
)
from openpype.pipeline import legacy_io
from openpype.resources import get_openpype_icon_filepath
from .pulse import FusionPulse
def load_stylesheet():
path = os.path.join(os.path.dirname(__file__), "menu_style.qss")
if not os.path.exists(path):
print("Unable to load stylesheet, file not found in resources")
return ""
with open(path, "r") as file_stream:
stylesheet = file_stream.read()
return stylesheet
class Spacer(QtWidgets.QWidget):
def __init__(self, height, *args, **kwargs):
super(Spacer, self).__init__(*args, **kwargs)
self.setFixedHeight(height)
real_spacer = QtWidgets.QWidget(self)
real_spacer.setObjectName("Spacer")
real_spacer.setFixedHeight(height)
layout = QtWidgets.QVBoxLayout(self)
layout.setContentsMargins(0, 0, 0, 0)
layout.addWidget(real_spacer)
self.setLayout(layout)
self = sys.modules[__name__]
self.menu = None
class OpenPypeMenu(QtWidgets.QWidget):
@ -46,15 +28,29 @@ class OpenPypeMenu(QtWidgets.QWidget):
self.setObjectName("OpenPypeMenu")
icon_path = get_openpype_icon_filepath()
icon = QtGui.QIcon(icon_path)
self.setWindowIcon(icon)
self.setWindowFlags(
QtCore.Qt.Window
| QtCore.Qt.CustomizeWindowHint
| QtCore.Qt.WindowTitleHint
| QtCore.Qt.WindowMinimizeButtonHint
| QtCore.Qt.WindowCloseButtonHint
| QtCore.Qt.WindowStaysOnTopHint
)
self.render_mode_widget = None
self.setWindowTitle("OpenPype")
asset_label = QtWidgets.QLabel("Context", self)
asset_label.setStyleSheet("""QLabel {
font-size: 14px;
font-weight: 600;
color: #5f9fb8;
}""")
asset_label.setAlignment(QtCore.Qt.AlignHCenter)
workfiles_btn = QtWidgets.QPushButton("Workfiles...", self)
create_btn = QtWidgets.QPushButton("Create...", self)
publish_btn = QtWidgets.QPushButton("Publish...", self)
@ -62,77 +58,107 @@ class OpenPypeMenu(QtWidgets.QWidget):
manager_btn = QtWidgets.QPushButton("Manage...", self)
libload_btn = QtWidgets.QPushButton("Library...", self)
rendermode_btn = QtWidgets.QPushButton("Set render mode...", self)
set_framerange_btn = QtWidgets.QPushButton("Set Frame Range", self)
set_resolution_btn = QtWidgets.QPushButton("Set Resolution", self)
duplicate_with_inputs_btn = QtWidgets.QPushButton(
"Duplicate with input connections", self
)
reset_resolution_btn = QtWidgets.QPushButton(
"Reset Resolution from project", self
)
layout = QtWidgets.QVBoxLayout(self)
layout.setContentsMargins(10, 20, 10, 20)
layout.addWidget(asset_label)
layout.addSpacing(20)
layout.addWidget(workfiles_btn)
layout.addSpacing(20)
layout.addWidget(create_btn)
layout.addWidget(publish_btn)
layout.addWidget(load_btn)
layout.addWidget(publish_btn)
layout.addWidget(manager_btn)
layout.addWidget(Spacer(15, self))
layout.addSpacing(20)
layout.addWidget(libload_btn)
layout.addWidget(Spacer(15, self))
layout.addSpacing(20)
layout.addWidget(set_framerange_btn)
layout.addWidget(set_resolution_btn)
layout.addWidget(rendermode_btn)
layout.addWidget(Spacer(15, self))
layout.addSpacing(20)
layout.addWidget(duplicate_with_inputs_btn)
layout.addWidget(reset_resolution_btn)
self.setLayout(layout)
# Store reference so we can update the label
self.asset_label = asset_label
workfiles_btn.clicked.connect(self.on_workfile_clicked)
create_btn.clicked.connect(self.on_create_clicked)
publish_btn.clicked.connect(self.on_publish_clicked)
load_btn.clicked.connect(self.on_load_clicked)
manager_btn.clicked.connect(self.on_manager_clicked)
libload_btn.clicked.connect(self.on_libload_clicked)
rendermode_btn.clicked.connect(self.on_rendernode_clicked)
rendermode_btn.clicked.connect(self.on_rendermode_clicked)
duplicate_with_inputs_btn.clicked.connect(
self.on_duplicate_with_inputs_clicked)
reset_resolution_btn.clicked.connect(self.on_reset_resolution_clicked)
set_resolution_btn.clicked.connect(self.on_set_resolution_clicked)
set_framerange_btn.clicked.connect(self.on_set_framerange_clicked)
self._callbacks = []
self.register_callback("taskChanged", self.on_task_changed)
self.on_task_changed()
# Force close current process if Fusion is closed
self._pulse = FusionPulse(parent=self)
self._pulse.start()
def on_task_changed(self):
# Update current context label
label = legacy_io.Session["AVALON_ASSET"]
self.asset_label.setText(label)
def register_callback(self, name, fn):
# Create a wrapper callback that we only store
# for as long as we want it to persist as callback
def _callback(*args):
fn()
self._callbacks.append(_callback)
register_event_callback(name, _callback)
def deregister_all_callbacks(self):
self._callbacks[:] = []
def on_workfile_clicked(self):
print("Clicked Workfile")
host_tools.show_workfiles()
def on_create_clicked(self):
print("Clicked Create")
host_tools.show_creator()
def on_publish_clicked(self):
print("Clicked Publish")
host_tools.show_publish()
def on_load_clicked(self):
print("Clicked Load")
host_tools.show_loader(use_context=True)
def on_manager_clicked(self):
print("Clicked Manager")
host_tools.show_scene_inventory()
def on_libload_clicked(self):
print("Clicked Library")
host_tools.show_library_loader()
def on_rendernode_clicked(self):
print("Clicked Set Render Mode")
def on_rendermode_clicked(self):
if self.render_mode_widget is None:
window = set_rendermode.SetRenderMode()
window.setStyleSheet(style.load_stylesheet())
window.setStyleSheet(load_stylesheet())
window.show()
self.render_mode_widget = window
else:
@ -140,15 +166,16 @@ class OpenPypeMenu(QtWidgets.QWidget):
def on_duplicate_with_inputs_clicked(self):
duplicate_with_inputs.duplicate_with_input_connections()
print("Clicked Set Colorspace")
def on_reset_resolution_clicked(self):
print("Clicked Reset Resolution")
def on_set_resolution_clicked(self):
set_asset_resolution()
def on_set_framerange_clicked(self):
set_asset_framerange()
def launch_openpype_menu():
app = QtWidgets.QApplication(sys.argv)
app.setQuitOnLastWindowClosed(False)
pype_menu = OpenPypeMenu()
@ -156,5 +183,8 @@ def launch_openpype_menu():
pype_menu.setStyleSheet(stylesheet)
pype_menu.show()
self.menu = pype_menu
sys.exit(app.exec_())
result = app.exec_()
print("Shutting down..")
sys.exit(result)

View file

@ -1,29 +0,0 @@
QWidget {
background-color: #282828;
border-radius: 3;
}
QPushButton {
border: 1px solid #090909;
background-color: #201f1f;
color: #ffffff;
padding: 5;
}
QPushButton:focus {
background-color: "#171717";
color: #d0d0d0;
}
QPushButton:hover {
background-color: "#171717";
color: #e64b3d;
}
#OpenPypeMenu {
border: 1px solid #fef9ef;
}
#Spacer {
background-color: #282828;
}

View file

@ -2,13 +2,14 @@
Basic avalon integration
"""
import os
import sys
import logging
import contextlib
import pyblish.api
from openpype.lib import Logger
from openpype.lib import (
Logger,
register_event_callback
)
from openpype.pipeline import (
register_loader_plugin_path,
register_creator_plugin_path,
@ -18,7 +19,15 @@ from openpype.pipeline import (
deregister_inventory_action_path,
AVALON_CONTAINER_ID,
)
from openpype.pipeline.load import any_outdated_containers
from openpype.hosts.fusion import FUSION_HOST_DIR
from openpype.tools.utils import host_tools
from .lib import (
get_current_comp,
comp_lock_and_undo_chunk,
validate_comp_prefs
)
log = Logger.get_logger(__name__)
@ -39,7 +48,7 @@ class CompLogHandler(logging.Handler):
def install():
"""Install fusion-specific functionality of avalon-core.
"""Install fusion-specific functionality of OpenPype.
This is where you install menus and register families, data
and loaders into fusion.
@ -51,7 +60,7 @@ def install():
"""
# Remove all handlers associated with the root logger object, because
# that one sometimes logs as "warnings" incorrectly.
# that one always logs as "warnings" incorrectly.
for handler in logging.root.handlers[:]:
logging.root.removeHandler(handler)
@ -63,8 +72,6 @@ def install():
logger.addHandler(handler)
logger.setLevel(logging.DEBUG)
log.info("openpype.hosts.fusion installed")
pyblish.api.register_host("fusion")
pyblish.api.register_plugin_path(PUBLISH_PATH)
log.info("Registering Fusion plug-ins..")
@ -77,6 +84,11 @@ def install():
"instanceToggled", on_pyblish_instance_toggled
)
# Fusion integration currently does not attach to direct callbacks of
# the application. So we use workfile callbacks to allow similar behavior
# on save and open
register_event_callback("workfile.open.after", on_after_open)
def uninstall():
"""Uninstall all that was installed
@ -102,7 +114,7 @@ def uninstall():
)
def on_pyblish_instance_toggled(instance, new_value, old_value):
def on_pyblish_instance_toggled(instance, old_value, new_value):
"""Toggle saver tool passthrough states on instance toggles."""
comp = instance.context.data.get("currentComp")
if not comp:
@ -125,6 +137,38 @@ def on_pyblish_instance_toggled(instance, new_value, old_value):
tool.SetAttrs({"TOOLB_PassThrough": passthrough})
def on_after_open(_event):
comp = get_current_comp()
validate_comp_prefs(comp)
if any_outdated_containers():
log.warning("Scene has outdated content.")
# Find OpenPype menu to attach to
from . import menu
def _on_show_scene_inventory():
# ensure that comp is active
frame = comp.CurrentFrame
if not frame:
print("Comp is closed, skipping show scene inventory")
return
frame.ActivateFrame() # raise comp window
host_tools.show_scene_inventory()
from openpype.widgets import popup
from openpype.style import load_stylesheet
dialog = popup.Popup(parent=menu.menu)
dialog.setWindowTitle("Fusion comp has outdated content")
dialog.setMessage("There are outdated containers in "
"your Fusion comp.")
dialog.on_clicked.connect(_on_show_scene_inventory)
dialog.show()
dialog.raise_()
dialog.activateWindow()
dialog.setStyleSheet(load_stylesheet())
def ls():
"""List containers from active Fusion scene
@ -210,19 +254,3 @@ def parse_container(tool):
return container
def get_current_comp():
"""Hack to get current comp in this session"""
fusion = getattr(sys.modules["__main__"], "fusion", None)
return fusion.CurrentComp if fusion else None
@contextlib.contextmanager
def comp_lock_and_undo_chunk(comp, undo_queue_name="Script CMD"):
"""Lock comp and open an undo chunk during the context"""
try:
comp.Lock()
comp.StartUndo(undo_queue_name)
yield
finally:
comp.Unlock()
comp.EndUndo()

View file

@ -0,0 +1,60 @@
import os
import sys
from Qt import QtCore
class PulseThread(QtCore.QThread):
no_response = QtCore.Signal()
def __init__(self, parent=None):
super(PulseThread, self).__init__(parent=parent)
def run(self):
app = getattr(sys.modules["__main__"], "app", None)
# Interval in milliseconds
interval = os.environ.get("OPENPYPE_FUSION_PULSE_INTERVAL", 1000)
while True:
if self.isInterruptionRequested():
return
try:
app.Test()
except Exception:
self.no_response.emit()
self.msleep(interval)
class FusionPulse(QtCore.QObject):
"""A Timer that checks whether host app is still alive.
This checks whether the Fusion process is still active at a certain
interval. This is useful due to how Fusion runs its scripts. Each script
runs in its own environment and process (a `fusionscript` process each).
If Fusion would go down and we have a UI process running at the same time
then it can happen that the `fusionscript.exe` will remain running in the
background in limbo due to e.g. a Qt interface's QApplication that keeps
running infinitely.
Warning:
When the host is not detected this will automatically exit
the current process.
"""
def __init__(self, parent=None):
super(FusionPulse, self).__init__(parent=parent)
self._thread = PulseThread(parent=self)
self._thread.no_response.connect(self.on_no_response)
def on_no_response(self):
print("Pulse detected no response from Fusion..")
sys.exit(1)
def start(self):
self._thread.start()
def stop(self):
self._thread.requestInterruption()

View file

@ -2,7 +2,7 @@
import sys
import os
from .pipeline import get_current_comp
from .lib import get_current_comp
def file_extensions():

View file

@ -0,0 +1,60 @@
{
Action
{
ID = "OpenPype_Menu",
Category = "OpenPype",
Name = "OpenPype Menu",
Targets =
{
Composition =
{
Execute = _Lua [=[
local scriptPath = app:MapPath("OpenPype:MenuScripts/openpype_menu.py")
if bmd.fileexists(scriptPath) == false then
print("[OpenPype Error] Can't run file: " .. scriptPath)
else
target:RunScript(scriptPath)
end
]=],
},
},
},
Action
{
ID = "OpenPype_Install_PySide2",
Category = "OpenPype",
Name = "Install PySide2",
Targets =
{
Composition =
{
Execute = _Lua [=[
local scriptPath = app:MapPath("OpenPype:MenuScripts/install_pyside2.py")
if bmd.fileexists(scriptPath) == false then
print("[OpenPype Error] Can't run file: " .. scriptPath)
else
target:RunScript(scriptPath)
end
]=],
},
},
},
Menus
{
Target = "ChildFrame",
Before "Help"
{
Sub "OpenPype"
{
"OpenPype_Menu{}",
"_",
Sub "Admin" {
"OpenPype_Install_PySide2{}"
}
}
},
},
}

View file

@ -0,0 +1,6 @@
### OpenPype deploy MenuScripts
Note that this `MenuScripts` is not an official Fusion folder.
OpenPype only uses this folder in `{fusion}/deploy/` to trigger the OpenPype menu actions.
They are used in the actions defined in `.fu` files in `{fusion}/deploy/Config`.

View file

@ -0,0 +1,29 @@
# This is just a quick hack for users running Py3 locally but having no
# Qt library installed
import os
import subprocess
import importlib
try:
from Qt import QtWidgets # noqa: F401
from Qt import __binding__
print(f"Qt binding: {__binding__}")
mod = importlib.import_module(__binding__)
print(f"Qt path: {mod.__file__}")
print("Qt library found, nothing to do..")
except ImportError:
print("Assuming no Qt library is installed..")
print('Installing PySide2 for Python 3.6: '
f'{os.environ["FUSION16_PYTHON36_HOME"]}')
# Get full path to python executable
exe = "python.exe" if os.name == 'nt' else "python"
python = os.path.join(os.environ["FUSION16_PYTHON36_HOME"], exe)
assert os.path.exists(python), f"Python doesn't exist: {python}"
# Do python -m pip install PySide2
args = [python, "-m", "pip", "install", "PySide2"]
print(f"Args: {args}")
subprocess.Popen(args)

View file

@ -9,6 +9,10 @@ from openpype.pipeline import (
def main(env):
# This script working directory starts in Fusion application folder.
# However the contents of that folder can conflict with Qt library dlls
# so we make sure to move out of it to avoid DLL Load Failed errors.
os.chdir("..")
from openpype.hosts.fusion import api
from openpype.hosts.fusion.api import menu
@ -20,6 +24,11 @@ def main(env):
menu.launch_openpype_menu()
# Initiate a QTimer to check if Fusion is still alive every X interval
# If Fusion is not found - kill itself
# todo(roy): Implement timer that ensures UI doesn't remain when e.g.
# Fusion closes down
if __name__ == "__main__":
result = main(os.environ)

View file

@ -0,0 +1,19 @@
{
Locked = true,
Global = {
Paths = {
Map = {
["OpenPype:"] = "$(OPENPYPE_FUSION)/deploy",
["Reactor:"] = "$(REACTOR)",
["Config:"] = "UserPaths:Config;OpenPype:Config",
["Scripts:"] = "UserPaths:Scripts;Reactor:System/Scripts;OpenPype:Scripts",
["UserPaths:"] = "UserData:;AllData:;Fusion:;Reactor:Deploy"
},
},
Script = {
PythonVersion = 3,
Python3Forced = true
},
},
}

View file

@ -0,0 +1,40 @@
import os
import platform
from openpype.lib import PreLaunchHook, ApplicationLaunchFailed
class FusionPreLaunchOCIO(PreLaunchHook):
"""Set OCIO environment variable for Fusion"""
app_groups = ["fusion"]
def execute(self):
"""Hook entry method."""
# get image io
project_settings = self.data["project_settings"]
# make sure anatomy settings are having flame key
imageio_fusion = project_settings.get("fusion", {}).get("imageio")
if not imageio_fusion:
raise ApplicationLaunchFailed((
"Anatomy project settings are missing `fusion` key. "
"Please make sure you remove project overrides on "
"Anatomy ImageIO")
)
ocio = imageio_fusion.get("ocio")
enabled = ocio.get("enabled", False)
if not enabled:
return
platform_key = platform.system().lower()
ocio_path = ocio["configFilePath"][platform_key]
if not ocio_path:
raise ApplicationLaunchFailed(
"Fusion OCIO is enabled in project settings but no OCIO config"
f"path is set for your current platform: {platform_key}"
)
self.log.info(f"Setting OCIO config path: {ocio_path}")
self.launch_context.env["OCIO"] = os.pathsep.join(ocio_path)

View file

@ -1,114 +1,61 @@
import os
import shutil
import openpype.hosts.fusion
from openpype.lib import PreLaunchHook, ApplicationLaunchFailed
from openpype.hosts.fusion import FUSION_HOST_DIR
class FusionPrelaunch(PreLaunchHook):
"""
This hook will check if current workfile path has Fusion
project inside.
"""Prepares OpenPype Fusion environment
Requires FUSION_PYTHON3_HOME to be defined in the environment for Fusion
to point at a valid Python 3 build for Fusion. That is Python 3.3-3.10
for Fusion 18 and Fusion 3.6 for Fusion 16 and 17.
This also sets FUSION16_MasterPrefs to apply the fusion master prefs
as set in openpype/hosts/fusion/deploy/fusion_shared.prefs to enable
the OpenPype menu and force Python 3 over Python 2.
"""
app_groups = ["fusion"]
def execute(self):
# making sure python 3.6 is installed at provided path
py36_dir = self.launch_context.env.get("PYTHON36")
if not py36_dir:
# making sure python 3 is installed at provided path
# Py 3.3-3.10 for Fusion 18+ or Py 3.6 for Fu 16-17
py3_var = "FUSION_PYTHON3_HOME"
fusion_python3_home = self.launch_context.env.get(py3_var, "")
self.log.info(f"Looking for Python 3 in: {fusion_python3_home}")
for path in fusion_python3_home.split(os.pathsep):
# Allow defining multiple paths to allow "fallback" to other
# path. But make to set only a single path as final variable.
py3_dir = os.path.normpath(path)
if os.path.isdir(py3_dir):
break
else:
raise ApplicationLaunchFailed(
"Required environment variable \"PYTHON36\" is not set."
"\n\nFusion implementation requires to have"
" installed Python 3.6"
"Python 3 is not installed at the provided path.\n"
"Make sure the environment in fusion settings has "
"'FUSION_PYTHON3_HOME' set correctly and make sure "
"Python 3 is installed in the given path."
f"\n\nPYTHON36: {fusion_python3_home}"
)
py36_dir = os.path.normpath(py36_dir)
if not os.path.isdir(py36_dir):
raise ApplicationLaunchFailed(
"Python 3.6 is not installed at the provided path.\n"
"Either make sure the environments in fusion settings has"
" 'PYTHON36' set corectly or make sure Python 3.6 is installed"
f" in the given path.\n\nPYTHON36: {py36_dir}"
)
self.log.info(f"Path to Fusion Python folder: '{py36_dir}'...")
self.launch_context.env["PYTHON36"] = py36_dir
self.log.info(f"Setting {py3_var}: '{py3_dir}'...")
self.launch_context.env[py3_var] = py3_dir
utility_dir = self.launch_context.env.get("FUSION_UTILITY_SCRIPTS_DIR")
if not utility_dir:
raise ApplicationLaunchFailed(
"Required Fusion utility script dir environment variable"
" \"FUSION_UTILITY_SCRIPTS_DIR\" is not set."
)
# Fusion 18+ requires FUSION_PYTHON3_HOME to also be on PATH
self.launch_context.env["PATH"] += ";" + py3_dir
# setting utility scripts dir for scripts syncing
utility_dir = os.path.normpath(utility_dir)
if not os.path.isdir(utility_dir):
raise ApplicationLaunchFailed(
"Fusion utility script dir does not exist. Either make sure "
"the environments in fusion settings has"
" 'FUSION_UTILITY_SCRIPTS_DIR' set correctly or reinstall "
f"Fusion.\n\nFUSION_UTILITY_SCRIPTS_DIR: '{utility_dir}'"
)
# Fusion 16 and 17 use FUSION16_PYTHON36_HOME instead of
# FUSION_PYTHON3_HOME and will only work with a Python 3.6 version
# TODO: Detect Fusion version to only set for specific Fusion build
self.launch_context.env["FUSION16_PYTHON36_HOME"] = py3_dir
self._sync_utility_scripts(self.launch_context.env)
self.log.info("Fusion Pype wrapper has been installed")
# Add our Fusion Master Prefs which is the only way to customize
# Fusion to define where it can read custom scripts and tools from
self.log.info(f"Setting OPENPYPE_FUSION: {FUSION_HOST_DIR}")
self.launch_context.env["OPENPYPE_FUSION"] = FUSION_HOST_DIR
def _sync_utility_scripts(self, env):
""" Synchronizing basic utlility scripts for resolve.
To be able to run scripts from inside `Fusion/Workspace/Scripts` menu
all scripts has to be accessible from defined folder.
"""
if not env:
env = {k: v for k, v in os.environ.items()}
# initiate inputs
scripts = {}
us_env = env.get("FUSION_UTILITY_SCRIPTS_SOURCE_DIR")
us_dir = env.get("FUSION_UTILITY_SCRIPTS_DIR", "")
us_paths = [os.path.join(
os.path.dirname(os.path.abspath(openpype.hosts.fusion.__file__)),
"utility_scripts"
)]
# collect script dirs
if us_env:
self.log.info(f"Utility Scripts Env: `{us_env}`")
us_paths = us_env.split(
os.pathsep) + us_paths
# collect scripts from dirs
for path in us_paths:
scripts.update({path: os.listdir(path)})
self.log.info(f"Utility Scripts Dir: `{us_paths}`")
self.log.info(f"Utility Scripts: `{scripts}`")
# make sure no script file is in folder
if next((s for s in os.listdir(us_dir)), None):
for s in os.listdir(us_dir):
path = os.path.normpath(
os.path.join(us_dir, s))
self.log.info(f"Removing `{path}`...")
# remove file or directory if not in our folders
if not os.path.isdir(path):
os.remove(path)
else:
shutil.rmtree(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.normpath(os.path.join(d, s))
dst = os.path.normpath(os.path.join(us_dir, s))
self.log.info(f"Copying `{src}` to `{dst}`...")
# copy file or directory from our folders to fusion's folder
if not os.path.isdir(src):
shutil.copy2(src, dst)
else:
shutil.copytree(src, dst)
pref_var = "FUSION16_MasterPrefs" # used by Fusion 16, 17 and 18
prefs = os.path.join(FUSION_HOST_DIR, "deploy", "fusion_shared.prefs")
self.log.info(f"Setting {pref_var}: {prefs}")
self.launch_context.env[pref_var] = prefs

View file

@ -1,6 +1,9 @@
import os
from openpype.pipeline import LegacyCreator
from openpype.pipeline import (
LegacyCreator,
legacy_io
)
from openpype.hosts.fusion.api import (
get_current_comp,
comp_lock_and_undo_chunk
@ -21,12 +24,9 @@ class CreateOpenEXRSaver(LegacyCreator):
comp = get_current_comp()
# todo: improve method of getting current environment
# todo: pref avalon.Session over os.environ
workdir = os.path.normpath(legacy_io.Session["AVALON_WORKDIR"])
workdir = os.path.normpath(os.environ["AVALON_WORKDIR"])
filename = "{}..tiff".format(self.name)
filename = "{}..exr".format(self.name)
filepath = os.path.join(workdir, "render", filename)
with comp_lock_and_undo_chunk(comp):
@ -39,10 +39,10 @@ class CreateOpenEXRSaver(LegacyCreator):
saver["Clip"] = filepath
saver["OutputFormat"] = file_format
# # # Set standard TIFF settings
# Check file format settings are available
if saver[file_format] is None:
raise RuntimeError("File format is not set to TiffFormat, "
"this is a bug")
raise RuntimeError("File format is not set to {}, "
"this is a bug".format(file_format))
# Set file format attributes
saver[file_format]["Depth"] = 1 # int8 | int16 | float32 | other

View file

@ -101,6 +101,9 @@ def loader_shift(loader, frame, relative=True):
else:
shift = frame - old_in
if not shift:
return 0
# 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
@ -149,9 +152,8 @@ class FusionLoadSequence(load.LoaderPlugin):
tool["Clip"] = path
# Set global in point to start frame (if in version.data)
start = context["version"]["data"].get("frameStart", None)
if start is not None:
loader_shift(tool, start, relative=False)
start = self._get_start(context["version"], tool)
loader_shift(tool, start, relative=False)
imprint_container(tool,
name=name,
@ -214,12 +216,7 @@ class FusionLoadSequence(load.LoaderPlugin):
# Get start frame from version data
project_name = legacy_io.active_project()
version = get_version_by_id(project_name, representation["parent"])
start = version["data"].get("frameStart")
if start is None:
self.log.warning("Missing start frame for updated version"
"assuming starts at frame 0 for: "
"{} ({})".format(tool.Name, representation))
start = 0
start = self._get_start(version, tool)
with comp_lock_and_undo_chunk(comp, "Update Loader"):
@ -256,3 +253,27 @@ class FusionLoadSequence(load.LoaderPlugin):
"""Get first file in representation root"""
files = sorted(os.listdir(root))
return os.path.join(root, files[0])
def _get_start(self, version_doc, tool):
"""Return real start frame of published files (incl. handles)"""
data = version_doc["data"]
# Get start frame directly with handle if it's in data
start = data.get("frameStartHandle")
if start is not None:
return start
# 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))
return 0
# Use `handleStart` if the data is available
handle_start = data.get("handleStart")
if handle_start:
start -= handle_start
return start

View file

@ -4,19 +4,21 @@ import pyblish.api
def get_comp_render_range(comp):
"""Return comp's start and end render range."""
"""Return comp's start-end render range and global start-end range."""
comp_attrs = comp.GetAttrs()
start = comp_attrs["COMPN_RenderStart"]
end = comp_attrs["COMPN_RenderEnd"]
global_start = comp_attrs["COMPN_GlobalStart"]
global_end = comp_attrs["COMPN_GlobalEnd"]
# Whenever render ranges are undefined fall back
# to the comp's global start and end
if start == -1000000000:
start = comp_attrs["COMPN_GlobalEnd"]
start = global_start
if end == -1000000000:
end = comp_attrs["COMPN_GlobalStart"]
end = global_end
return start, end
return start, end, global_start, global_end
class CollectInstances(pyblish.api.ContextPlugin):
@ -42,9 +44,11 @@ class CollectInstances(pyblish.api.ContextPlugin):
tools = comp.GetToolList(False).values()
savers = [tool for tool in tools if tool.ID == "Saver"]
start, end = get_comp_render_range(comp)
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)
for tool in savers:
path = tool["Clip"][comp.TIME_UNDEFINED]
@ -78,8 +82,10 @@ class CollectInstances(pyblish.api.ContextPlugin):
"label": label,
"frameStart": context.data["frameStart"],
"frameEnd": context.data["frameEnd"],
"frameStartHandle": context.data["frameStartHandle"],
"frameEndHandle": context.data["frameStartHandle"],
"fps": context.data["fps"],
"families": ["render", "review", "ftrack"],
"families": ["render", "review"],
"family": "render",
"active": active,
"publish": active # backwards compatibility

View file

@ -20,6 +20,8 @@ class Fusionlocal(pyblish.api.InstancePlugin):
def process(self, instance):
# This plug-in runs only once and thus assumes all instances
# currently will render the same frame range
context = instance.context
key = "__hasRun{}".format(self.__class__.__name__)
if context.data.get(key, False):
@ -28,8 +30,8 @@ class Fusionlocal(pyblish.api.InstancePlugin):
context.data[key] = True
current_comp = context.data["currentComp"]
frame_start = current_comp.GetAttrs("COMPN_RenderStart")
frame_end = current_comp.GetAttrs("COMPN_RenderEnd")
frame_start = context.data["frameStartHandle"]
frame_end = context.data["frameEndHandle"]
path = instance.data["path"]
output_dir = instance.data["outputDir"]
@ -40,7 +42,11 @@ class Fusionlocal(pyblish.api.InstancePlugin):
self.log.info("End frame: {}".format(frame_end))
with comp_lock_and_undo_chunk(current_comp):
result = current_comp.Render()
result = current_comp.Render({
"Start": frame_start,
"End": frame_end,
"Wait": True
})
if "representations" not in instance.data:
instance.data["representations"] = []

View file

@ -1,284 +0,0 @@
import os
import re
import sys
import logging
from openpype.client import (
get_asset_by_name,
get_versions,
)
from openpype.pipeline import (
legacy_io,
install_host,
registered_host,
)
from openpype.lib import version_up
from openpype.hosts.fusion import api
from openpype.hosts.fusion.api import lib
from openpype.pipeline.context_tools import get_workdir_from_session
log = logging.getLogger("Update Slap Comp")
def _format_version_folder(folder):
"""Format a version folder based on the filepath
Assumption here is made that, if the path does not exists the folder
will be "v001"
Args:
folder: file path to a folder
Returns:
str: new version folder name
"""
new_version = 1
if os.path.isdir(folder):
re_version = re.compile(r"v\d+$")
versions = [i for i in os.listdir(folder) if os.path.isdir(i)
and re_version.match(i)]
if versions:
# ensure the "v" is not included
new_version = int(max(versions)[1:]) + 1
version_folder = "v{:03d}".format(new_version)
return version_folder
def _get_fusion_instance():
fusion = getattr(sys.modules["__main__"], "fusion", None)
if fusion is None:
try:
# Support for FuScript.exe, BlackmagicFusion module for py2 only
import BlackmagicFusion as bmf
fusion = bmf.scriptapp("Fusion")
except ImportError:
raise RuntimeError("Could not find a Fusion instance")
return fusion
def _format_filepath(session):
project = session["AVALON_PROJECT"]
asset = session["AVALON_ASSET"]
# Save updated slap comp
work_path = get_workdir_from_session(session)
walk_to_dir = os.path.join(work_path, "scenes", "slapcomp")
slapcomp_dir = os.path.abspath(walk_to_dir)
# Ensure destination exists
if not os.path.isdir(slapcomp_dir):
log.warning("Folder did not exist, creating folder structure")
os.makedirs(slapcomp_dir)
# Compute output path
new_filename = "{}_{}_slapcomp_v001.comp".format(project, asset)
new_filepath = os.path.join(slapcomp_dir, new_filename)
# Create new unique filepath
if os.path.exists(new_filepath):
new_filepath = version_up(new_filepath)
return new_filepath
def _update_savers(comp, session):
"""Update all savers of the current comp to ensure the output is correct
This will refactor the Saver file outputs to the renders of the new session
that is provided.
In the case the original saver path had a path set relative to a /fusion/
folder then that relative path will be matched with the exception of all
"version" (e.g. v010) references will be reset to v001. Otherwise only a
version folder will be computed in the new session's work "render" folder
to dump the files in and keeping the original filenames.
Args:
comp (object): current comp instance
session (dict): the current Avalon session
Returns:
None
"""
new_work = get_workdir_from_session(session)
renders = os.path.join(new_work, "renders")
version_folder = _format_version_folder(renders)
renders_version = os.path.join(renders, version_folder)
comp.Print("New renders to: %s\n" % renders)
with api.comp_lock_and_undo_chunk(comp):
savers = comp.GetToolList(False, "Saver").values()
for saver in savers:
filepath = saver.GetAttrs("TOOLST_Clip_Name")[1.0]
# Get old relative path to the "fusion" app folder so we can apply
# the same relative path afterwards. If not found fall back to
# using just a version folder with the filename in it.
# todo: can we make this less magical?
relpath = filepath.replace("\\", "/").rsplit("/fusion/", 1)[-1]
if os.path.isabs(relpath):
# If not relative to a "/fusion/" folder then just use filename
filename = os.path.basename(filepath)
log.warning("Can't parse relative path, refactoring to only"
"filename in a version folder: %s" % filename)
new_path = os.path.join(renders_version, filename)
else:
# Else reuse the relative path
# Reset version in folder and filename in the relative path
# to v001. The version should be is only detected when prefixed
# with either `_v` (underscore) or `/v` (folder)
version_pattern = r"(/|_)v[0-9]+"
if re.search(version_pattern, relpath):
new_relpath = re.sub(version_pattern,
r"\1v001",
relpath)
log.info("Resetting version folders to v001: "
"%s -> %s" % (relpath, new_relpath))
relpath = new_relpath
new_path = os.path.join(new_work, relpath)
saver["Clip"] = new_path
def update_frame_range(comp, representations):
"""Update the frame range of the comp and render length
The start and end frame are based on the lowest start frame and the highest
end frame
Args:
comp (object): current focused comp
representations (list) collection of dicts
Returns:
None
"""
project_name = legacy_io.active_project()
version_ids = {r["parent"] for r in representations}
versions = list(get_versions(project_name, version_ids))
versions = [v for v in versions
if v["data"].get("frameStart", None) is not None]
if not versions:
log.warning("No versions loaded to match frame range to.\n")
return
start = min(v["data"]["frameStart"] for v in versions)
end = max(v["data"]["frameEnd"] for v in versions)
lib.update_frame_range(start, end, comp=comp)
def switch(asset_name, filepath=None, new=True):
"""Switch the current containers of the file to the other asset (shot)
Args:
filepath (str): file path of the comp file
asset_name (str): name of the asset (shot)
new (bool): Save updated comp under a different name
Returns:
comp path (str): new filepath of the updated comp
"""
# If filepath provided, ensure it is valid absolute path
if filepath is not None:
if not os.path.isabs(filepath):
filepath = os.path.abspath(filepath)
assert os.path.exists(filepath), "%s must exist " % filepath
# Assert asset name exists
# It is better to do this here then to wait till switch_shot does it
project_name = legacy_io.active_project()
asset = get_asset_by_name(project_name, asset_name)
assert asset, "Could not find '%s' in the database" % asset_name
# Go to comp
if not filepath:
current_comp = api.get_current_comp()
assert current_comp is not None, "Could not find current comp"
else:
fusion = _get_fusion_instance()
current_comp = fusion.LoadComp(filepath, quiet=True)
assert current_comp is not None, (
"Fusion could not load '{}'").format(filepath)
host = registered_host()
containers = list(host.ls())
assert containers, "Nothing to update"
representations = []
for container in containers:
try:
representation = lib.switch_item(
container,
asset_name=asset_name)
representations.append(representation)
except Exception as e:
current_comp.Print("Error in switching! %s\n" % e.message)
message = "Switched %i Loaders of the %i\n" % (len(representations),
len(containers))
current_comp.Print(message)
# Build the session to switch to
switch_to_session = legacy_io.Session.copy()
switch_to_session["AVALON_ASSET"] = asset['name']
if new:
comp_path = _format_filepath(switch_to_session)
# Update savers output based on new session
_update_savers(current_comp, switch_to_session)
else:
comp_path = version_up(filepath)
current_comp.Print(comp_path)
current_comp.Print("\nUpdating frame range")
update_frame_range(current_comp, representations)
current_comp.Save(comp_path)
return comp_path
if __name__ == '__main__':
# QUESTION: can we convert this to gui rather then standalone script?
# TODO: convert to gui tool
import argparse
parser = argparse.ArgumentParser(description="Switch to a shot within an"
"existing comp file")
parser.add_argument("--file_path",
type=str,
default=True,
help="File path of the comp to use")
parser.add_argument("--asset_name",
type=str,
default=True,
help="Name of the asset (shot) to switch")
args, unknown = parser.parse_args()
install_host(api)
switch(args.asset_name, args.file_path)
sys.exit(0)

View file

@ -0,0 +1,12 @@
{
"imageio": {
"ocio": {
"enabled": false,
"configFilePath": {
"windows": [],
"darwin": [],
"linux": []
}
}
}
}

View file

@ -706,30 +706,28 @@
"icon": "{}/app_icons/fusion.png",
"host_name": "fusion",
"environment": {
"FUSION_UTILITY_SCRIPTS_SOURCE_DIR": [],
"FUSION_UTILITY_SCRIPTS_DIR": {
"windows": "{PROGRAMDATA}/Blackmagic Design/Fusion/Scripts/Comp",
"darwin": "/Library/Application Support/Blackmagic Design/Fusion/Scripts/Comp",
"linux": "/opt/Fusion/Scripts/Comp"
},
"PYTHON36": {
"FUSION_PYTHON3_HOME": {
"windows": "{LOCALAPPDATA}/Programs/Python/Python36",
"darwin": "~/Library/Python/3.6/bin",
"linux": "/opt/Python/3.6/bin"
},
"PYTHONPATH": [
"{PYTHON36}/Lib/site-packages",
"{VIRTUAL_ENV}/Lib/site-packages",
"{PYTHONPATH}"
],
"PATH": [
"{PYTHON36}",
"{PYTHON36}/Scripts",
"{PATH}"
],
"OPENPYPE_LOG_NO_COLORS": "Yes"
}
},
"variants": {
"18": {
"executables": {
"windows": [
"C:\\Program Files\\Blackmagic Design\\Fusion 18\\Fusion.exe"
],
"darwin": [],
"linux": []
},
"arguments": {
"windows": [],
"darwin": [],
"linux": []
},
"environment": {}
},
"17": {
"executables": {
"windows": [

View file

@ -90,6 +90,10 @@
"type": "schema",
"name": "schema_project_nuke"
},
{
"type": "schema",
"name": "schema_project_fusion"
},
{
"type": "schema",
"name": "schema_project_hiero"

View file

@ -0,0 +1,38 @@
{
"type": "dict",
"collapsible": true,
"key": "fusion",
"label": "Fusion",
"is_file": true,
"children": [
{
"key": "imageio",
"type": "dict",
"label": "Color Management (ImageIO)",
"collapsible": true,
"children": [
{
"key": "ocio",
"type": "dict",
"label": "OpenColorIO (OCIO)",
"collapsible": true,
"checkbox_key": "enabled",
"children": [
{
"type": "boolean",
"key": "enabled",
"label": "Set OCIO variable for Fusion"
},
{
"type": "path",
"key": "configFilePath",
"label": "OCIO Config File Path",
"multiplatform": true,
"multipath": true
}
]
}
]
}
]
}