diff --git a/CHANGELOG.md b/CHANGELOG.md
index 95792f8a7a..e08a4cf9e1 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,8 +1,40 @@
# Changelog
+## [3.6.0-nightly.2](https://github.com/pypeclub/OpenPype/tree/HEAD)
+
+[Full Changelog](https://github.com/pypeclub/OpenPype/compare/3.5.0...HEAD)
+
+**π New features**
+
+- Flame: a host basic integration [\#2165](https://github.com/pypeclub/OpenPype/pull/2165)
+
+**π Enhancements**
+
+- Tools: Experimental tools [\#2167](https://github.com/pypeclub/OpenPype/pull/2167)
+- Loader: Refactor and use OpenPype stylesheets [\#2166](https://github.com/pypeclub/OpenPype/pull/2166)
+- Add loader for linked smart objects in photoshop [\#2149](https://github.com/pypeclub/OpenPype/pull/2149)
+- Burnins: DNxHD profiles handling [\#2142](https://github.com/pypeclub/OpenPype/pull/2142)
+- Tools: Single access point for host tools [\#2139](https://github.com/pypeclub/OpenPype/pull/2139)
+
+**π Bug fixes**
+
+- StandalonePublisher: Source validator don't expect representations [\#2190](https://github.com/pypeclub/OpenPype/pull/2190)
+- MacOS: Launching of applications may cause Permissions error [\#2175](https://github.com/pypeclub/OpenPype/pull/2175)
+- Blender: Fix 'Deselect All' with object not in 'Object Mode' [\#2163](https://github.com/pypeclub/OpenPype/pull/2163)
+- Tools: Stylesheets are applied after tool show [\#2161](https://github.com/pypeclub/OpenPype/pull/2161)
+- Maya: Collect render - fix UNC path support π [\#2158](https://github.com/pypeclub/OpenPype/pull/2158)
+- Maya: Fix hotbox broken by scriptsmenu [\#2151](https://github.com/pypeclub/OpenPype/pull/2151)
+- Ftrack: Ignore save warnings exception in Prepare project action [\#2150](https://github.com/pypeclub/OpenPype/pull/2150)
+- Loader thumbnails with smooth edges [\#2147](https://github.com/pypeclub/OpenPype/pull/2147)
+
+**Merged pull requests:**
+
+- Bump pillow from 8.2.0 to 8.3.2 [\#2162](https://github.com/pypeclub/OpenPype/pull/2162)
+- Bump axios from 0.21.1 to 0.21.4 in /website [\#2059](https://github.com/pypeclub/OpenPype/pull/2059)
+
## [3.5.0](https://github.com/pypeclub/OpenPype/tree/3.5.0) (2021-10-17)
-[Full Changelog](https://github.com/pypeclub/OpenPype/compare/3.4.1...3.5.0)
+[Full Changelog](https://github.com/pypeclub/OpenPype/compare/CI/3.5.0-nightly.8...3.5.0)
**Deprecated:**
@@ -37,6 +69,7 @@
**π Bug fixes**
+- Added validator for source files for Standalone Publisher [\#2138](https://github.com/pypeclub/OpenPype/pull/2138)
- Maya: fix model publishing [\#2130](https://github.com/pypeclub/OpenPype/pull/2130)
- Fix - oiiotool wasn't recognized even if present [\#2129](https://github.com/pypeclub/OpenPype/pull/2129)
- General: Disk mapping group [\#2120](https://github.com/pypeclub/OpenPype/pull/2120)
@@ -66,10 +99,6 @@
[Full Changelog](https://github.com/pypeclub/OpenPype/compare/CI/3.4.1-nightly.1...3.4.1)
-**π New features**
-
-- Settings: Flag project as deactivated and hide from tools' view [\#2008](https://github.com/pypeclub/OpenPype/pull/2008)
-
**π Enhancements**
- General: Startup validations [\#2054](https://github.com/pypeclub/OpenPype/pull/2054)
@@ -79,11 +108,6 @@
- Loader: Families filtering [\#2043](https://github.com/pypeclub/OpenPype/pull/2043)
- Settings UI: Project view enhancements [\#2042](https://github.com/pypeclub/OpenPype/pull/2042)
- Settings for Nuke IncrementScriptVersion [\#2039](https://github.com/pypeclub/OpenPype/pull/2039)
-- Loader & Library loader: Use tools from OpenPype [\#2038](https://github.com/pypeclub/OpenPype/pull/2038)
-- Adding predefined project folders creation in PM [\#2030](https://github.com/pypeclub/OpenPype/pull/2030)
-- WebserverModule: Removed interface of webserver module [\#2028](https://github.com/pypeclub/OpenPype/pull/2028)
-- TimersManager: Removed interface of timers manager [\#2024](https://github.com/pypeclub/OpenPype/pull/2024)
-- Feature Maya import asset from scene inventory [\#2018](https://github.com/pypeclub/OpenPype/pull/2018)
**π Bug fixes**
@@ -101,35 +125,16 @@
[Full Changelog](https://github.com/pypeclub/OpenPype/compare/CI/3.4.0-nightly.6...3.4.0)
-**π New features**
-
-- Nuke: Compatibility with Nuke 13 [\#2003](https://github.com/pypeclub/OpenPype/pull/2003)
-
**π Enhancements**
- Added possibility to configure of synchronization of workfile version⦠[\#2041](https://github.com/pypeclub/OpenPype/pull/2041)
+- Loader & Library loader: Use tools from OpenPype [\#2038](https://github.com/pypeclub/OpenPype/pull/2038)
- General: Task types in profiles [\#2036](https://github.com/pypeclub/OpenPype/pull/2036)
-- Console interpreter: Handle invalid sizes on initialization [\#2022](https://github.com/pypeclub/OpenPype/pull/2022)
-- Ftrack: Show OpenPype versions in event server status [\#2019](https://github.com/pypeclub/OpenPype/pull/2019)
-- General: Staging icon [\#2017](https://github.com/pypeclub/OpenPype/pull/2017)
-- Ftrack: Sync to avalon actions have jobs [\#2015](https://github.com/pypeclub/OpenPype/pull/2015)
-- Modules: Connect method is not required [\#2009](https://github.com/pypeclub/OpenPype/pull/2009)
-- Settings UI: Number with configurable steps [\#2001](https://github.com/pypeclub/OpenPype/pull/2001)
-- Moving project folder structure creation out of ftrack module \#1989 [\#1996](https://github.com/pypeclub/OpenPype/pull/1996)
**π Bug fixes**
- Workfiles tool: Task selection [\#2040](https://github.com/pypeclub/OpenPype/pull/2040)
- Ftrack: Delete old versions missing settings key [\#2037](https://github.com/pypeclub/OpenPype/pull/2037)
-- Nuke: typo on a button [\#2034](https://github.com/pypeclub/OpenPype/pull/2034)
-- Hiero: Fix "none" named tags [\#2033](https://github.com/pypeclub/OpenPype/pull/2033)
-- FFmpeg: Subprocess arguments as list [\#2032](https://github.com/pypeclub/OpenPype/pull/2032)
-- General: Fix Python 2 breaking line [\#2016](https://github.com/pypeclub/OpenPype/pull/2016)
-- Bugfix/webpublisher task type [\#2006](https://github.com/pypeclub/OpenPype/pull/2006)
-
-### π Documentation
-
-- Documentation: Ftrack launch argsuments update [\#2014](https://github.com/pypeclub/OpenPype/pull/2014)
## [3.3.1](https://github.com/pypeclub/OpenPype/tree/3.3.1) (2021-08-20)
diff --git a/openpype/cli.py b/openpype/cli.py
index c69407e295..8438703bd3 100644
--- a/openpype/cli.py
+++ b/openpype/cli.py
@@ -158,6 +158,25 @@ def publish(debug, paths, targets):
PypeCommands.publish(list(paths), targets)
+@main.command()
+@click.argument("path")
+@click.option("-d", "--debug", is_flag=True, help="Print debug messages")
+@click.option("-h", "--host", help="Host")
+@click.option("-u", "--user", help="User email address")
+@click.option("-p", "--project", help="Project")
+@click.option("-t", "--targets", help="Targets", default=None,
+ multiple=True)
+def remotepublishfromapp(debug, project, path, host, targets=None, user=None):
+ """Start CLI publishing.
+
+ Publish collects json from paths provided as an argument.
+ More than one path is allowed.
+ """
+ if debug:
+ os.environ['OPENPYPE_DEBUG'] = '3'
+ PypeCommands.remotepublishfromapp(project, path, host, user,
+ targets=targets)
+
@main.command()
@click.argument("path")
@click.option("-d", "--debug", is_flag=True, help="Print debug messages")
diff --git a/openpype/hooks/pre_foundry_apps.py b/openpype/hooks/pre_foundry_apps.py
index 85f68c6b60..7df1a6a833 100644
--- a/openpype/hooks/pre_foundry_apps.py
+++ b/openpype/hooks/pre_foundry_apps.py
@@ -13,7 +13,7 @@ class LaunchFoundryAppsWindows(PreLaunchHook):
# Should be as last hook because must change launch arguments to string
order = 1000
- app_groups = ["nuke", "nukex", "hiero", "nukestudio"]
+ app_groups = ["nuke", "nukex", "hiero", "nukestudio", "photoshop"]
platforms = ["windows"]
def execute(self):
diff --git a/openpype/hosts/blender/plugins/create/create_camera.py b/openpype/hosts/blender/plugins/create/create_camera.py
index c7fea30787..98ccca313c 100644
--- a/openpype/hosts/blender/plugins/create/create_camera.py
+++ b/openpype/hosts/blender/plugins/create/create_camera.py
@@ -3,11 +3,12 @@
import bpy
from avalon import api
-from avalon.blender import lib
-import openpype.hosts.blender.api.plugin
+from avalon.blender import lib, ops
+from avalon.blender.pipeline import AVALON_INSTANCES
+from openpype.hosts.blender.api import plugin
-class CreateCamera(openpype.hosts.blender.api.plugin.Creator):
+class CreateCamera(plugin.Creator):
"""Polygonal static geometry"""
name = "cameraMain"
@@ -16,17 +17,46 @@ class CreateCamera(openpype.hosts.blender.api.plugin.Creator):
icon = "video-camera"
def process(self):
+ """ Run the creator on Blender main thread"""
+ mti = ops.MainThreadItem(self._process)
+ ops.execute_in_main_thread(mti)
+ def _process(self):
+ # Get Instance Containter or create it if it does not exist
+ instances = bpy.data.collections.get(AVALON_INSTANCES)
+ if not instances:
+ instances = bpy.data.collections.new(name=AVALON_INSTANCES)
+ bpy.context.scene.collection.children.link(instances)
+
+ # Create instance object
asset = self.data["asset"]
subset = self.data["subset"]
- name = openpype.hosts.blender.api.plugin.asset_name(asset, subset)
- collection = bpy.data.collections.new(name=name)
- bpy.context.scene.collection.children.link(collection)
+ name = plugin.asset_name(asset, subset)
+
+ camera = bpy.data.cameras.new(subset)
+ camera_obj = bpy.data.objects.new(subset, camera)
+
+ instances.objects.link(camera_obj)
+
+ asset_group = bpy.data.objects.new(name=name, object_data=None)
+ asset_group.empty_display_type = 'SINGLE_ARROW'
+ instances.objects.link(asset_group)
self.data['task'] = api.Session.get('AVALON_TASK')
- lib.imprint(collection, self.data)
+ print(f"self.data: {self.data}")
+ lib.imprint(asset_group, self.data)
if (self.options or {}).get("useSelection"):
- for obj in lib.get_selection():
- collection.objects.link(obj)
+ bpy.context.view_layer.objects.active = asset_group
+ selected = lib.get_selection()
+ for obj in selected:
+ obj.select_set(True)
+ selected.append(asset_group)
+ bpy.ops.object.parent_set(keep_transform=True)
+ else:
+ plugin.deselect_all()
+ camera_obj.select_set(True)
+ asset_group.select_set(True)
+ bpy.context.view_layer.objects.active = asset_group
+ bpy.ops.object.parent_set(keep_transform=True)
- return collection
+ return asset_group
diff --git a/openpype/hosts/blender/plugins/load/load_camera.py b/openpype/hosts/blender/plugins/load/load_camera.py
deleted file mode 100644
index 30300100e0..0000000000
--- a/openpype/hosts/blender/plugins/load/load_camera.py
+++ /dev/null
@@ -1,247 +0,0 @@
-"""Load a camera asset in Blender."""
-
-import logging
-from pathlib import Path
-from pprint import pformat
-from typing import Dict, List, Optional
-
-from avalon import api, blender
-import bpy
-import openpype.hosts.blender.api.plugin
-
-logger = logging.getLogger("openpype").getChild("blender").getChild("load_camera")
-
-
-class BlendCameraLoader(openpype.hosts.blender.api.plugin.AssetLoader):
- """Load a camera from a .blend file.
-
- Warning:
- Loading the same asset more then once is not properly supported at the
- moment.
- """
-
- families = ["camera"]
- representations = ["blend"]
-
- label = "Link Camera"
- icon = "code-fork"
- color = "orange"
-
- def _remove(self, objects, lib_container):
- for obj in list(objects):
- bpy.data.cameras.remove(obj.data)
-
- bpy.data.collections.remove(bpy.data.collections[lib_container])
-
- def _process(self, libpath, lib_container, container_name, actions):
-
- relative = bpy.context.preferences.filepaths.use_relative_paths
- with bpy.data.libraries.load(
- libpath, link=True, relative=relative
- ) as (_, data_to):
- data_to.collections = [lib_container]
-
- scene = bpy.context.scene
-
- scene.collection.children.link(bpy.data.collections[lib_container])
-
- camera_container = scene.collection.children[lib_container].make_local()
-
- objects_list = []
-
- for obj in camera_container.objects:
- local_obj = obj.make_local()
- local_obj.data.make_local()
-
- if not local_obj.get(blender.pipeline.AVALON_PROPERTY):
- local_obj[blender.pipeline.AVALON_PROPERTY] = dict()
-
- avalon_info = local_obj[blender.pipeline.AVALON_PROPERTY]
- avalon_info.update({"container_name": container_name})
-
- if actions[0] is not None:
- if local_obj.animation_data is None:
- local_obj.animation_data_create()
- local_obj.animation_data.action = actions[0]
-
- if actions[1] is not None:
- if local_obj.data.animation_data is None:
- local_obj.data.animation_data_create()
- local_obj.data.animation_data.action = actions[1]
-
- objects_list.append(local_obj)
-
- camera_container.pop(blender.pipeline.AVALON_PROPERTY)
-
- bpy.ops.object.select_all(action='DESELECT')
-
- return objects_list
-
- def process_asset(
- self, context: dict, name: str, namespace: Optional[str] = None,
- options: Optional[Dict] = None
- ) -> Optional[List]:
- """
- Arguments:
- name: Use pre-defined name
- namespace: Use pre-defined namespace
- context: Full parenthood of representation to load
- options: Additional settings dictionary
- """
-
- libpath = self.fname
- asset = context["asset"]["name"]
- subset = context["subset"]["name"]
- lib_container = openpype.hosts.blender.api.plugin.asset_name(asset, subset)
- container_name = openpype.hosts.blender.api.plugin.asset_name(
- asset, subset, namespace
- )
-
- container = bpy.data.collections.new(lib_container)
- container.name = container_name
- blender.pipeline.containerise_existing(
- container,
- name,
- namespace,
- context,
- self.__class__.__name__,
- )
-
- container_metadata = container.get(
- blender.pipeline.AVALON_PROPERTY)
-
- container_metadata["libpath"] = libpath
- container_metadata["lib_container"] = lib_container
-
- objects_list = self._process(
- libpath, lib_container, container_name, (None, None))
-
- # Save the list of objects in the metadata container
- container_metadata["objects"] = objects_list
-
- nodes = list(container.objects)
- nodes.append(container)
- self[:] = nodes
- return nodes
-
- def update(self, container: Dict, representation: Dict):
- """Update the loaded asset.
-
- This will remove all objects of the current collection, load the new
- ones and add them to the collection.
- If the objects of the collection are used in another collection they
- will not be removed, only unlinked. Normally this should not be the
- case though.
-
- Warning:
- No nested collections are supported at the moment!
- """
-
- collection = bpy.data.collections.get(
- container["objectName"]
- )
-
- libpath = Path(api.get_representation_path(representation))
- extension = libpath.suffix.lower()
-
- logger.info(
- "Container: %s\nRepresentation: %s",
- pformat(container, indent=2),
- pformat(representation, indent=2),
- )
-
- assert collection, (
- f"The asset is not loaded: {container['objectName']}"
- )
- assert not (collection.children), (
- "Nested collections are not supported."
- )
- assert libpath, (
- "No existing library file found for {container['objectName']}"
- )
- assert libpath.is_file(), (
- f"The file doesn't exist: {libpath}"
- )
- assert extension in openpype.hosts.blender.api.plugin.VALID_EXTENSIONS, (
- f"Unsupported file: {libpath}"
- )
-
- collection_metadata = collection.get(
- blender.pipeline.AVALON_PROPERTY)
- collection_libpath = collection_metadata["libpath"]
- objects = collection_metadata["objects"]
- lib_container = collection_metadata["lib_container"]
-
- normalized_collection_libpath = (
- str(Path(bpy.path.abspath(collection_libpath)).resolve())
- )
- normalized_libpath = (
- str(Path(bpy.path.abspath(str(libpath))).resolve())
- )
- logger.debug(
- "normalized_collection_libpath:\n %s\nnormalized_libpath:\n %s",
- normalized_collection_libpath,
- normalized_libpath,
- )
- if normalized_collection_libpath == normalized_libpath:
- logger.info("Library already loaded, not updating...")
- return
-
- camera = objects[0]
-
- camera_action = None
- camera_data_action = None
-
- if camera.animation_data and camera.animation_data.action:
- camera_action = camera.animation_data.action
-
- if camera.data.animation_data and camera.data.animation_data.action:
- camera_data_action = camera.data.animation_data.action
-
- actions = (camera_action, camera_data_action)
-
- self._remove(objects, lib_container)
-
- objects_list = self._process(
- str(libpath), lib_container, collection.name, actions)
-
- # Save the list of objects in the metadata container
- collection_metadata["objects"] = objects_list
- collection_metadata["libpath"] = str(libpath)
- collection_metadata["representation"] = str(representation["_id"])
-
- bpy.ops.object.select_all(action='DESELECT')
-
- def remove(self, container: Dict) -> bool:
- """Remove an existing container from a Blender scene.
-
- Arguments:
- container (openpype:container-1.0): Container to remove,
- from `host.ls()`.
-
- Returns:
- bool: Whether the container was deleted.
-
- Warning:
- No nested collections are supported at the moment!
- """
-
- collection = bpy.data.collections.get(
- container["objectName"]
- )
- if not collection:
- return False
- assert not (collection.children), (
- "Nested collections are not supported."
- )
-
- collection_metadata = collection.get(
- blender.pipeline.AVALON_PROPERTY)
- objects = collection_metadata["objects"]
- lib_container = collection_metadata["lib_container"]
-
- self._remove(objects, lib_container)
-
- bpy.data.collections.remove(collection)
-
- return True
diff --git a/openpype/hosts/blender/plugins/load/load_camera_blend.py b/openpype/hosts/blender/plugins/load/load_camera_blend.py
new file mode 100644
index 0000000000..834eb467d8
--- /dev/null
+++ b/openpype/hosts/blender/plugins/load/load_camera_blend.py
@@ -0,0 +1,252 @@
+"""Load a camera asset in Blender."""
+
+import logging
+from pathlib import Path
+from pprint import pformat
+from typing import Dict, List, Optional
+
+import bpy
+
+from avalon import api
+from avalon.blender.pipeline import AVALON_CONTAINERS
+from avalon.blender.pipeline import AVALON_CONTAINER_ID
+from avalon.blender.pipeline import AVALON_PROPERTY
+from openpype.hosts.blender.api import plugin
+
+logger = logging.getLogger("openpype").getChild(
+ "blender").getChild("load_camera")
+
+
+class BlendCameraLoader(plugin.AssetLoader):
+ """Load a camera from a .blend file.
+
+ Warning:
+ Loading the same asset more then once is not properly supported at the
+ moment.
+ """
+
+ families = ["camera"]
+ representations = ["blend"]
+
+ label = "Link Camera (Blend)"
+ icon = "code-fork"
+ color = "orange"
+
+ def _remove(self, asset_group):
+ objects = list(asset_group.children)
+
+ for obj in objects:
+ if obj.type == 'CAMERA':
+ bpy.data.cameras.remove(obj.data)
+
+ def _process(self, libpath, asset_group, group_name):
+ with bpy.data.libraries.load(
+ libpath, link=True, relative=False
+ ) as (data_from, data_to):
+ data_to.objects = data_from.objects
+
+ parent = bpy.context.scene.collection
+
+ empties = [obj for obj in data_to.objects if obj.type == 'EMPTY']
+
+ container = None
+
+ for empty in empties:
+ if empty.get(AVALON_PROPERTY):
+ container = empty
+ break
+
+ assert container, "No asset group found"
+
+ # Children must be linked before parents,
+ # otherwise the hierarchy will break
+ objects = []
+ nodes = list(container.children)
+
+ for obj in nodes:
+ obj.parent = asset_group
+
+ for obj in nodes:
+ objects.append(obj)
+ nodes.extend(list(obj.children))
+
+ objects.reverse()
+
+ for obj in objects:
+ parent.objects.link(obj)
+
+ for obj in objects:
+ local_obj = plugin.prepare_data(obj, group_name)
+
+ if local_obj.type != 'EMPTY':
+ plugin.prepare_data(local_obj.data, group_name)
+
+ if not local_obj.get(AVALON_PROPERTY):
+ local_obj[AVALON_PROPERTY] = dict()
+
+ avalon_info = local_obj[AVALON_PROPERTY]
+ avalon_info.update({"container_name": group_name})
+
+ objects.reverse()
+
+ bpy.data.orphans_purge(do_local_ids=False)
+
+ plugin.deselect_all()
+
+ return objects
+
+ def process_asset(
+ self, context: dict, name: str, namespace: Optional[str] = None,
+ options: Optional[Dict] = None
+ ) -> Optional[List]:
+ """
+ Arguments:
+ name: Use pre-defined name
+ namespace: Use pre-defined namespace
+ context: Full parenthood of representation to load
+ options: Additional settings dictionary
+ """
+ libpath = self.fname
+ asset = context["asset"]["name"]
+ subset = context["subset"]["name"]
+
+ asset_name = plugin.asset_name(asset, subset)
+ unique_number = plugin.get_unique_number(asset, subset)
+ group_name = plugin.asset_name(asset, subset, unique_number)
+ namespace = namespace or f"{asset}_{unique_number}"
+
+ avalon_container = bpy.data.collections.get(AVALON_CONTAINERS)
+ if not avalon_container:
+ avalon_container = bpy.data.collections.new(name=AVALON_CONTAINERS)
+ bpy.context.scene.collection.children.link(avalon_container)
+
+ asset_group = bpy.data.objects.new(group_name, object_data=None)
+ asset_group.empty_display_type = 'SINGLE_ARROW'
+ avalon_container.objects.link(asset_group)
+
+ objects = self._process(libpath, asset_group, group_name)
+
+ bpy.context.scene.collection.objects.link(asset_group)
+
+ asset_group[AVALON_PROPERTY] = {
+ "schema": "openpype:container-2.0",
+ "id": AVALON_CONTAINER_ID,
+ "name": name,
+ "namespace": namespace or '',
+ "loader": str(self.__class__.__name__),
+ "representation": str(context["representation"]["_id"]),
+ "libpath": libpath,
+ "asset_name": asset_name,
+ "parent": str(context["representation"]["parent"]),
+ "family": context["representation"]["context"]["family"],
+ "objectName": group_name
+ }
+
+ self[:] = objects
+ return objects
+
+ def exec_update(self, container: Dict, representation: Dict):
+ """Update the loaded asset.
+
+ This will remove all children of the asset group, load the new ones
+ and add them as children of the group.
+ """
+ object_name = container["objectName"]
+ asset_group = bpy.data.objects.get(object_name)
+ libpath = Path(api.get_representation_path(representation))
+ extension = libpath.suffix.lower()
+
+ self.log.info(
+ "Container: %s\nRepresentation: %s",
+ pformat(container, indent=2),
+ pformat(representation, indent=2),
+ )
+
+ assert asset_group, (
+ f"The asset is not loaded: {container['objectName']}"
+ )
+ assert libpath, (
+ "No existing library file found for {container['objectName']}"
+ )
+ assert libpath.is_file(), (
+ f"The file doesn't exist: {libpath}"
+ )
+ assert extension in plugin.VALID_EXTENSIONS, (
+ f"Unsupported file: {libpath}"
+ )
+
+ metadata = asset_group.get(AVALON_PROPERTY)
+ group_libpath = metadata["libpath"]
+
+ normalized_group_libpath = (
+ str(Path(bpy.path.abspath(group_libpath)).resolve())
+ )
+ normalized_libpath = (
+ str(Path(bpy.path.abspath(str(libpath))).resolve())
+ )
+ self.log.debug(
+ "normalized_group_libpath:\n %s\nnormalized_libpath:\n %s",
+ normalized_group_libpath,
+ normalized_libpath,
+ )
+ if normalized_group_libpath == normalized_libpath:
+ self.log.info("Library already loaded, not updating...")
+ return
+
+ # Check how many assets use the same library
+ count = 0
+ for obj in bpy.data.collections.get(AVALON_CONTAINERS).objects:
+ if obj.get(AVALON_PROPERTY).get('libpath') == group_libpath:
+ count += 1
+
+ mat = asset_group.matrix_basis.copy()
+
+ self._remove(asset_group)
+
+ # If it is the last object to use that library, remove it
+ if count == 1:
+ library = bpy.data.libraries.get(bpy.path.basename(group_libpath))
+ if library:
+ bpy.data.libraries.remove(library)
+
+ self._process(str(libpath), asset_group, object_name)
+
+ asset_group.matrix_basis = mat
+
+ metadata["libpath"] = str(libpath)
+ metadata["representation"] = str(representation["_id"])
+ metadata["parent"] = str(representation["parent"])
+
+ def exec_remove(self, container: Dict) -> bool:
+ """Remove an existing container from a Blender scene.
+
+ Arguments:
+ container (openpype:container-1.0): Container to remove,
+ from `host.ls()`.
+
+ Returns:
+ bool: Whether the container was deleted.
+ """
+ object_name = container["objectName"]
+ asset_group = bpy.data.objects.get(object_name)
+ libpath = asset_group.get(AVALON_PROPERTY).get('libpath')
+
+ # Check how many assets use the same library
+ count = 0
+ for obj in bpy.data.collections.get(AVALON_CONTAINERS).objects:
+ if obj.get(AVALON_PROPERTY).get('libpath') == libpath:
+ count += 1
+
+ if not asset_group:
+ return False
+
+ self._remove(asset_group)
+
+ bpy.data.objects.remove(asset_group)
+
+ # If it is the last object to use that library, remove it
+ if count == 1:
+ library = bpy.data.libraries.get(bpy.path.basename(libpath))
+ bpy.data.libraries.remove(library)
+
+ return True
diff --git a/openpype/hosts/blender/plugins/load/load_camera_fbx.py b/openpype/hosts/blender/plugins/load/load_camera_fbx.py
new file mode 100644
index 0000000000..5edba7ec0c
--- /dev/null
+++ b/openpype/hosts/blender/plugins/load/load_camera_fbx.py
@@ -0,0 +1,218 @@
+"""Load an asset in Blender from an Alembic file."""
+
+from pathlib import Path
+from pprint import pformat
+from typing import Dict, List, Optional
+
+import bpy
+
+from avalon import api
+from avalon.blender import lib
+from avalon.blender.pipeline import AVALON_CONTAINERS
+from avalon.blender.pipeline import AVALON_CONTAINER_ID
+from avalon.blender.pipeline import AVALON_PROPERTY
+from openpype.hosts.blender.api import plugin
+
+
+class FbxCameraLoader(plugin.AssetLoader):
+ """Load a camera from FBX.
+
+ Stores the imported asset in an empty named after the asset.
+ """
+
+ families = ["camera"]
+ representations = ["fbx"]
+
+ label = "Load Camera (FBX)"
+ icon = "code-fork"
+ color = "orange"
+
+ def _remove(self, asset_group):
+ objects = list(asset_group.children)
+
+ for obj in objects:
+ if obj.type == 'CAMERA':
+ bpy.data.cameras.remove(obj.data)
+ elif obj.type == 'EMPTY':
+ objects.extend(obj.children)
+ bpy.data.objects.remove(obj)
+
+ def _process(self, libpath, asset_group, group_name):
+ plugin.deselect_all()
+
+ collection = bpy.context.view_layer.active_layer_collection.collection
+
+ bpy.ops.import_scene.fbx(filepath=libpath)
+
+ parent = bpy.context.scene.collection
+
+ objects = lib.get_selection()
+
+ for obj in objects:
+ obj.parent = asset_group
+
+ for obj in objects:
+ parent.objects.link(obj)
+ collection.objects.unlink(obj)
+
+ for obj in objects:
+ name = obj.name
+ obj.name = f"{group_name}:{name}"
+ if obj.type != 'EMPTY':
+ name_data = obj.data.name
+ obj.data.name = f"{group_name}:{name_data}"
+
+ if not obj.get(AVALON_PROPERTY):
+ obj[AVALON_PROPERTY] = dict()
+
+ avalon_info = obj[AVALON_PROPERTY]
+ avalon_info.update({"container_name": group_name})
+
+ plugin.deselect_all()
+
+ return objects
+
+ def process_asset(
+ self, context: dict, name: str, namespace: Optional[str] = None,
+ options: Optional[Dict] = None
+ ) -> Optional[List]:
+ """
+ Arguments:
+ name: Use pre-defined name
+ namespace: Use pre-defined namespace
+ context: Full parenthood of representation to load
+ options: Additional settings dictionary
+ """
+ libpath = self.fname
+ asset = context["asset"]["name"]
+ subset = context["subset"]["name"]
+
+ asset_name = plugin.asset_name(asset, subset)
+ unique_number = plugin.get_unique_number(asset, subset)
+ group_name = plugin.asset_name(asset, subset, unique_number)
+ namespace = namespace or f"{asset}_{unique_number}"
+
+ avalon_container = bpy.data.collections.get(AVALON_CONTAINERS)
+ if not avalon_container:
+ avalon_container = bpy.data.collections.new(name=AVALON_CONTAINERS)
+ bpy.context.scene.collection.children.link(avalon_container)
+
+ asset_group = bpy.data.objects.new(group_name, object_data=None)
+ avalon_container.objects.link(asset_group)
+
+ objects = self._process(libpath, asset_group, group_name)
+
+ objects = []
+ nodes = list(asset_group.children)
+
+ for obj in nodes:
+ objects.append(obj)
+ nodes.extend(list(obj.children))
+
+ bpy.context.scene.collection.objects.link(asset_group)
+
+ asset_group[AVALON_PROPERTY] = {
+ "schema": "openpype:container-2.0",
+ "id": AVALON_CONTAINER_ID,
+ "name": name,
+ "namespace": namespace or '',
+ "loader": str(self.__class__.__name__),
+ "representation": str(context["representation"]["_id"]),
+ "libpath": libpath,
+ "asset_name": asset_name,
+ "parent": str(context["representation"]["parent"]),
+ "family": context["representation"]["context"]["family"],
+ "objectName": group_name
+ }
+
+ self[:] = objects
+ return objects
+
+ def exec_update(self, container: Dict, representation: Dict):
+ """Update the loaded asset.
+
+ This will remove all objects of the current collection, load the new
+ ones and add them to the collection.
+ If the objects of the collection are used in another collection they
+ will not be removed, only unlinked. Normally this should not be the
+ case though.
+
+ Warning:
+ No nested collections are supported at the moment!
+ """
+ object_name = container["objectName"]
+ asset_group = bpy.data.objects.get(object_name)
+ libpath = Path(api.get_representation_path(representation))
+ extension = libpath.suffix.lower()
+
+ self.log.info(
+ "Container: %s\nRepresentation: %s",
+ pformat(container, indent=2),
+ pformat(representation, indent=2),
+ )
+
+ assert asset_group, (
+ f"The asset is not loaded: {container['objectName']}"
+ )
+ assert libpath, (
+ "No existing library file found for {container['objectName']}"
+ )
+ assert libpath.is_file(), (
+ f"The file doesn't exist: {libpath}"
+ )
+ assert extension in plugin.VALID_EXTENSIONS, (
+ f"Unsupported file: {libpath}"
+ )
+
+ metadata = asset_group.get(AVALON_PROPERTY)
+ group_libpath = metadata["libpath"]
+
+ normalized_group_libpath = (
+ str(Path(bpy.path.abspath(group_libpath)).resolve())
+ )
+ normalized_libpath = (
+ str(Path(bpy.path.abspath(str(libpath))).resolve())
+ )
+ self.log.debug(
+ "normalized_group_libpath:\n %s\nnormalized_libpath:\n %s",
+ normalized_group_libpath,
+ normalized_libpath,
+ )
+ if normalized_group_libpath == normalized_libpath:
+ self.log.info("Library already loaded, not updating...")
+ return
+
+ mat = asset_group.matrix_basis.copy()
+
+ self._remove(asset_group)
+ self._process(str(libpath), asset_group, object_name)
+
+ asset_group.matrix_basis = mat
+
+ metadata["libpath"] = str(libpath)
+ metadata["representation"] = str(representation["_id"])
+
+ def exec_remove(self, container: Dict) -> bool:
+ """Remove an existing container from a Blender scene.
+
+ Arguments:
+ container (openpype:container-1.0): Container to remove,
+ from `host.ls()`.
+
+ Returns:
+ bool: Whether the container was deleted.
+
+ Warning:
+ No nested collections are supported at the moment!
+ """
+ object_name = container["objectName"]
+ asset_group = bpy.data.objects.get(object_name)
+
+ if not asset_group:
+ return False
+
+ self._remove(asset_group)
+
+ bpy.data.objects.remove(asset_group)
+
+ return True
diff --git a/openpype/hosts/blender/plugins/load/load_layout_json.py b/openpype/hosts/blender/plugins/load/load_layout_json.py
index 38718fd9b2..442cf05d85 100644
--- a/openpype/hosts/blender/plugins/load/load_layout_json.py
+++ b/openpype/hosts/blender/plugins/load/load_layout_json.py
@@ -12,6 +12,7 @@ from avalon.blender.pipeline import AVALON_CONTAINERS
from avalon.blender.pipeline import AVALON_CONTAINER_ID
from avalon.blender.pipeline import AVALON_PROPERTY
from avalon.blender.pipeline import AVALON_INSTANCES
+from openpype import lib
from openpype.hosts.blender.api import plugin
@@ -103,6 +104,21 @@ class JsonLayoutLoader(plugin.AssetLoader):
options=options
)
+ # Create the camera asset and the camera instance
+ creator_plugin = lib.get_creator_by_name("CreateCamera")
+ if not creator_plugin:
+ raise ValueError("Creator plugin \"CreateCamera\" was "
+ "not found.")
+
+ api.create(
+ creator_plugin,
+ name="camera",
+ # name=f"{unique_number}_{subset}_animation",
+ asset=asset,
+ options={"useSelection": False}
+ # data={"dependencies": str(context["representation"]["_id"])}
+ )
+
def process_asset(self,
context: dict,
name: str,
diff --git a/openpype/hosts/blender/plugins/publish/extract_camera.py b/openpype/hosts/blender/plugins/publish/extract_camera.py
new file mode 100644
index 0000000000..a0e78178c8
--- /dev/null
+++ b/openpype/hosts/blender/plugins/publish/extract_camera.py
@@ -0,0 +1,73 @@
+import os
+
+from openpype import api
+from openpype.hosts.blender.api import plugin
+
+import bpy
+
+
+class ExtractCamera(api.Extractor):
+ """Extract as the camera as FBX."""
+
+ label = "Extract Camera"
+ hosts = ["blender"]
+ families = ["camera"]
+ optional = True
+
+ def process(self, instance):
+ # Define extract output file path
+ stagingdir = self.staging_dir(instance)
+ filename = f"{instance.name}.fbx"
+ filepath = os.path.join(stagingdir, filename)
+
+ # Perform extraction
+ self.log.info("Performing extraction..")
+
+ plugin.deselect_all()
+
+ selected = []
+
+ camera = None
+
+ for obj in instance:
+ if obj.type == "CAMERA":
+ obj.select_set(True)
+ selected.append(obj)
+ camera = obj
+ break
+
+ assert camera, "No camera found"
+
+ context = plugin.create_blender_context(
+ active=camera, selected=selected)
+
+ scale_length = bpy.context.scene.unit_settings.scale_length
+ bpy.context.scene.unit_settings.scale_length = 0.01
+
+ # We export the fbx
+ bpy.ops.export_scene.fbx(
+ context,
+ filepath=filepath,
+ use_active_collection=False,
+ use_selection=True,
+ object_types={'CAMERA'},
+ bake_anim_simplify_factor=0.0
+ )
+
+ bpy.context.scene.unit_settings.scale_length = scale_length
+
+ plugin.deselect_all()
+
+ if "representations" not in instance.data:
+ instance.data["representations"] = []
+
+ representation = {
+ 'name': 'fbx',
+ 'ext': 'fbx',
+ 'files': filename,
+ "stagingDir": stagingdir,
+ }
+ instance.data["representations"].append(representation)
+
+ self.log.info("Extracted instance '%s' to: %s",
+ instance.name, representation)
diff --git a/openpype/hosts/blender/plugins/publish/validate_camera_zero_keyframe.py b/openpype/hosts/blender/plugins/publish/validate_camera_zero_keyframe.py
new file mode 100644
index 0000000000..39b9b67511
--- /dev/null
+++ b/openpype/hosts/blender/plugins/publish/validate_camera_zero_keyframe.py
@@ -0,0 +1,48 @@
+from typing import List
+
+import mathutils
+
+import pyblish.api
+import openpype.hosts.blender.api.action
+
+
+class ValidateCameraZeroKeyframe(pyblish.api.InstancePlugin):
+ """Camera must have a keyframe at frame 0.
+
+ Unreal shifts the first keyframe to frame 0. Forcing the camera to have
+ a keyframe at frame 0 will ensure that the animation will be the same
+ in Unreal and Blender.
+ """
+
+ order = openpype.api.ValidateContentsOrder
+ hosts = ["blender"]
+ families = ["camera"]
+ category = "geometry"
+ version = (0, 1, 0)
+ label = "Zero Keyframe"
+ actions = [openpype.hosts.blender.api.action.SelectInvalidAction]
+
+ _identity = mathutils.Matrix()
+
+ @classmethod
+ def get_invalid(cls, instance) -> List:
+ invalid = []
+ for obj in [obj for obj in instance]:
+ if obj.type == "CAMERA":
+ if obj.animation_data and obj.animation_data.action:
+ action = obj.animation_data.action
+ frames_set = set()
+ for fcu in action.fcurves:
+ for kp in fcu.keyframe_points:
+ frames_set.add(kp.co[0])
+ frames = list(frames_set)
+ frames.sort()
+ if frames[0] != 0.0:
+ invalid.append(obj)
+ return invalid
+
+ def process(self, instance):
+ invalid = self.get_invalid(instance)
+ if invalid:
+ raise RuntimeError(
+ f"Object found in instance is not in Object Mode: {invalid}")
diff --git a/openpype/hosts/flame/__init__.py b/openpype/hosts/flame/__init__.py
new file mode 100644
index 0000000000..48e8dc86c9
--- /dev/null
+++ b/openpype/hosts/flame/__init__.py
@@ -0,0 +1,105 @@
+from .api.utils import (
+ setup
+)
+
+from .api.pipeline import (
+ install,
+ uninstall,
+ ls,
+ containerise,
+ update_container,
+ maintained_selection,
+ remove_instance,
+ list_instances,
+ imprint
+)
+
+from .api.lib import (
+ FlameAppFramework,
+ maintain_current_timeline,
+ get_project_manager,
+ get_current_project,
+ get_current_timeline,
+ create_bin,
+)
+
+from .api.menu import (
+ FlameMenuProjectConnect,
+ FlameMenuTimeline
+)
+
+from .api.workio import (
+ open_file,
+ save_file,
+ current_file,
+ has_unsaved_changes,
+ file_extensions,
+ work_root
+)
+
+import os
+
+HOST_DIR = os.path.dirname(
+ os.path.abspath(__file__)
+)
+API_DIR = os.path.join(HOST_DIR, "api")
+PLUGINS_DIR = os.path.join(HOST_DIR, "plugins")
+PUBLISH_PATH = os.path.join(PLUGINS_DIR, "publish")
+LOAD_PATH = os.path.join(PLUGINS_DIR, "load")
+CREATE_PATH = os.path.join(PLUGINS_DIR, "create")
+INVENTORY_PATH = os.path.join(PLUGINS_DIR, "inventory")
+
+app_framework = None
+apps = []
+
+
+__all__ = [
+ "HOST_DIR",
+ "API_DIR",
+ "PLUGINS_DIR",
+ "PUBLISH_PATH",
+ "LOAD_PATH",
+ "CREATE_PATH",
+ "INVENTORY_PATH",
+ "INVENTORY_PATH",
+
+ "app_framework",
+ "apps",
+
+ # pipeline
+ "install",
+ "uninstall",
+ "ls",
+ "containerise",
+ "update_container",
+ "reload_pipeline",
+ "maintained_selection",
+ "remove_instance",
+ "list_instances",
+ "imprint",
+
+ # utils
+ "setup",
+
+ # lib
+ "FlameAppFramework",
+ "maintain_current_timeline",
+ "get_project_manager",
+ "get_current_project",
+ "get_current_timeline",
+ "create_bin",
+
+ # menu
+ "FlameMenuProjectConnect",
+ "FlameMenuTimeline",
+
+ # plugin
+
+ # workio
+ "open_file",
+ "save_file",
+ "current_file",
+ "has_unsaved_changes",
+ "file_extensions",
+ "work_root"
+]
diff --git a/openpype/hosts/flame/api/__init__.py b/openpype/hosts/flame/api/__init__.py
new file mode 100644
index 0000000000..50a6b3f098
--- /dev/null
+++ b/openpype/hosts/flame/api/__init__.py
@@ -0,0 +1,3 @@
+"""
+OpenPype Autodesk Flame api
+"""
diff --git a/openpype/hosts/flame/api/lib.py b/openpype/hosts/flame/api/lib.py
new file mode 100644
index 0000000000..48331dcbc2
--- /dev/null
+++ b/openpype/hosts/flame/api/lib.py
@@ -0,0 +1,276 @@
+import sys
+import os
+import pickle
+import contextlib
+from pprint import pformat
+
+from openpype.api import Logger
+
+log = Logger().get_logger(__name__)
+
+
+@contextlib.contextmanager
+def io_preferences_file(klass, filepath, write=False):
+ try:
+ flag = "w" if write else "r"
+ yield open(filepath, flag)
+
+ except IOError as _error:
+ klass.log.info("Unable to work with preferences `{}`: {}".format(
+ filepath, _error))
+
+
+class FlameAppFramework(object):
+ # flameAppFramework class takes care of preferences
+
+ class prefs_dict(dict):
+
+ def __init__(self, master, name, **kwargs):
+ self.name = name
+ self.master = master
+ if not self.master.get(self.name):
+ self.master[self.name] = {}
+ self.master[self.name].__init__()
+
+ def __getitem__(self, k):
+ return self.master[self.name].__getitem__(k)
+
+ def __setitem__(self, k, v):
+ return self.master[self.name].__setitem__(k, v)
+
+ def __delitem__(self, k):
+ return self.master[self.name].__delitem__(k)
+
+ def get(self, k, default=None):
+ return self.master[self.name].get(k, default)
+
+ def setdefault(self, k, default=None):
+ return self.master[self.name].setdefault(k, default)
+
+ def pop(self, k, v=object()):
+ if v is object():
+ return self.master[self.name].pop(k)
+ return self.master[self.name].pop(k, v)
+
+ def update(self, mapping=(), **kwargs):
+ self.master[self.name].update(mapping, **kwargs)
+
+ def __contains__(self, k):
+ return self.master[self.name].__contains__(k)
+
+ def copy(self): # don"t delegate w/ super - dict.copy() -> dict :(
+ return type(self)(self)
+
+ def keys(self):
+ return self.master[self.name].keys()
+
+ @classmethod
+ def fromkeys(cls, keys, v=None):
+ return cls.master[cls.name].fromkeys(keys, v)
+
+ def __repr__(self):
+ return "{0}({1})".format(
+ type(self).__name__, self.master[self.name].__repr__())
+
+ def master_keys(self):
+ return self.master.keys()
+
+ def __init__(self):
+ self.name = self.__class__.__name__
+ self.bundle_name = "OpenPypeFlame"
+ # self.prefs scope is limited to flame project and user
+ self.prefs = {}
+ self.prefs_user = {}
+ self.prefs_global = {}
+ self.log = log
+
+ try:
+ import flame
+ self.flame = flame
+ self.flame_project_name = self.flame.project.current_project.name
+ self.flame_user_name = flame.users.current_user.name
+ except Exception:
+ self.flame = None
+ self.flame_project_name = None
+ self.flame_user_name = None
+
+ import socket
+ self.hostname = socket.gethostname()
+
+ if sys.platform == "darwin":
+ self.prefs_folder = os.path.join(
+ os.path.expanduser("~"),
+ "Library",
+ "Caches",
+ "OpenPype",
+ self.bundle_name
+ )
+ elif sys.platform.startswith("linux"):
+ self.prefs_folder = os.path.join(
+ os.path.expanduser("~"),
+ ".OpenPype",
+ self.bundle_name)
+
+ self.prefs_folder = os.path.join(
+ self.prefs_folder,
+ self.hostname,
+ )
+
+ self.log.info("[{}] waking up".format(self.__class__.__name__))
+ self.load_prefs()
+
+ # menu auto-refresh defaults
+
+ if not self.prefs_global.get("menu_auto_refresh"):
+ self.prefs_global["menu_auto_refresh"] = {
+ "media_panel": True,
+ "batch": True,
+ "main_menu": True,
+ "timeline_menu": True
+ }
+
+ self.apps = []
+
+ def get_pref_file_paths(self):
+
+ prefix = self.prefs_folder + os.path.sep + self.bundle_name
+ prefs_file_path = "_".join([
+ prefix, self.flame_user_name,
+ self.flame_project_name]) + ".prefs"
+ prefs_user_file_path = "_".join([
+ prefix, self.flame_user_name]) + ".prefs"
+ prefs_global_file_path = prefix + ".prefs"
+
+ return (prefs_file_path, prefs_user_file_path, prefs_global_file_path)
+
+ def load_prefs(self):
+
+ (proj_pref_path, user_pref_path,
+ glob_pref_path) = self.get_pref_file_paths()
+
+ with io_preferences_file(self, proj_pref_path) as prefs_file:
+ self.prefs = pickle.load(prefs_file)
+ self.log.info(
+ "Project - preferences contents:\n{}".format(
+ pformat(self.prefs)
+ ))
+
+ with io_preferences_file(self, user_pref_path) as prefs_file:
+ self.prefs_user = pickle.load(prefs_file)
+ self.log.info(
+ "User - preferences contents:\n{}".format(
+ pformat(self.prefs_user)
+ ))
+
+ with io_preferences_file(self, glob_pref_path) as prefs_file:
+ self.prefs_global = pickle.load(prefs_file)
+ self.log.info(
+ "Global - preferences contents:\n{}".format(
+ pformat(self.prefs_global)
+ ))
+
+ return True
+
+ def save_prefs(self):
+ # make sure the preference folder is available
+ if not os.path.isdir(self.prefs_folder):
+ try:
+ os.makedirs(self.prefs_folder)
+ except Exception:
+ self.log.info("Unable to create folder {}".format(
+ self.prefs_folder))
+ return False
+
+ # get all pref file paths
+ (proj_pref_path, user_pref_path,
+ glob_pref_path) = self.get_pref_file_paths()
+
+ with io_preferences_file(self, proj_pref_path, True) as prefs_file:
+ pickle.dump(self.prefs, prefs_file)
+ self.log.info(
+ "Project - preferences contents:\n{}".format(
+ pformat(self.prefs)
+ ))
+
+ with io_preferences_file(self, user_pref_path, True) as prefs_file:
+ pickle.dump(self.prefs_user, prefs_file)
+ self.log.info(
+ "User - preferences contents:\n{}".format(
+ pformat(self.prefs_user)
+ ))
+
+ with io_preferences_file(self, glob_pref_path, True) as prefs_file:
+ pickle.dump(self.prefs_global, prefs_file)
+ self.log.info(
+ "Global - preferences contents:\n{}".format(
+ pformat(self.prefs_global)
+ ))
+
+ return True
+
+
+@contextlib.contextmanager
+def maintain_current_timeline(to_timeline, from_timeline=None):
+ """Maintain current timeline selection during context
+
+ Attributes:
+ from_timeline (resolve.Timeline)[optional]:
+ Example:
+ >>> print(from_timeline.GetName())
+ timeline1
+ >>> print(to_timeline.GetName())
+ timeline2
+
+ >>> with maintain_current_timeline(to_timeline):
+ ... print(get_current_timeline().GetName())
+ timeline2
+
+ >>> print(get_current_timeline().GetName())
+ timeline1
+ """
+ # todo: this is still Resolve's implementation
+ project = get_current_project()
+ working_timeline = from_timeline or project.GetCurrentTimeline()
+
+ # swith to the input timeline
+ project.SetCurrentTimeline(to_timeline)
+
+ try:
+ # do a work
+ yield
+ finally:
+ # put the original working timeline to context
+ project.SetCurrentTimeline(working_timeline)
+
+
+def get_project_manager():
+ # TODO: get_project_manager
+ return
+
+
+def get_media_storage():
+ # TODO: get_media_storage
+ return
+
+
+def get_current_project():
+ # TODO: get_current_project
+ return
+
+
+def get_current_timeline(new=False):
+ # TODO: get_current_timeline
+ return
+
+
+def create_bin(name, root=None):
+ # TODO: create_bin
+ return
+
+
+def rescan_hooks():
+ import flame
+ try:
+ flame.execute_shortcut('Rescan Python Hooks')
+ except Exception:
+ pass
diff --git a/openpype/hosts/flame/api/menu.py b/openpype/hosts/flame/api/menu.py
new file mode 100644
index 0000000000..b4f1728acf
--- /dev/null
+++ b/openpype/hosts/flame/api/menu.py
@@ -0,0 +1,208 @@
+import os
+from Qt import QtWidgets
+from copy import deepcopy
+
+from openpype.tools.utils.host_tools import HostToolsHelper
+
+
+menu_group_name = 'OpenPype'
+
+default_flame_export_presets = {
+ 'Publish': {
+ 'PresetVisibility': 2,
+ 'PresetType': 0,
+ 'PresetFile': 'OpenEXR/OpenEXR (16-bit fp PIZ).xml'
+ },
+ 'Preview': {
+ 'PresetVisibility': 3,
+ 'PresetType': 2,
+ 'PresetFile': 'Generate Preview.xml'
+ },
+ 'Thumbnail': {
+ 'PresetVisibility': 3,
+ 'PresetType': 0,
+ 'PresetFile': 'Generate Thumbnail.xml'
+ }
+}
+
+
+class _FlameMenuApp(object):
+ def __init__(self, framework):
+ self.name = self.__class__.__name__
+ self.framework = framework
+ self.log = framework.log
+ self.menu_group_name = menu_group_name
+ self.dynamic_menu_data = {}
+
+ # flame module is only avaliable when a
+ # flame project is loaded and initialized
+ self.flame = None
+ try:
+ import flame
+ self.flame = flame
+ except ImportError:
+ self.flame = None
+
+ self.flame_project_name = flame.project.current_project.name
+ self.prefs = self.framework.prefs_dict(self.framework.prefs, self.name)
+ self.prefs_user = self.framework.prefs_dict(
+ self.framework.prefs_user, self.name)
+ self.prefs_global = self.framework.prefs_dict(
+ self.framework.prefs_global, self.name)
+
+ self.mbox = QtWidgets.QMessageBox()
+
+ self.menu = {
+ "actions": [{
+ 'name': os.getenv("AVALON_PROJECT", "project"),
+ 'isEnabled': False
+ }],
+ "name": self.menu_group_name
+ }
+ self.tools_helper = HostToolsHelper()
+
+ def __getattr__(self, name):
+ def method(*args, **kwargs):
+ print('calling %s' % name)
+ return method
+
+ def rescan(self, *args, **kwargs):
+ if not self.flame:
+ try:
+ import flame
+ self.flame = flame
+ except ImportError:
+ self.flame = None
+
+ if self.flame:
+ self.flame.execute_shortcut('Rescan Python Hooks')
+ self.log.info('Rescan Python Hooks')
+
+
+class FlameMenuProjectConnect(_FlameMenuApp):
+
+ # flameMenuProjectconnect app takes care of the preferences dialog as well
+
+ def __init__(self, framework):
+ _FlameMenuApp.__init__(self, framework)
+
+ def __getattr__(self, name):
+ def method(*args, **kwargs):
+ project = self.dynamic_menu_data.get(name)
+ if project:
+ self.link_project(project)
+ return method
+
+ def build_menu(self):
+ if not self.flame:
+ return []
+
+ flame_project_name = self.flame_project_name
+ self.log.info("______ {} ______".format(flame_project_name))
+
+ menu = deepcopy(self.menu)
+
+ menu['actions'].append({
+ "name": "Workfiles ...",
+ "execute": lambda x: self.tools_helper.show_workfiles()
+ })
+ menu['actions'].append({
+ "name": "Create ...",
+ "execute": lambda x: self.tools_helper.show_creator()
+ })
+ menu['actions'].append({
+ "name": "Publish ...",
+ "execute": lambda x: self.tools_helper.show_publish()
+ })
+ menu['actions'].append({
+ "name": "Load ...",
+ "execute": lambda x: self.tools_helper.show_loader()
+ })
+ menu['actions'].append({
+ "name": "Manage ...",
+ "execute": lambda x: self.tools_helper.show_scene_inventory()
+ })
+ menu['actions'].append({
+ "name": "Library ...",
+ "execute": lambda x: self.tools_helper.show_library_loader()
+ })
+ return menu
+
+ def get_projects(self, *args, **kwargs):
+ pass
+
+ def refresh(self, *args, **kwargs):
+ self.rescan()
+
+ def rescan(self, *args, **kwargs):
+ if not self.flame:
+ try:
+ import flame
+ self.flame = flame
+ except ImportError:
+ self.flame = None
+
+ if self.flame:
+ self.flame.execute_shortcut('Rescan Python Hooks')
+ self.log.info('Rescan Python Hooks')
+
+
+class FlameMenuTimeline(_FlameMenuApp):
+
+ # flameMenuProjectconnect app takes care of the preferences dialog as well
+
+ def __init__(self, framework):
+ _FlameMenuApp.__init__(self, framework)
+
+ def __getattr__(self, name):
+ def method(*args, **kwargs):
+ project = self.dynamic_menu_data.get(name)
+ if project:
+ self.link_project(project)
+ return method
+
+ def build_menu(self):
+ if not self.flame:
+ return []
+
+ flame_project_name = self.flame_project_name
+ self.log.info("______ {} ______".format(flame_project_name))
+
+ menu = deepcopy(self.menu)
+
+ menu['actions'].append({
+ "name": "Create ...",
+ "execute": lambda x: self.tools_helper.show_creator()
+ })
+ menu['actions'].append({
+ "name": "Publish ...",
+ "execute": lambda x: self.tools_helper.show_publish()
+ })
+ menu['actions'].append({
+ "name": "Load ...",
+ "execute": lambda x: self.tools_helper.show_loader()
+ })
+ menu['actions'].append({
+ "name": "Manage ...",
+ "execute": lambda x: self.tools_helper.show_scene_inventory()
+ })
+
+ return menu
+
+ def get_projects(self, *args, **kwargs):
+ pass
+
+ def refresh(self, *args, **kwargs):
+ self.rescan()
+
+ def rescan(self, *args, **kwargs):
+ if not self.flame:
+ try:
+ import flame
+ self.flame = flame
+ except ImportError:
+ self.flame = None
+
+ if self.flame:
+ self.flame.execute_shortcut('Rescan Python Hooks')
+ self.log.info('Rescan Python Hooks')
diff --git a/openpype/hosts/flame/api/pipeline.py b/openpype/hosts/flame/api/pipeline.py
new file mode 100644
index 0000000000..26dfe7c032
--- /dev/null
+++ b/openpype/hosts/flame/api/pipeline.py
@@ -0,0 +1,155 @@
+"""
+Basic avalon integration
+"""
+import contextlib
+from avalon import api as avalon
+from pyblish import api as pyblish
+from openpype.api import Logger
+
+AVALON_CONTAINERS = "AVALON_CONTAINERS"
+
+log = Logger().get_logger(__name__)
+
+
+def install():
+ from .. import (
+ PUBLISH_PATH,
+ LOAD_PATH,
+ CREATE_PATH,
+ INVENTORY_PATH
+ )
+ # TODO: install
+
+ # Disable all families except for the ones we explicitly want to see
+ family_states = [
+ "imagesequence",
+ "render2d",
+ "plate",
+ "render",
+ "mov",
+ "clip"
+ ]
+ avalon.data["familiesStateDefault"] = False
+ avalon.data["familiesStateToggled"] = family_states
+
+ log.info("openpype.hosts.flame installed")
+
+ pyblish.register_host("flame")
+ pyblish.register_plugin_path(PUBLISH_PATH)
+ log.info("Registering Flame plug-ins..")
+
+ avalon.register_plugin_path(avalon.Loader, LOAD_PATH)
+ avalon.register_plugin_path(avalon.Creator, CREATE_PATH)
+ avalon.register_plugin_path(avalon.InventoryAction, INVENTORY_PATH)
+
+ # register callback for switching publishable
+ pyblish.register_callback("instanceToggled", on_pyblish_instance_toggled)
+
+
+def uninstall():
+ from .. import (
+ PUBLISH_PATH,
+ LOAD_PATH,
+ CREATE_PATH,
+ INVENTORY_PATH
+ )
+
+ # TODO: uninstall
+ pyblish.deregister_host("flame")
+ pyblish.deregister_plugin_path(PUBLISH_PATH)
+ log.info("Deregistering DaVinci Resovle plug-ins..")
+
+ avalon.deregister_plugin_path(avalon.Loader, LOAD_PATH)
+ avalon.deregister_plugin_path(avalon.Creator, CREATE_PATH)
+ avalon.deregister_plugin_path(avalon.InventoryAction, INVENTORY_PATH)
+
+ # register callback for switching publishable
+ pyblish.deregister_callback("instanceToggled", on_pyblish_instance_toggled)
+
+
+def containerise(tl_segment,
+ name,
+ namespace,
+ context,
+ loader=None,
+ data=None):
+ # TODO: containerise
+ pass
+
+
+def ls():
+ """List available containers.
+ """
+ # TODO: ls
+ pass
+
+
+def parse_container(tl_segment, validate=True):
+ """Return container data from timeline_item's openpype tag.
+ """
+ # TODO: parse_container
+ pass
+
+
+def update_container(tl_segment, data=None):
+ """Update container data to input timeline_item's openpype tag.
+ """
+ # TODO: update_container
+ pass
+
+
+@contextlib.contextmanager
+def maintained_selection():
+ """Maintain selection during context
+
+ Example:
+ >>> with maintained_selection():
+ ... node['selected'].setValue(True)
+ >>> print(node['selected'].value())
+ False
+ """
+ # TODO: maintained_selection + remove undo steps
+
+ try:
+ # do the operation
+ yield
+ finally:
+ pass
+
+
+def reset_selection():
+ """Deselect all selected nodes
+ """
+ pass
+
+
+def on_pyblish_instance_toggled(instance, old_value, new_value):
+ """Toggle node passthrough states on instance toggles."""
+
+ log.info("instance toggle: {}, old_value: {}, new_value:{} ".format(
+ instance, old_value, new_value))
+
+ # from openpype.hosts.resolve import (
+ # set_publish_attribute
+ # )
+
+ # # Whether instances should be passthrough based on new value
+ # timeline_item = instance.data["item"]
+ # set_publish_attribute(timeline_item, new_value)
+
+
+def remove_instance(instance):
+ """Remove instance marker from track item."""
+ # TODO: remove_instance
+ pass
+
+
+def list_instances():
+ """List all created instances from current workfile."""
+ # TODO: list_instances
+ pass
+
+
+def imprint(item, data=None):
+ # TODO: imprint
+ pass
diff --git a/openpype/hosts/flame/api/plugin.py b/openpype/hosts/flame/api/plugin.py
new file mode 100644
index 0000000000..2a28a20a75
--- /dev/null
+++ b/openpype/hosts/flame/api/plugin.py
@@ -0,0 +1,3 @@
+# Creator plugin functions
+# Publishing plugin functions
+# Loader plugin functions
diff --git a/openpype/hosts/flame/api/utils.py b/openpype/hosts/flame/api/utils.py
new file mode 100644
index 0000000000..a750046362
--- /dev/null
+++ b/openpype/hosts/flame/api/utils.py
@@ -0,0 +1,108 @@
+"""
+Flame utils for syncing scripts
+"""
+
+import os
+import shutil
+from openpype.api import Logger
+log = Logger().get_logger(__name__)
+
+
+def _sync_utility_scripts(env=None):
+ """ Synchronizing basic utlility scripts for flame.
+
+ To be able to run start OpenPype within Flame we have to copy
+ all utility_scripts and additional FLAME_SCRIPT_DIR into
+ `/opt/Autodesk/shared/python`. This will be always synchronizing those
+ folders.
+ """
+ from .. import HOST_DIR
+
+ env = env or os.environ
+
+ # initiate inputs
+ scripts = {}
+ fsd_env = env.get("FLAME_SCRIPT_DIRS", "")
+ flame_shared_dir = "/opt/Autodesk/shared/python"
+
+ fsd_paths = [os.path.join(
+ HOST_DIR,
+ "utility_scripts"
+ )]
+
+ # collect script dirs
+ log.info("FLAME_SCRIPT_DIRS: `{fsd_env}`".format(**locals()))
+ log.info("fsd_paths: `{fsd_paths}`".format(**locals()))
+
+ # add application environment setting for FLAME_SCRIPT_DIR
+ # to script path search
+ for _dirpath in fsd_env.split(os.pathsep):
+ if not os.path.isdir(_dirpath):
+ log.warning("Path is not a valid dir: `{_dirpath}`".format(
+ **locals()))
+ continue
+ fsd_paths.append(_dirpath)
+
+ # collect scripts from dirs
+ for path in fsd_paths:
+ scripts.update({path: os.listdir(path)})
+
+ remove_black_list = []
+ for _k, s_list in scripts.items():
+ remove_black_list += s_list
+
+ log.info("remove_black_list: `{remove_black_list}`".format(**locals()))
+ log.info("Additional Flame script paths: `{fsd_paths}`".format(**locals()))
+ log.info("Flame Scripts: `{scripts}`".format(**locals()))
+
+ # make sure no script file is in folder
+ if next(iter(os.listdir(flame_shared_dir)), None):
+ for _itm in os.listdir(flame_shared_dir):
+ skip = False
+
+ # skip all scripts and folders which are not maintained
+ if _itm not in remove_black_list:
+ skip = True
+
+ # do not skyp if pyc in extension
+ if not os.path.isdir(_itm) and "pyc" in os.path.splitext(_itm)[-1]:
+ skip = False
+
+ # continue if skip in true
+ if skip:
+ continue
+
+ path = os.path.join(flame_shared_dir, _itm)
+ log.info("Removing `{path}`...".format(**locals()))
+ if os.path.isdir(path):
+ shutil.rmtree(path, onerror=None)
+ else:
+ os.remove(path)
+
+ # copy scripts into Resolve's utility scripts dir
+ for dirpath, scriptlist in scripts.items():
+ # directory and scripts list
+ for _script in scriptlist:
+ # script in script list
+ src = os.path.join(dirpath, _script)
+ dst = os.path.join(flame_shared_dir, _script)
+ log.info("Copying `{src}` to `{dst}`...".format(**locals()))
+ if os.path.isdir(src):
+ shutil.copytree(
+ src, dst, symlinks=False,
+ ignore=None, ignore_dangling_symlinks=False
+ )
+ else:
+ shutil.copy2(src, dst)
+
+
+def setup(env=None):
+ """ Wrapper installer started from
+ `flame/hooks/pre_flame_setup.py`
+ """
+ env = env or os.environ
+
+ # synchronize resolve utility scripts
+ _sync_utility_scripts(env)
+
+ log.info("Flame OpenPype wrapper has been installed")
diff --git a/openpype/hosts/flame/api/workio.py b/openpype/hosts/flame/api/workio.py
new file mode 100644
index 0000000000..d2e2408798
--- /dev/null
+++ b/openpype/hosts/flame/api/workio.py
@@ -0,0 +1,37 @@
+"""Host API required Work Files tool"""
+
+import os
+from openpype.api import Logger
+# from .. import (
+# get_project_manager,
+# get_current_project
+# )
+
+
+log = Logger().get_logger(__name__)
+
+exported_projet_ext = ".otoc"
+
+
+def file_extensions():
+ return [exported_projet_ext]
+
+
+def has_unsaved_changes():
+ pass
+
+
+def save_file(filepath):
+ pass
+
+
+def open_file(filepath):
+ pass
+
+
+def current_file():
+ pass
+
+
+def work_root(session):
+ return os.path.normpath(session["AVALON_WORKDIR"]).replace("\\", "/")
diff --git a/openpype/hosts/flame/hooks/pre_flame_setup.py b/openpype/hosts/flame/hooks/pre_flame_setup.py
new file mode 100644
index 0000000000..368a70f395
--- /dev/null
+++ b/openpype/hosts/flame/hooks/pre_flame_setup.py
@@ -0,0 +1,132 @@
+import os
+import json
+import tempfile
+import contextlib
+from openpype.lib import (
+ PreLaunchHook, get_openpype_username)
+from openpype.hosts import flame as opflame
+import openpype
+from pprint import pformat
+
+
+class FlamePrelaunch(PreLaunchHook):
+ """ Flame prelaunch hook
+
+ Will make sure flame_script_dirs are coppied to user's folder defined
+ in environment var FLAME_SCRIPT_DIR.
+ """
+ app_groups = ["flame"]
+
+ # todo: replace version number with avalon launch app version
+ flame_python_exe = "/opt/Autodesk/python/2021/bin/python2.7"
+
+ wtc_script_path = os.path.join(
+ opflame.HOST_DIR, "scripts", "wiretap_com.py")
+
+ def __init__(self, *args, **kwargs):
+ super().__init__(*args, **kwargs)
+
+ self.signature = "( {} )".format(self.__class__.__name__)
+
+ def execute(self):
+ """Hook entry method."""
+ project_doc = self.data["project_doc"]
+ user_name = get_openpype_username()
+
+ self.log.debug("Collected user \"{}\"".format(user_name))
+ self.log.info(pformat(project_doc))
+ _db_p_data = project_doc["data"]
+ width = _db_p_data["resolutionWidth"]
+ height = _db_p_data["resolutionHeight"]
+ fps = int(_db_p_data["fps"])
+
+ project_data = {
+ "Name": project_doc["name"],
+ "Nickname": _db_p_data["code"],
+ "Description": "Created by OpenPype",
+ "SetupDir": project_doc["name"],
+ "FrameWidth": int(width),
+ "FrameHeight": int(height),
+ "AspectRatio": float((width / height) * _db_p_data["pixelAspect"]),
+ "FrameRate": "{} fps".format(fps),
+ "FrameDepth": "16-bit fp",
+ "FieldDominance": "PROGRESSIVE"
+ }
+
+ data_to_script = {
+ # from settings
+ "host_name": "localhost",
+ "volume_name": "stonefs",
+ "group_name": "staff",
+ "color_policy": "ACES 1.1",
+
+ # from project
+ "project_name": project_doc["name"],
+ "user_name": user_name,
+ "project_data": project_data
+ }
+ app_arguments = self._get_launch_arguments(data_to_script)
+
+ self.log.info(pformat(dict(self.launch_context.env)))
+
+ opflame.setup(self.launch_context.env)
+
+ self.launch_context.launch_args.extend(app_arguments)
+
+ def _get_launch_arguments(self, script_data):
+ # Dump data to string
+ dumped_script_data = json.dumps(script_data)
+
+ with make_temp_file(dumped_script_data) as tmp_json_path:
+ # Prepare subprocess arguments
+ args = [
+ self.flame_python_exe,
+ self.wtc_script_path,
+ tmp_json_path
+ ]
+ self.log.info("Executing: {}".format(" ".join(args)))
+
+ process_kwargs = {
+ "logger": self.log,
+ "env": {}
+ }
+
+ openpype.api.run_subprocess(args, **process_kwargs)
+
+ # process returned json file to pass launch args
+ return_json_data = open(tmp_json_path).read()
+ returned_data = json.loads(return_json_data)
+ app_args = returned_data.get("app_args")
+ self.log.info("____ app_args: `{}`".format(app_args))
+
+ if not app_args:
+ RuntimeError("App arguments were not solved")
+
+ return app_args
+
+
+@contextlib.contextmanager
+def make_temp_file(data):
+ try:
+ # Store dumped json to temporary file
+ temporary_json_file = tempfile.NamedTemporaryFile(
+ mode="w", suffix=".json", delete=False
+ )
+ temporary_json_file.write(data)
+ temporary_json_file.close()
+ temporary_json_filepath = temporary_json_file.name.replace(
+ "\\", "/"
+ )
+
+ yield temporary_json_filepath
+
+ except IOError as _error:
+ raise IOError(
+ "Not able to create temp json file: {}".format(
+ _error
+ )
+ )
+
+ finally:
+ # Remove the temporary json
+ os.remove(temporary_json_filepath)
diff --git a/openpype/hosts/flame/scripts/wiretap_com.py b/openpype/hosts/flame/scripts/wiretap_com.py
new file mode 100644
index 0000000000..d8dc1884cf
--- /dev/null
+++ b/openpype/hosts/flame/scripts/wiretap_com.py
@@ -0,0 +1,490 @@
+#!/usr/bin/env python2.7
+# -*- coding: utf-8 -*-
+
+from __future__ import absolute_import
+import os
+import sys
+import subprocess
+import json
+import xml.dom.minidom as minidom
+from copy import deepcopy
+import datetime
+
+try:
+ from libwiretapPythonClientAPI import (
+ WireTapClientInit)
+except ImportError:
+ flame_python_path = "/opt/Autodesk/flame_2021/python"
+ flame_exe_path = (
+ "/opt/Autodesk/flame_2021/bin/flame.app"
+ "/Contents/MacOS/startApp")
+
+ sys.path.append(flame_python_path)
+
+ from libwiretapPythonClientAPI import (
+ WireTapClientInit,
+ WireTapClientUninit,
+ WireTapNodeHandle,
+ WireTapServerHandle,
+ WireTapInt,
+ WireTapStr
+ )
+
+
+class WireTapCom(object):
+ """
+ Comunicator class wrapper for talking to WireTap db.
+
+ This way we are able to set new project with settings and
+ correct colorspace policy. Also we are able to create new user
+ or get actuall user with similar name (users are usually cloning
+ their profiles and adding date stamp into suffix).
+ """
+
+ def __init__(self, host_name=None, volume_name=None, group_name=None):
+ """Initialisation of WireTap communication class
+
+ Args:
+ host_name (str, optional): Name of host server. Defaults to None.
+ volume_name (str, optional): Name of volume. Defaults to None.
+ group_name (str, optional): Name of user group. Defaults to None.
+ """
+ # set main attributes of server
+ # if there are none set the default installation
+ self.host_name = host_name or "localhost"
+ self.volume_name = volume_name or "stonefs"
+ self.group_name = group_name or "staff"
+
+ # initialize WireTap client
+ WireTapClientInit()
+
+ # add the server to shared variable
+ self._server = WireTapServerHandle("{}:IFFFS".format(self.host_name))
+ print("WireTap connected at '{}'...".format(
+ self.host_name))
+
+ def close(self):
+ self._server = None
+ WireTapClientUninit()
+ print("WireTap closed...")
+
+ def get_launch_args(
+ self, project_name, project_data, user_name, *args, **kwargs):
+ """Forming launch arguments for OpenPype launcher.
+
+ Args:
+ project_name (str): name of project
+ project_data (dict): Flame compatible project data
+ user_name (str): name of user
+
+ Returns:
+ list: arguments
+ """
+
+ workspace_name = kwargs.get("workspace_name")
+ color_policy = kwargs.get("color_policy")
+
+ self._project_prep(project_name)
+ self._set_project_settings(project_name, project_data)
+ self._set_project_colorspace(project_name, color_policy)
+ user_name = self._user_prep(user_name)
+
+ if workspace_name is None:
+ # default workspace
+ print("Using a default workspace")
+ return [
+ "--start-project={}".format(project_name),
+ "--start-user={}".format(user_name),
+ "--create-workspace"
+ ]
+
+ else:
+ print(
+ "Using a custom workspace '{}'".format(workspace_name))
+
+ self._workspace_prep(project_name, workspace_name)
+ return [
+ "--start-project={}".format(project_name),
+ "--start-user={}".format(user_name),
+ "--create-workspace",
+ "--start-workspace={}".format(workspace_name)
+ ]
+
+ def _workspace_prep(self, project_name, workspace_name):
+ """Preparing a workspace
+
+ In case it doesn not exists it will create one
+
+ Args:
+ project_name (str): project name
+ workspace_name (str): workspace name
+
+ Raises:
+ AttributeError: unable to create workspace
+ """
+ workspace_exists = self._child_is_in_parent_path(
+ "/projects/{}".format(project_name), workspace_name, "WORKSPACE"
+ )
+ if not workspace_exists:
+ project = WireTapNodeHandle(
+ self._server, "/projects/{}".format(project_name))
+
+ workspace_node = WireTapNodeHandle()
+ created_workspace = project.createNode(
+ workspace_name, "WORKSPACE", workspace_node)
+
+ if not created_workspace:
+ raise AttributeError(
+ "Cannot create workspace `{}` in "
+ "project `{}`: `{}`".format(
+ workspace_name, project_name, project.lastError())
+ )
+
+ print(
+ "Workspace `{}` is successfully created".format(workspace_name))
+
+ def _project_prep(self, project_name):
+ """Preparing a project
+
+ In case it doesn not exists it will create one
+
+ Args:
+ project_name (str): project name
+
+ Raises:
+ AttributeError: unable to create project
+ """
+ # test if projeft exists
+ project_exists = self._child_is_in_parent_path(
+ "/projects", project_name, "PROJECT")
+
+ if not project_exists:
+ volumes = self._get_all_volumes()
+
+ if len(volumes) == 0:
+ raise AttributeError(
+ "Not able to create new project. No Volumes existing"
+ )
+
+ # check if volumes exists
+ if self.volume_name not in volumes:
+ raise AttributeError(
+ ("Volume '{}' does not exist '{}'").format(
+ self.volume_name, volumes)
+ )
+
+ # form cmd arguments
+ project_create_cmd = [
+ os.path.join(
+ "/opt/Autodesk/",
+ "wiretap",
+ "tools",
+ "2021",
+ "wiretap_create_node",
+ ),
+ '-n',
+ os.path.join("/volumes", self.volume_name),
+ '-d',
+ project_name,
+ '-g',
+ ]
+
+ project_create_cmd.append(self.group_name)
+
+ print(project_create_cmd)
+
+ exit_code = subprocess.call(
+ project_create_cmd,
+ cwd=os.path.expanduser('~'))
+
+ if exit_code != 0:
+ RuntimeError("Cannot create project in flame db")
+
+ print(
+ "A new project '{}' is created.".format(project_name))
+
+ def _get_all_volumes(self):
+ """Request all available volumens from WireTap
+
+ Returns:
+ list: all available volumes in server
+
+ Rises:
+ AttributeError: unable to get any volumes childs from server
+ """
+ root = WireTapNodeHandle(self._server, "/volumes")
+ children_num = WireTapInt(0)
+
+ get_children_num = root.getNumChildren(children_num)
+ if not get_children_num:
+ raise AttributeError(
+ "Cannot get number of volumes: {}".format(root.lastError())
+ )
+
+ volumes = []
+
+ # go trough all children and get volume names
+ child_obj = WireTapNodeHandle()
+ for child_idx in range(children_num):
+
+ # get a child
+ if not root.getChild(child_idx, child_obj):
+ raise AttributeError(
+ "Unable to get child: {}".format(root.lastError()))
+
+ node_name = WireTapStr()
+ get_children_name = child_obj.getDisplayName(node_name)
+
+ if not get_children_name:
+ raise AttributeError(
+ "Unable to get child name: {}".format(
+ child_obj.lastError())
+ )
+
+ volumes.append(node_name.c_str())
+
+ return volumes
+
+ def _user_prep(self, user_name):
+ """Ensuring user does exists in user's stack
+
+ Args:
+ user_name (str): name of a user
+
+ Raises:
+ AttributeError: unable to create user
+ """
+
+ # get all used usernames in db
+ used_names = self._get_usernames()
+ print(">> used_names: {}".format(used_names))
+
+ # filter only those which are sharing input user name
+ filtered_users = [user for user in used_names if user_name in user]
+
+ if filtered_users:
+ # todo: need to find lastly created following regex patern for
+ # date used in name
+ return filtered_users.pop()
+
+ # create new user name with date in suffix
+ now = datetime.datetime.now() # current date and time
+ date = now.strftime("%Y%m%d")
+ new_user_name = "{}_{}".format(user_name, date)
+ print(new_user_name)
+
+ if not self._child_is_in_parent_path("/users", new_user_name, "USER"):
+ # Create the new user
+ users = WireTapNodeHandle(self._server, "/users")
+
+ user_node = WireTapNodeHandle()
+ created_user = users.createNode(new_user_name, "USER", user_node)
+ if not created_user:
+ raise AttributeError(
+ "User {} cannot be created: {}".format(
+ new_user_name, users.lastError())
+ )
+
+ print("User `{}` is created".format(new_user_name))
+ return new_user_name
+
+ def _get_usernames(self):
+ """Requesting all available users from WireTap
+
+ Returns:
+ list: all available user names
+
+ Raises:
+ AttributeError: there are no users in server
+ """
+ root = WireTapNodeHandle(self._server, "/users")
+ children_num = WireTapInt(0)
+
+ get_children_num = root.getNumChildren(children_num)
+ if not get_children_num:
+ raise AttributeError(
+ "Cannot get number of volumes: {}".format(root.lastError())
+ )
+
+ usernames = []
+
+ # go trough all children and get volume names
+ child_obj = WireTapNodeHandle()
+ for child_idx in range(children_num):
+
+ # get a child
+ if not root.getChild(child_idx, child_obj):
+ raise AttributeError(
+ "Unable to get child: {}".format(root.lastError()))
+
+ node_name = WireTapStr()
+ get_children_name = child_obj.getDisplayName(node_name)
+
+ if not get_children_name:
+ raise AttributeError(
+ "Unable to get child name: {}".format(
+ child_obj.lastError())
+ )
+
+ usernames.append(node_name.c_str())
+
+ return usernames
+
+ def _child_is_in_parent_path(self, parent_path, child_name, child_type):
+ """Checking if a given child is in parent path.
+
+ Args:
+ parent_path (str): db path to parent
+ child_name (str): name of child
+ child_type (str): type of child
+
+ Raises:
+ AttributeError: Not able to get number of children
+ AttributeError: Not able to get children form parent
+ AttributeError: Not able to get children name
+ AttributeError: Not able to get children type
+
+ Returns:
+ bool: True if child is in parent path
+ """
+ parent = WireTapNodeHandle(self._server, parent_path)
+
+ # iterate number of children
+ children_num = WireTapInt(0)
+ requested = parent.getNumChildren(children_num)
+ if not requested:
+ raise AttributeError((
+ "Error: Cannot request number of "
+ "childrens from the node {}. Make sure your "
+ "wiretap service is running: {}").format(
+ parent_path, parent.lastError())
+ )
+
+ # iterate children
+ child_obj = WireTapNodeHandle()
+ for child_idx in range(children_num):
+ if not parent.getChild(child_idx, child_obj):
+ raise AttributeError(
+ "Cannot get child: {}".format(
+ parent.lastError()))
+
+ node_name = WireTapStr()
+ node_type = WireTapStr()
+
+ if not child_obj.getDisplayName(node_name):
+ raise AttributeError(
+ "Unable to get child name: %s" % child_obj.lastError()
+ )
+ if not child_obj.getNodeTypeStr(node_type):
+ raise AttributeError(
+ "Unable to obtain child type: %s" % child_obj.lastError()
+ )
+
+ if (node_name.c_str() == child_name) and (
+ node_type.c_str() == child_type):
+ return True
+
+ return False
+
+ def _set_project_settings(self, project_name, project_data):
+ """Setting project attributes.
+
+ Args:
+ project_name (str): name of project
+ project_data (dict): data with project attributes
+ (flame compatible)
+
+ Raises:
+ AttributeError: Not able to set project attributes
+ """
+ # generated xml from project_data dict
+ _xml = ""
+ for key, value in project_data.items():
+ _xml += "<{}>{}{}>".format(key, value, key)
+ _xml += ""
+
+ pretty_xml = minidom.parseString(_xml).toprettyxml()
+ print("__ xml: {}".format(pretty_xml))
+
+ # set project data to wiretap
+ project_node = WireTapNodeHandle(
+ self._server, "/projects/{}".format(project_name))
+
+ if not project_node.setMetaData("XML", _xml):
+ raise AttributeError(
+ "Not able to set project attributes {}. Error: {}".format(
+ project_name, project_node.lastError())
+ )
+
+ print("Project settings successfully set.")
+
+ def _set_project_colorspace(self, project_name, color_policy):
+ """Set project's colorspace policy.
+
+ Args:
+ project_name (str): name of project
+ color_policy (str): name of policy
+
+ Raises:
+ RuntimeError: Not able to set colorspace policy
+ """
+ color_policy = color_policy or "Legacy"
+ project_colorspace_cmd = [
+ os.path.join(
+ "/opt/Autodesk/",
+ "wiretap",
+ "tools",
+ "2021",
+ "wiretap_duplicate_node",
+ ),
+ "-s",
+ "/syncolor/policies/Autodesk/{}".format(color_policy),
+ "-n",
+ "/projects/{}/syncolor".format(project_name)
+ ]
+
+ print(project_colorspace_cmd)
+
+ exit_code = subprocess.call(
+ project_colorspace_cmd,
+ cwd=os.path.expanduser('~'))
+
+ if exit_code != 0:
+ RuntimeError("Cannot set colorspace {} on project {}".format(
+ color_policy, project_name
+ ))
+
+
+if __name__ == "__main__":
+ # get json exchange data
+ json_path = sys.argv[-1]
+ json_data = open(json_path).read()
+ in_data = json.loads(json_data)
+ out_data = deepcopy(in_data)
+
+ # get main server attributes
+ host_name = in_data.pop("host_name")
+ volume_name = in_data.pop("volume_name")
+ group_name = in_data.pop("group_name")
+
+ # initialize class
+ wiretap_handler = WireTapCom(host_name, volume_name, group_name)
+
+ try:
+ app_args = wiretap_handler.get_launch_args(
+ project_name=in_data.pop("project_name"),
+ project_data=in_data.pop("project_data"),
+ user_name=in_data.pop("user_name"),
+ **in_data
+ )
+ finally:
+ wiretap_handler.close()
+
+ # set returned args back to out data
+ out_data.update({
+ "app_args": app_args
+ })
+
+ # write it out back to the exchange json file
+ with open(json_path, "w") as file_stream:
+ json.dump(out_data, file_stream, indent=4)
diff --git a/openpype/hosts/flame/utility_scripts/openpype_in_flame.py b/openpype/hosts/flame/utility_scripts/openpype_in_flame.py
new file mode 100644
index 0000000000..c5fa881f3c
--- /dev/null
+++ b/openpype/hosts/flame/utility_scripts/openpype_in_flame.py
@@ -0,0 +1,191 @@
+from __future__ import print_function
+import sys
+from Qt import QtWidgets
+from pprint import pformat
+import atexit
+import openpype
+import avalon
+import openpype.hosts.flame as opflame
+
+flh = sys.modules[__name__]
+flh._project = None
+
+
+def openpype_install():
+ """Registering OpenPype in context
+ """
+ openpype.install()
+ avalon.api.install(opflame)
+ print("Avalon registred hosts: {}".format(
+ avalon.api.registered_host()))
+
+
+# Exception handler
+def exeption_handler(exctype, value, _traceback):
+ """Exception handler for improving UX
+
+ Args:
+ exctype (str): type of exception
+ value (str): exception value
+ tb (str): traceback to show
+ """
+ import traceback
+ msg = "OpenPype: Python exception {} in {}".format(value, exctype)
+ mbox = QtWidgets.QMessageBox()
+ mbox.setText(msg)
+ mbox.setDetailedText(
+ pformat(traceback.format_exception(exctype, value, _traceback)))
+ mbox.setStyleSheet('QLabel{min-width: 800px;}')
+ mbox.exec_()
+ sys.__excepthook__(exctype, value, _traceback)
+
+
+# add exception handler into sys module
+sys.excepthook = exeption_handler
+
+
+# register clean up logic to be called at Flame exit
+def cleanup():
+ """Cleaning up Flame framework context
+ """
+ if opflame.apps:
+ print('`{}` cleaning up apps:\n {}\n'.format(
+ __file__, pformat(opflame.apps)))
+ while len(opflame.apps):
+ app = opflame.apps.pop()
+ print('`{}` removing : {}'.format(__file__, app.name))
+ del app
+ opflame.apps = []
+
+ if opflame.app_framework:
+ print('PYTHON\t: %s cleaning up' % opflame.app_framework.bundle_name)
+ opflame.app_framework.save_prefs()
+ opflame.app_framework = None
+
+
+atexit.register(cleanup)
+
+
+def load_apps():
+ """Load available apps into Flame framework
+ """
+ opflame.apps.append(opflame.FlameMenuProjectConnect(opflame.app_framework))
+ opflame.apps.append(opflame.FlameMenuTimeline(opflame.app_framework))
+ opflame.app_framework.log.info("Apps are loaded")
+
+
+def project_changed_dict(info):
+ """Hook for project change action
+
+ Args:
+ info (str): info text
+ """
+ cleanup()
+
+
+def app_initialized(parent=None):
+ """Inicialization of Framework
+
+ Args:
+ parent (obj, optional): Parent object. Defaults to None.
+ """
+ opflame.app_framework = opflame.FlameAppFramework()
+
+ print("{} initializing".format(
+ opflame.app_framework.bundle_name))
+
+ load_apps()
+
+
+"""
+Initialisation of the hook is starting from here
+
+First it needs to test if it can import the flame modul.
+This will happen only in case a project has been loaded.
+Then `app_initialized` will load main Framework which will load
+all menu objects as apps.
+"""
+
+try:
+ import flame # noqa
+ app_initialized(parent=None)
+except ImportError:
+ print("!!!! not able to import flame module !!!!")
+
+
+def rescan_hooks():
+ import flame # noqa
+ flame.execute_shortcut('Rescan Python Hooks')
+
+
+def _build_app_menu(app_name):
+ """Flame menu object generator
+
+ Args:
+ app_name (str): name of menu object app
+
+ Returns:
+ list: menu object
+ """
+ menu = []
+
+ # first find the relative appname
+ app = None
+ for _app in opflame.apps:
+ if _app.__class__.__name__ == app_name:
+ app = _app
+
+ if app:
+ menu.append(app.build_menu())
+
+ if opflame.app_framework:
+ menu_auto_refresh = opflame.app_framework.prefs_global.get(
+ 'menu_auto_refresh', {})
+ if menu_auto_refresh.get('timeline_menu', True):
+ try:
+ import flame # noqa
+ flame.schedule_idle_event(rescan_hooks)
+ except ImportError:
+ print("!-!!! not able to import flame module !!!!")
+
+ return menu
+
+
+""" Flame hooks are starting here
+"""
+
+
+def project_saved(project_name, save_time, is_auto_save):
+ """Hook to activate when project is saved
+
+ Args:
+ project_name (str): name of project
+ save_time (str): time when it was saved
+ is_auto_save (bool): autosave is on or off
+ """
+ if opflame.app_framework:
+ opflame.app_framework.save_prefs()
+
+
+def get_main_menu_custom_ui_actions():
+ """Hook to create submenu in start menu
+
+ Returns:
+ list: menu object
+ """
+ # install openpype and the host
+ openpype_install()
+
+ return _build_app_menu("FlameMenuProjectConnect")
+
+
+def get_timeline_custom_ui_actions():
+ """Hook to create submenu in timeline
+
+ Returns:
+ list: menu object
+ """
+ # install openpype and the host
+ openpype_install()
+
+ return _build_app_menu("FlameMenuTimeline")
diff --git a/openpype/hosts/houdini/api/plugin.py b/openpype/hosts/houdini/api/plugin.py
index efdaa60084..63d9bba470 100644
--- a/openpype/hosts/houdini/api/plugin.py
+++ b/openpype/hosts/houdini/api/plugin.py
@@ -1,6 +1,7 @@
# -*- coding: utf-8 -*-
"""Houdini specific Avalon/Pyblish plugin definitions."""
import sys
+from avalon.api import CreatorError
from avalon import houdini
import six
@@ -8,7 +9,7 @@ import hou
from openpype.api import PypeCreatorMixin
-class OpenPypeCreatorError(Exception):
+class OpenPypeCreatorError(CreatorError):
pass
diff --git a/openpype/hosts/houdini/api/usd.py b/openpype/hosts/houdini/api/usd.py
index 850ffb60e5..6f808779ea 100644
--- a/openpype/hosts/houdini/api/usd.py
+++ b/openpype/hosts/houdini/api/usd.py
@@ -4,8 +4,8 @@ import contextlib
import logging
from Qt import QtCore, QtGui
-from avalon.tools.widgets import AssetWidget
-from avalon import style
+from openpype.tools.utils.widgets import AssetWidget
+from avalon import style, io
from pxr import Sdf
@@ -31,7 +31,7 @@ def pick_asset(node):
# Construct the AssetWidget as a frameless popup so it automatically
# closes when clicked outside of it.
global tool
- tool = AssetWidget(silo_creatable=False)
+ tool = AssetWidget(io)
tool.setContentsMargins(5, 5, 5, 5)
tool.setWindowTitle("Pick Asset")
tool.setStyleSheet(style.load_stylesheet())
@@ -41,8 +41,6 @@ def pick_asset(node):
# Select the current asset if there is any
name = parm.eval()
if name:
- from avalon import io
-
db_asset = io.find_one({"name": name, "type": "asset"})
if db_asset:
silo = db_asset.get("silo")
diff --git a/openpype/hosts/houdini/plugins/create/create_hda.py b/openpype/hosts/houdini/plugins/create/create_hda.py
new file mode 100644
index 0000000000..2af1e4a257
--- /dev/null
+++ b/openpype/hosts/houdini/plugins/create/create_hda.py
@@ -0,0 +1,96 @@
+# -*- coding: utf-8 -*-
+from openpype.hosts.houdini.api import plugin
+from avalon.houdini import lib
+from avalon import io
+import hou
+
+
+class CreateHDA(plugin.Creator):
+ """Publish Houdini Digital Asset file."""
+
+ name = "hda"
+ label = "Houdini Digital Asset (Hda)"
+ family = "hda"
+ icon = "gears"
+ maintain_selection = False
+
+ def __init__(self, *args, **kwargs):
+ super(CreateHDA, self).__init__(*args, **kwargs)
+ self.data.pop("active", None)
+
+ def _check_existing(self, subset_name):
+ # type: (str) -> bool
+ """Check if existing subset name versions already exists."""
+ # Get all subsets of the current asset
+ asset_id = io.find_one({"name": self.data["asset"], "type": "asset"},
+ projection={"_id": True})['_id']
+ subset_docs = io.find(
+ {
+ "type": "subset",
+ "parent": asset_id
+ }, {"name": 1}
+ )
+ existing_subset_names = set(subset_docs.distinct("name"))
+ existing_subset_names_low = {
+ _name.lower() for _name in existing_subset_names
+ }
+ return subset_name.lower() in existing_subset_names_low
+
+ def _process(self, instance):
+ subset_name = self.data["subset"]
+ # get selected nodes
+ out = hou.node("/obj")
+ self.nodes = hou.selectedNodes()
+
+ if (self.options or {}).get("useSelection") and self.nodes:
+ # if we have `use selection` enabled and we have some
+ # selected nodes ...
+ to_hda = self.nodes[0]
+ if len(self.nodes) > 1:
+ # if there is more then one node, create subnet first
+ subnet = out.createNode(
+ "subnet", node_name="{}_subnet".format(self.name))
+ to_hda = subnet
+ else:
+ # in case of no selection, just create subnet node
+ subnet = out.createNode(
+ "subnet", node_name="{}_subnet".format(self.name))
+ subnet.moveToGoodPosition()
+ to_hda = subnet
+
+ if not to_hda.type().definition():
+ # if node type has not its definition, it is not user
+ # created hda. We test if hda can be created from the node.
+ if not to_hda.canCreateDigitalAsset():
+ raise Exception(
+ "cannot create hda from node {}".format(to_hda))
+
+ hda_node = to_hda.createDigitalAsset(
+ name=subset_name,
+ hda_file_name="$HIP/{}.hda".format(subset_name)
+ )
+ hou.moveNodesTo(self.nodes, hda_node)
+ hda_node.layoutChildren()
+ else:
+ if self._check_existing(subset_name):
+ raise plugin.OpenPypeCreatorError(
+ ("subset {} is already published with different HDA"
+ "definition.").format(subset_name))
+ hda_node = to_hda
+
+ hda_node.setName(subset_name)
+
+ # delete node created by Avalon in /out
+ # this needs to be addressed in future Houdini workflow refactor.
+
+ hou.node("/out/{}".format(subset_name)).destroy()
+
+ try:
+ lib.imprint(hda_node, self.data)
+ except hou.OperationFailed:
+ raise plugin.OpenPypeCreatorError(
+ ("Cannot set metadata on asset. Might be that it already is "
+ "OpenPype asset.")
+ )
+
+ return hda_node
diff --git a/openpype/hosts/houdini/plugins/load/load_hda.py b/openpype/hosts/houdini/plugins/load/load_hda.py
new file mode 100644
index 0000000000..6610d5e513
--- /dev/null
+++ b/openpype/hosts/houdini/plugins/load/load_hda.py
@@ -0,0 +1,62 @@
+# -*- coding: utf-8 -*-
+from avalon import api
+
+from avalon.houdini import pipeline
+
+
+class HdaLoader(api.Loader):
+ """Load Houdini Digital Asset file."""
+
+ families = ["hda"]
+ label = "Load Hda"
+ representations = ["hda"]
+ order = -10
+ icon = "code-fork"
+ color = "orange"
+
+ def load(self, context, name=None, namespace=None, data=None):
+ import os
+ import hou
+
+ # Format file name, Houdini only wants forward slashes
+ file_path = os.path.normpath(self.fname)
+ file_path = file_path.replace("\\", "/")
+
+ # Get the root node
+ obj = hou.node("/obj")
+
+ # Create a unique name
+ counter = 1
+ namespace = namespace or context["asset"]["name"]
+ formatted = "{}_{}".format(namespace, name) if namespace else name
+ node_name = "{0}_{1:03d}".format(formatted, counter)
+
+ hou.hda.installFile(file_path)
+ hda_node = obj.createNode(name, node_name)
+
+ self[:] = [hda_node]
+
+ return pipeline.containerise(
+ node_name,
+ namespace,
+ [hda_node],
+ context,
+ self.__class__.__name__,
+ suffix="",
+ )
+
+ def update(self, container, representation):
+ import hou
+
+ hda_node = container["node"]
+ file_path = api.get_representation_path(representation)
+ file_path = file_path.replace("\\", "/")
+ hou.hda.installFile(file_path)
+ defs = hda_node.type().allInstalledDefinitions()
+ def_paths = [d.libraryFilePath() for d in defs]
+ new = def_paths.index(file_path)
+ defs[new].setIsPreferred(True)
+
+ def remove(self, container):
+ node = container["node"]
+ node.destroy()
diff --git a/openpype/hosts/houdini/plugins/publish/collect_active_state.py b/openpype/hosts/houdini/plugins/publish/collect_active_state.py
index 1193f0cd19..862d5720e1 100644
--- a/openpype/hosts/houdini/plugins/publish/collect_active_state.py
+++ b/openpype/hosts/houdini/plugins/publish/collect_active_state.py
@@ -23,8 +23,10 @@ class CollectInstanceActiveState(pyblish.api.InstancePlugin):
return
# Check bypass state and reverse
+ active = True
node = instance[0]
- active = not node.isBypassed()
+ if hasattr(node, "isBypassed"):
+ active = not node.isBypassed()
# Set instance active state
instance.data.update(
diff --git a/openpype/hosts/houdini/plugins/publish/collect_instances.py b/openpype/hosts/houdini/plugins/publish/collect_instances.py
index 1b36526783..ac081ac297 100644
--- a/openpype/hosts/houdini/plugins/publish/collect_instances.py
+++ b/openpype/hosts/houdini/plugins/publish/collect_instances.py
@@ -31,6 +31,7 @@ class CollectInstances(pyblish.api.ContextPlugin):
def process(self, context):
nodes = hou.node("/out").children()
+ nodes += hou.node("/obj").children()
# Include instances in USD stage only when it exists so it
# remains backwards compatible with version before houdini 18
@@ -49,9 +50,12 @@ class CollectInstances(pyblish.api.ContextPlugin):
has_family = node.evalParm("family")
assert has_family, "'%s' is missing 'family'" % node.name()
+ self.log.info("processing {}".format(node))
+
data = lib.read(node)
# Check bypass state and reverse
- data.update({"active": not node.isBypassed()})
+ if hasattr(node, "isBypassed"):
+ data.update({"active": not node.isBypassed()})
# temporarily translation of `active` to `publish` till issue has
# been resolved, https://github.com/pyblish/pyblish-base/issues/307
diff --git a/openpype/hosts/houdini/plugins/publish/extract_hda.py b/openpype/hosts/houdini/plugins/publish/extract_hda.py
new file mode 100644
index 0000000000..301dd4e297
--- /dev/null
+++ b/openpype/hosts/houdini/plugins/publish/extract_hda.py
@@ -0,0 +1,43 @@
+# -*- coding: utf-8 -*-
+import os
+
+from pprint import pformat
+
+import pyblish.api
+import openpype.api
+
+
+class ExtractHDA(openpype.api.Extractor):
+
+ order = pyblish.api.ExtractorOrder
+ label = "Extract HDA"
+ hosts = ["houdini"]
+ families = ["hda"]
+
+ def process(self, instance):
+ self.log.info(pformat(instance.data))
+ hda_node = instance[0]
+ hda_def = hda_node.type().definition()
+ hda_options = hda_def.options()
+ hda_options.setSaveInitialParmsAndContents(True)
+
+ next_version = instance.data["anatomyData"]["version"]
+ self.log.info("setting version: {}".format(next_version))
+ hda_def.setVersion(str(next_version))
+ hda_def.setOptions(hda_options)
+ hda_def.save(hda_def.libraryFilePath(), hda_node, hda_options)
+
+ if "representations" not in instance.data:
+ instance.data["representations"] = []
+
+ file = os.path.basename(hda_def.libraryFilePath())
+ staging_dir = os.path.dirname(hda_def.libraryFilePath())
+ self.log.info("Using HDA from {}".format(hda_def.libraryFilePath()))
+
+ representation = {
+ 'name': 'hda',
+ 'ext': 'hda',
+ 'files': file,
+ "stagingDir": staging_dir,
+ }
+ instance.data["representations"].append(representation)
diff --git a/openpype/hosts/houdini/plugins/publish/validate_bypass.py b/openpype/hosts/houdini/plugins/publish/validate_bypass.py
index 79c67c3008..fc4e18f701 100644
--- a/openpype/hosts/houdini/plugins/publish/validate_bypass.py
+++ b/openpype/hosts/houdini/plugins/publish/validate_bypass.py
@@ -35,5 +35,5 @@ class ValidateBypassed(pyblish.api.InstancePlugin):
def get_invalid(cls, instance):
rop = instance[0]
- if rop.isBypassed():
+ if hasattr(rop, "isBypassed") and rop.isBypassed():
return [rop]
diff --git a/openpype/hosts/maya/api/__init__.py b/openpype/hosts/maya/api/__init__.py
index d1c13b04d5..0a8370eafc 100644
--- a/openpype/hosts/maya/api/__init__.py
+++ b/openpype/hosts/maya/api/__init__.py
@@ -275,8 +275,7 @@ def on_open(_):
# Show outdated pop-up
def _on_show_inventory():
- import avalon.tools.sceneinventory as tool
- tool.show(parent=parent)
+ host_tools.show_scene_inventory(parent=parent)
dialog = popup.Popup(parent=parent)
dialog.setWindowTitle("Maya scene has outdated content")
diff --git a/openpype/hosts/maya/api/lib.py b/openpype/hosts/maya/api/lib.py
index b24235447f..b16774a8d6 100644
--- a/openpype/hosts/maya/api/lib.py
+++ b/openpype/hosts/maya/api/lib.py
@@ -437,7 +437,8 @@ def empty_sets(sets, force=False):
cmds.connectAttr(src, dest)
# Restore original members
- for origin_set, members in original.iteritems():
+ _iteritems = getattr(original, "iteritems", original.items)
+ for origin_set, members in _iteritems():
cmds.sets(members, forceElement=origin_set)
@@ -581,7 +582,7 @@ def get_shader_assignments_from_shapes(shapes, components=True):
# Build a mapping from parent to shapes to include in lookup.
transforms = {shape.rsplit("|", 1)[0]: shape for shape in shapes}
- lookup = set(shapes + transforms.keys())
+ lookup = set(shapes) | set(transforms.keys())
component_assignments = defaultdict(list)
for shading_group in assignments.keys():
@@ -669,7 +670,8 @@ def displaySmoothness(nodes,
yield
finally:
# Revert state
- for node, state in originals.iteritems():
+ _iteritems = getattr(originals, "iteritems", originals.items)
+ for node, state in _iteritems():
if state:
cmds.displaySmoothness(node, **state)
@@ -712,7 +714,8 @@ def no_display_layers(nodes):
yield
finally:
# Restore original members
- for layer, members in original.iteritems():
+ _iteritems = getattr(original, "iteritems", original.items)
+ for layer, members in _iteritems():
cmds.editDisplayLayerMembers(layer, members, noRecurse=True)
diff --git a/openpype/hosts/maya/api/setdress.py b/openpype/hosts/maya/api/setdress.py
index be26572039..3537fa3837 100644
--- a/openpype/hosts/maya/api/setdress.py
+++ b/openpype/hosts/maya/api/setdress.py
@@ -5,6 +5,7 @@ import os
import contextlib
import copy
+import six
from maya import cmds
from avalon import api, io
@@ -69,7 +70,8 @@ def unlocked(nodes):
yield
finally:
# Reapply original states
- for uuid, state in states.iteritems():
+ _iteritems = getattr(states, "iteritems", states.items)
+ for uuid, state in _iteritems():
nodes_from_id = cmds.ls(uuid, long=True)
if nodes_from_id:
node = nodes_from_id[0]
@@ -94,7 +96,7 @@ def load_package(filepath, name, namespace=None):
# Define a unique namespace for the package
namespace = os.path.basename(filepath).split(".")[0]
unique_namespace(namespace)
- assert isinstance(namespace, basestring)
+ assert isinstance(namespace, six.string_types)
# Load the setdress package data
with open(filepath, "r") as fp:
diff --git a/openpype/hosts/maya/plugins/publish/extract_fbx.py b/openpype/hosts/maya/plugins/publish/extract_fbx.py
index e5f3b0cda4..720a61b0a7 100644
--- a/openpype/hosts/maya/plugins/publish/extract_fbx.py
+++ b/openpype/hosts/maya/plugins/publish/extract_fbx.py
@@ -183,7 +183,8 @@ class ExtractFBX(openpype.api.Extractor):
# Apply the FBX overrides through MEL since the commands
# only work correctly in MEL according to online
# available discussions on the topic
- for option, value in options.iteritems():
+ _iteritems = getattr(options, "iteritems", options.items)
+ for option, value in _iteritems():
key = option[0].upper() + option[1:] # uppercase first letter
# Boolean must be passed as lower-case strings
diff --git a/openpype/hosts/maya/plugins/publish/submit_maya_muster.py b/openpype/hosts/maya/plugins/publish/submit_maya_muster.py
index 1c97f0faf7..207cf56cfe 100644
--- a/openpype/hosts/maya/plugins/publish/submit_maya_muster.py
+++ b/openpype/hosts/maya/plugins/publish/submit_maya_muster.py
@@ -383,7 +383,7 @@ class MayaSubmitMuster(pyblish.api.InstancePlugin):
"attributes": {
"environmental_variables": {
"value": ", ".join("{!s}={!r}".format(k, v)
- for (k, v) in env.iteritems()),
+ for (k, v) in env.items()),
"state": True,
"subst": False
diff --git a/openpype/hosts/maya/plugins/publish/validate_instance_subset.py b/openpype/hosts/maya/plugins/publish/validate_instance_subset.py
index a8c16425d6..539f3f9d3c 100644
--- a/openpype/hosts/maya/plugins/publish/validate_instance_subset.py
+++ b/openpype/hosts/maya/plugins/publish/validate_instance_subset.py
@@ -2,6 +2,8 @@ import pyblish.api
import openpype.api
import string
+import six
+
# Allow only characters, numbers and underscore
allowed = set(string.ascii_lowercase +
string.ascii_uppercase +
@@ -29,7 +31,7 @@ class ValidateSubsetName(pyblish.api.InstancePlugin):
raise RuntimeError("Instance is missing subset "
"name: {0}".format(subset))
- if not isinstance(subset, basestring):
+ if not isinstance(subset, six.string_types):
raise TypeError("Instance subset name must be string, "
"got: {0} ({1})".format(subset, type(subset)))
diff --git a/openpype/hosts/maya/plugins/publish/validate_node_ids_unique.py b/openpype/hosts/maya/plugins/publish/validate_node_ids_unique.py
index 39bb148911..ed9ef526d6 100644
--- a/openpype/hosts/maya/plugins/publish/validate_node_ids_unique.py
+++ b/openpype/hosts/maya/plugins/publish/validate_node_ids_unique.py
@@ -52,7 +52,8 @@ class ValidateNodeIdsUnique(pyblish.api.InstancePlugin):
# Take only the ids with more than one member
invalid = list()
- for _ids, members in ids.iteritems():
+ _iteritems = getattr(ids, "iteritems", ids.items)
+ for _ids, members in _iteritems():
if len(members) > 1:
cls.log.error("ID found on multiple nodes: '%s'" % members)
invalid.extend(members)
diff --git a/openpype/hosts/maya/plugins/publish/validate_node_no_ghosting.py b/openpype/hosts/maya/plugins/publish/validate_node_no_ghosting.py
index 671c744a22..38f3ab1e68 100644
--- a/openpype/hosts/maya/plugins/publish/validate_node_no_ghosting.py
+++ b/openpype/hosts/maya/plugins/publish/validate_node_no_ghosting.py
@@ -32,7 +32,10 @@ class ValidateNodeNoGhosting(pyblish.api.InstancePlugin):
nodes = cmds.ls(instance, long=True, type=['transform', 'shape'])
invalid = []
for node in nodes:
- for attr, required_value in cls._attributes.iteritems():
+ _iteritems = getattr(
+ cls._attributes, "iteritems", cls._attributes.items
+ )
+ for attr, required_value in _iteritems():
if cmds.attributeQuery(attr, node=node, exists=True):
value = cmds.getAttr('{0}.{1}'.format(node, attr))
diff --git a/openpype/hosts/maya/plugins/publish/validate_shape_render_stats.py b/openpype/hosts/maya/plugins/publish/validate_shape_render_stats.py
index 667a1f13be..714451bb98 100644
--- a/openpype/hosts/maya/plugins/publish/validate_shape_render_stats.py
+++ b/openpype/hosts/maya/plugins/publish/validate_shape_render_stats.py
@@ -33,7 +33,8 @@ class ValidateShapeRenderStats(pyblish.api.Validator):
shapes = cmds.ls(instance, long=True, type='surfaceShape')
invalid = []
for shape in shapes:
- for attr, default_value in cls.defaults.iteritems():
+ _iteritems = getattr(cls.defaults, "iteritems", cls.defaults.items)
+ for attr, default_value in _iteritems():
if cmds.attributeQuery(attr, node=shape, exists=True):
value = cmds.getAttr('{}.{}'.format(shape, attr))
if value != default_value:
@@ -52,7 +53,8 @@ class ValidateShapeRenderStats(pyblish.api.Validator):
@classmethod
def repair(cls, instance):
for shape in cls.get_invalid(instance):
- for attr, default_value in cls.defaults.iteritems():
+ _iteritems = getattr(cls.defaults, "iteritems", cls.defaults.items)
+ for attr, default_value in _iteritems():
if cmds.attributeQuery(attr, node=shape, exists=True):
plug = '{0}.{1}'.format(shape, attr)
diff --git a/openpype/hosts/photoshop/plugins/load/load_image.py b/openpype/hosts/photoshop/plugins/load/load_image.py
index d043323768..981a1ed204 100644
--- a/openpype/hosts/photoshop/plugins/load/load_image.py
+++ b/openpype/hosts/photoshop/plugins/load/load_image.py
@@ -6,7 +6,6 @@ from openpype.hosts.photoshop.plugins.lib import get_unique_layer_name
stub = photoshop.stub()
-
class ImageLoader(api.Loader):
"""Load images
@@ -21,7 +20,7 @@ class ImageLoader(api.Loader):
context["asset"]["name"],
name)
with photoshop.maintained_selection():
- layer = stub.import_smart_object(self.fname, layer_name)
+ layer = self.import_layer(self.fname, layer_name)
self[:] = [layer]
namespace = namespace or layer_name
@@ -45,8 +44,9 @@ class ImageLoader(api.Loader):
layer_name = "{}_{}".format(context["asset"], context["subset"])
# switching assets
if namespace_from_container != layer_name:
- layer_name = self._get_unique_layer_name(context["asset"],
- context["subset"])
+ layer_name = get_unique_layer_name(stub.get_layers(),
+ context["asset"],
+ context["subset"])
else: # switching version - keep same name
layer_name = container["namespace"]
@@ -72,3 +72,6 @@ class ImageLoader(api.Loader):
def switch(self, container, representation):
self.update(container, representation)
+
+ def import_layer(self, file_name, layer_name):
+ return stub.import_smart_object(file_name, layer_name)
diff --git a/openpype/hosts/photoshop/plugins/load/load_reference.py b/openpype/hosts/photoshop/plugins/load/load_reference.py
new file mode 100644
index 0000000000..0cb4e4a69f
--- /dev/null
+++ b/openpype/hosts/photoshop/plugins/load/load_reference.py
@@ -0,0 +1,82 @@
+import re
+
+from avalon import api, photoshop
+
+from openpype.hosts.photoshop.plugins.lib import get_unique_layer_name
+
+stub = photoshop.stub()
+
+
+class ReferenceLoader(api.Loader):
+ """Load reference images
+
+ Stores the imported asset in a container named after the asset.
+
+ Inheriting from 'load_image' didn't work because of
+ "Cannot write to closing transport", possible refactor.
+ """
+
+ families = ["image", "render"]
+ representations = ["*"]
+
+ def load(self, context, name=None, namespace=None, data=None):
+ layer_name = get_unique_layer_name(stub.get_layers(),
+ context["asset"]["name"],
+ name)
+ with photoshop.maintained_selection():
+ layer = self.import_layer(self.fname, layer_name)
+
+ self[:] = [layer]
+ namespace = namespace or layer_name
+
+ return photoshop.containerise(
+ name,
+ namespace,
+ layer,
+ context,
+ self.__class__.__name__
+ )
+
+ def update(self, container, representation):
+ """ Switch asset or change version """
+ layer = container.pop("layer")
+
+ context = representation.get("context", {})
+
+ namespace_from_container = re.sub(r'_\d{3}$', '',
+ container["namespace"])
+ layer_name = "{}_{}".format(context["asset"], context["subset"])
+ # switching assets
+ if namespace_from_container != layer_name:
+ layer_name = get_unique_layer_name(stub.get_layers(),
+ context["asset"],
+ context["subset"])
+ else: # switching version - keep same name
+ layer_name = container["namespace"]
+
+ path = api.get_representation_path(representation)
+ with photoshop.maintained_selection():
+ stub.replace_smart_object(
+ layer, path, layer_name
+ )
+
+ stub.imprint(
+ layer, {"representation": str(representation["_id"])}
+ )
+
+ def remove(self, container):
+ """
+ Removes element from scene: deletes layer + removes from Headline
+ Args:
+ container (dict): container to be removed - used to get layer_id
+ """
+ layer = container.pop("layer")
+ stub.imprint(layer, {})
+ stub.delete_layer(layer.id)
+
+ def switch(self, container, representation):
+ self.update(container, representation)
+
+ def import_layer(self, file_name, layer_name):
+ return stub.import_smart_object(file_name, layer_name,
+ as_reference=True)
diff --git a/openpype/hosts/photoshop/plugins/publish/closePS.py b/openpype/hosts/photoshop/plugins/publish/closePS.py
new file mode 100644
index 0000000000..19994a0db8
--- /dev/null
+++ b/openpype/hosts/photoshop/plugins/publish/closePS.py
@@ -0,0 +1,30 @@
+# -*- coding: utf-8 -*-
+"""Close PS after publish. For Webpublishing only."""
+import os
+
+import pyblish.api
+
+from avalon import photoshop
+
+
+class ClosePS(pyblish.api.ContextPlugin):
+ """Close PS after publish. For Webpublishing only.
+ """
+
+ order = pyblish.api.IntegratorOrder + 14
+ label = "Close PS"
+ optional = True
+ active = True
+
+ hosts = ["photoshop"]
+
+ def process(self, context):
+ self.log.info("ClosePS")
+ if not os.environ.get("IS_HEADLESS"):
+ return
+
+ stub = photoshop.stub()
+ self.log.info("Shutting down PS")
+ stub.save()
+ stub.close()
+ self.log.info("PS closed")
diff --git a/openpype/hosts/photoshop/plugins/publish/collect_remote_instances.py b/openpype/hosts/photoshop/plugins/publish/collect_remote_instances.py
new file mode 100644
index 0000000000..12f9fa5ab5
--- /dev/null
+++ b/openpype/hosts/photoshop/plugins/publish/collect_remote_instances.py
@@ -0,0 +1,135 @@
+import pyblish.api
+import os
+import re
+
+from avalon import photoshop
+from openpype.lib import prepare_template_data
+from openpype.lib.plugin_tools import parse_json
+
+
+class CollectRemoteInstances(pyblish.api.ContextPlugin):
+ """Gather instances configured color code of a layer.
+
+ Used in remote publishing when artists marks publishable layers by color-
+ coding.
+
+ Identifier:
+ id (str): "pyblish.avalon.instance"
+ """
+ order = pyblish.api.CollectorOrder + 0.100
+
+ label = "Instances"
+ order = pyblish.api.CollectorOrder
+ hosts = ["photoshop"]
+
+ # configurable by Settings
+ color_code_mapping = []
+
+ def process(self, context):
+ self.log.info("CollectRemoteInstances")
+ self.log.info("mapping:: {}".format(self.color_code_mapping))
+ if not os.environ.get("IS_HEADLESS"):
+ self.log.debug("Not headless publishing, skipping.")
+ return
+
+ # parse variant if used in webpublishing, comes from webpublisher batch
+ batch_dir = os.environ.get("OPENPYPE_PUBLISH_DATA")
+ variant = "Main"
+ if batch_dir and os.path.exists(batch_dir):
+ # TODO check if batch manifest is same as tasks manifests
+ task_data = parse_json(os.path.join(batch_dir,
+ "manifest.json"))
+ if not task_data:
+ raise ValueError(
+ "Cannot parse batch meta in {} folder".format(batch_dir))
+ variant = task_data["variant"]
+
+ stub = photoshop.stub()
+ layers = stub.get_layers()
+
+ instance_names = []
+ for layer in layers:
+ self.log.info("Layer:: {}".format(layer))
+ resolved_family, resolved_subset_template = self._resolve_mapping(
+ layer
+ )
+ self.log.info("resolved_family {}".format(resolved_family))
+ self.log.info("resolved_subset_template {}".format(
+ resolved_subset_template))
+
+ if not resolved_subset_template or not resolved_family:
+ self.log.debug("!!! Not marked, skip")
+ continue
+
+ if layer.parents:
+ self.log.debug("!!! Not a top layer, skip")
+ continue
+
+ instance = context.create_instance(layer.name)
+ instance.append(layer)
+ instance.data["family"] = resolved_family
+ instance.data["publish"] = layer.visible
+ instance.data["asset"] = context.data["assetEntity"]["name"]
+ instance.data["task"] = context.data["taskType"]
+
+ fill_pairs = {
+ "variant": variant,
+ "family": instance.data["family"],
+ "task": instance.data["task"],
+ "layer": layer.name
+ }
+ subset = resolved_subset_template.format(
+ **prepare_template_data(fill_pairs))
+ instance.data["subset"] = subset
+
+ instance_names.append(layer.name)
+
+ # Produce diagnostic message for any graphical
+ # user interface interested in visualising it.
+ self.log.info("Found: \"%s\" " % instance.data["name"])
+ self.log.info("instance: {} ".format(instance.data))
+
+ if len(instance_names) != len(set(instance_names)):
+ self.log.warning("Duplicate instances found. " +
+ "Remove unwanted via SubsetManager")
+
+ def _resolve_mapping(self, layer):
+ """Matches 'layer' color code and name to mapping.
+
+ If both color code AND name regex is configured, BOTH must be valid
+ If layer matches to multiple mappings, only first is used!
+ """
+ family_list = []
+ family = None
+ subset_name_list = []
+ resolved_subset_template = None
+ for mapping in self.color_code_mapping:
+ if mapping["color_code"] and \
+ layer.color_code not in mapping["color_code"]:
+ continue
+
+ if mapping["layer_name_regex"] and \
+ not any(re.search(pattern, layer.name)
+ for pattern in mapping["layer_name_regex"]):
+ continue
+
+ family_list.append(mapping["family"])
+ subset_name_list.append(mapping["subset_template_name"])
+
+ if len(subset_name_list) > 1:
+ self.log.warning("Multiple mappings found for '{}'".
+ format(layer.name))
+ self.log.warning("Only first subset name template used!")
+ subset_name_list[:] = subset_name_list[0]
+
+ if len(family_list) > 1:
+ self.log.warning("Multiple mappings found for '{}'".
+ format(layer.name))
+ self.log.warning("Only first family used!")
+ family_list[:] = family_list[0]
+ if subset_name_list:
+ resolved_subset_template = subset_name_list.pop()
+ if family_list:
+ family = family_list.pop()
+
+ return family, resolved_subset_template
diff --git a/openpype/hosts/photoshop/plugins/publish/extract_image.py b/openpype/hosts/photoshop/plugins/publish/extract_image.py
index 87574d1269..ae9892e290 100644
--- a/openpype/hosts/photoshop/plugins/publish/extract_image.py
+++ b/openpype/hosts/photoshop/plugins/publish/extract_image.py
@@ -12,7 +12,7 @@ class ExtractImage(openpype.api.Extractor):
label = "Extract Image"
hosts = ["photoshop"]
- families = ["image"]
+ families = ["image", "background"]
formats = ["png", "jpg"]
def process(self, instance):
diff --git a/openpype/hosts/standalonepublisher/plugins/publish/collect_bulk_mov_instances.py b/openpype/hosts/standalonepublisher/plugins/publish/collect_bulk_mov_instances.py
index a5177335b3..9f075d66cf 100644
--- a/openpype/hosts/standalonepublisher/plugins/publish/collect_bulk_mov_instances.py
+++ b/openpype/hosts/standalonepublisher/plugins/publish/collect_bulk_mov_instances.py
@@ -3,7 +3,7 @@ import json
import pyblish.api
from avalon import io
-from openpype.lib import get_subset_name
+from openpype.lib import get_subset_name_with_asset_doc
class CollectBulkMovInstances(pyblish.api.InstancePlugin):
@@ -26,16 +26,10 @@ class CollectBulkMovInstances(pyblish.api.InstancePlugin):
context = instance.context
asset_name = instance.data["asset"]
- asset_doc = io.find_one(
- {
- "type": "asset",
- "name": asset_name
- },
- {
- "_id": 1,
- "data.tasks": 1
- }
- )
+ asset_doc = io.find_one({
+ "type": "asset",
+ "name": asset_name
+ })
if not asset_doc:
raise AssertionError((
"Couldn't find Asset document with name \"{}\""
@@ -53,11 +47,11 @@ class CollectBulkMovInstances(pyblish.api.InstancePlugin):
task_name = available_task_names[_task_name_low]
break
- subset_name = get_subset_name(
+ subset_name = get_subset_name_with_asset_doc(
self.new_instance_family,
self.subset_name_variant,
task_name,
- asset_doc["_id"],
+ asset_doc,
io.Session["AVALON_PROJECT"]
)
instance_name = f"{asset_name}_{subset_name}"
diff --git a/openpype/hosts/standalonepublisher/plugins/publish/validate_sources.py b/openpype/hosts/standalonepublisher/plugins/publish/validate_sources.py
index da424cfb45..eec675e97f 100644
--- a/openpype/hosts/standalonepublisher/plugins/publish/validate_sources.py
+++ b/openpype/hosts/standalonepublisher/plugins/publish/validate_sources.py
@@ -22,15 +22,15 @@ class ValidateSources(pyblish.api.InstancePlugin):
def process(self, instance):
self.log.info("instance {}".format(instance.data))
- for repr in instance.data["representations"]:
+ for repre in instance.data.get("representations") or []:
files = []
- if isinstance(repr["files"], str):
- files.append(repr["files"])
+ if isinstance(repre["files"], str):
+ files.append(repre["files"])
else:
- files = list(repr["files"])
+ files = list(repre["files"])
for file_name in files:
- source_file = os.path.join(repr["stagingDir"],
+ source_file = os.path.join(repre["stagingDir"],
file_name)
if not os.path.exists(source_file):
diff --git a/openpype/hosts/tvpaint/plugins/publish/collect_instances.py b/openpype/hosts/tvpaint/plugins/publish/collect_instances.py
index dfa8f17ee9..1d7a48e389 100644
--- a/openpype/hosts/tvpaint/plugins/publish/collect_instances.py
+++ b/openpype/hosts/tvpaint/plugins/publish/collect_instances.py
@@ -4,7 +4,7 @@ import copy
import pyblish.api
from avalon import io
-from openpype.lib import get_subset_name
+from openpype.lib import get_subset_name_with_asset_doc
class CollectInstances(pyblish.api.ContextPlugin):
@@ -70,16 +70,10 @@ class CollectInstances(pyblish.api.ContextPlugin):
# - not sure if it's good idea to require asset id in
# get_subset_name?
asset_name = context.data["workfile_context"]["asset"]
- asset_doc = io.find_one(
- {
- "type": "asset",
- "name": asset_name
- },
- {"_id": 1}
- )
- asset_id = None
- if asset_doc:
- asset_id = asset_doc["_id"]
+ asset_doc = io.find_one({
+ "type": "asset",
+ "name": asset_name
+ })
# Project name from workfile context
project_name = context.data["workfile_context"]["project"]
@@ -88,11 +82,11 @@ class CollectInstances(pyblish.api.ContextPlugin):
# Use empty variant value
variant = ""
task_name = io.Session["AVALON_TASK"]
- new_subset_name = get_subset_name(
+ new_subset_name = get_subset_name_with_asset_doc(
family,
variant,
task_name,
- asset_id,
+ asset_doc,
project_name,
host_name
)
diff --git a/openpype/hosts/tvpaint/plugins/publish/collect_workfile.py b/openpype/hosts/tvpaint/plugins/publish/collect_workfile.py
index 65e38ea258..68ba350a85 100644
--- a/openpype/hosts/tvpaint/plugins/publish/collect_workfile.py
+++ b/openpype/hosts/tvpaint/plugins/publish/collect_workfile.py
@@ -3,7 +3,7 @@ import json
import pyblish.api
from avalon import io
-from openpype.lib import get_subset_name
+from openpype.lib import get_subset_name_with_asset_doc
class CollectWorkfile(pyblish.api.ContextPlugin):
@@ -28,16 +28,10 @@ class CollectWorkfile(pyblish.api.ContextPlugin):
# get_subset_name?
family = "workfile"
asset_name = context.data["workfile_context"]["asset"]
- asset_doc = io.find_one(
- {
- "type": "asset",
- "name": asset_name
- },
- {"_id": 1}
- )
- asset_id = None
- if asset_doc:
- asset_id = asset_doc["_id"]
+ asset_doc = io.find_one({
+ "type": "asset",
+ "name": asset_name
+ })
# Project name from workfile context
project_name = context.data["workfile_context"]["project"]
@@ -46,11 +40,11 @@ class CollectWorkfile(pyblish.api.ContextPlugin):
# Use empty variant value
variant = ""
task_name = io.Session["AVALON_TASK"]
- subset_name = get_subset_name(
+ subset_name = get_subset_name_with_asset_doc(
family,
variant,
task_name,
- asset_id,
+ asset_doc,
project_name,
host_name
)
diff --git a/openpype/hosts/unreal/api/lib.py b/openpype/hosts/unreal/api/lib.py
index 7e34c3ff15..c0fafbb667 100644
--- a/openpype/hosts/unreal/api/lib.py
+++ b/openpype/hosts/unreal/api/lib.py
@@ -253,6 +253,7 @@ def create_unreal_project(project_name: str,
"Plugins": [
{"Name": "PythonScriptPlugin", "Enabled": True},
{"Name": "EditorScriptingUtilities", "Enabled": True},
+ {"Name": "SequencerScripting", "Enabled": True},
{"Name": "Avalon", "Enabled": True}
]
}
diff --git a/openpype/hosts/unreal/hooks/pre_workfile_preparation.py b/openpype/hosts/unreal/hooks/pre_workfile_preparation.py
index 01b8b6bc05..880dba5cfb 100644
--- a/openpype/hosts/unreal/hooks/pre_workfile_preparation.py
+++ b/openpype/hosts/unreal/hooks/pre_workfile_preparation.py
@@ -6,7 +6,9 @@ from pathlib import Path
from openpype.lib import (
PreLaunchHook,
ApplicationLaunchFailed,
- ApplicationNotFound
+ ApplicationNotFound,
+ get_workdir_data,
+ get_workfile_template_key
)
from openpype.hosts.unreal.api import lib as unreal_lib
@@ -25,13 +27,46 @@ class UnrealPrelaunchHook(PreLaunchHook):
self.signature = "( {} )".format(self.__class__.__name__)
+ def _get_work_filename(self):
+ # Use last workfile if was found
+ if self.data.get("last_workfile_path"):
+ last_workfile = Path(self.data.get("last_workfile_path"))
+ if last_workfile and last_workfile.exists():
+ return last_workfile.name
+
+ # Prepare data for fill data and for getting workfile template key
+ task_name = self.data["task_name"]
+ anatomy = self.data["anatomy"]
+ asset_doc = self.data["asset_doc"]
+ project_doc = self.data["project_doc"]
+
+ asset_tasks = asset_doc.get("data", {}).get("tasks") or {}
+ task_info = asset_tasks.get(task_name) or {}
+ task_type = task_info.get("type")
+
+ workdir_data = get_workdir_data(
+ project_doc, asset_doc, task_name, self.host_name
+ )
+ # QUESTION raise exception if version is part of filename template?
+ workdir_data["version"] = 1
+ workdir_data["ext"] = "uproject"
+
+ # Get workfile template key for current context
+ workfile_template_key = get_workfile_template_key(
+ task_type,
+ self.host_name,
+ project_name=project_doc["name"]
+ )
+ # Fill templates
+ filled_anatomy = anatomy.format(workdir_data)
+
+ # Return filename
+ return filled_anatomy[workfile_template_key]["file"]
+
def execute(self):
"""Hook entry method."""
- asset_name = self.data["asset_name"]
- task_name = self.data["task_name"]
workdir = self.launch_context.env["AVALON_WORKDIR"]
engine_version = self.app_name.split("/")[-1].replace("-", ".")
- unreal_project_name = f"{asset_name}_{task_name}"
try:
if int(engine_version.split(".")[0]) < 4 and \
int(engine_version.split(".")[1]) < 26:
@@ -45,6 +80,8 @@ class UnrealPrelaunchHook(PreLaunchHook):
# so lets keep it quite.
...
+ unreal_project_filename = self._get_work_filename()
+ unreal_project_name = os.path.splitext(unreal_project_filename)[0]
# Unreal is sensitive about project names longer then 20 chars
if len(unreal_project_name) > 20:
self.log.warning((
@@ -89,10 +126,10 @@ class UnrealPrelaunchHook(PreLaunchHook):
ue4_path = unreal_lib.get_editor_executable_path(
Path(detected[engine_version]))
- self.launch_context.launch_args.append(ue4_path.as_posix())
+ self.launch_context.launch_args = [ue4_path.as_posix()]
project_path.mkdir(parents=True, exist_ok=True)
- project_file = project_path / f"{unreal_project_name}.uproject"
+ project_file = project_path / unreal_project_filename
if not project_file.is_file():
engine_path = detected[engine_version]
self.log.info((
diff --git a/openpype/hosts/unreal/plugins/create/create_camera.py b/openpype/hosts/unreal/plugins/create/create_camera.py
new file mode 100644
index 0000000000..eda2b52be3
--- /dev/null
+++ b/openpype/hosts/unreal/plugins/create/create_camera.py
@@ -0,0 +1,43 @@
+import unreal
+from unreal import EditorAssetLibrary as eal
+from unreal import EditorLevelLibrary as ell
+
+from openpype.hosts.unreal.api.plugin import Creator
+from avalon.unreal import (
+ instantiate,
+)
+
+
+class CreateCamera(Creator):
+ """Layout output for character rigs"""
+
+ name = "layoutMain"
+ label = "Camera"
+ family = "camera"
+ icon = "cubes"
+
+ root = "/Game/Avalon/Instances"
+ suffix = "_INS"
+
+ def __init__(self, *args, **kwargs):
+ super(CreateCamera, self).__init__(*args, **kwargs)
+
+ def process(self):
+ data = self.data
+
+ name = data["subset"]
+
+ data["level"] = ell.get_editor_world().get_path_name()
+
+ if not eal.does_directory_exist(self.root):
+ eal.make_directory(self.root)
+
+ factory = unreal.LevelSequenceFactoryNew()
+ tools = unreal.AssetToolsHelpers().get_asset_tools()
+ tools.create_asset(name, f"{self.root}/{name}", None, factory)
+
+ asset_name = f"{self.root}/{name}/{name}.{name}"
+
+ data["members"] = [asset_name]
+
+ instantiate(f"{self.root}", name, data, None, self.suffix)
diff --git a/openpype/hosts/unreal/plugins/load/load_camera.py b/openpype/hosts/unreal/plugins/load/load_camera.py
new file mode 100644
index 0000000000..b2b25eec73
--- /dev/null
+++ b/openpype/hosts/unreal/plugins/load/load_camera.py
@@ -0,0 +1,206 @@
+import os
+
+from avalon import api, io, pipeline
+from avalon.unreal import lib
+from avalon.unreal import pipeline as unreal_pipeline
+import unreal
+
+
+class CameraLoader(api.Loader):
+ """Load Unreal StaticMesh from FBX"""
+
+ families = ["camera"]
+ label = "Load Camera"
+ representations = ["fbx"]
+ icon = "cube"
+ color = "orange"
+
+ def load(self, context, name, namespace, data):
+ """
+ Load and containerise representation into Content Browser.
+
+ This is two step process. First, import FBX to temporary path and
+ then call `containerise()` on it - this moves all content to new
+ directory and then it will create AssetContainer there and imprint it
+ with metadata. This will mark this path as container.
+
+ Args:
+ context (dict): application context
+ name (str): subset name
+ namespace (str): in Unreal this is basically path to container.
+ This is not passed here, so namespace is set
+ by `containerise()` because only then we know
+ real path.
+ data (dict): Those would be data to be imprinted. This is not used
+ now, data are imprinted by `containerise()`.
+
+ Returns:
+ list(str): list of container content
+ """
+
+ # Create directory for asset and avalon container
+ root = "/Game/Avalon/Assets"
+ asset = context.get('asset').get('name')
+ suffix = "_CON"
+ if asset:
+ asset_name = "{}_{}".format(asset, name)
+ else:
+ asset_name = "{}".format(name)
+
+ tools = unreal.AssetToolsHelpers().get_asset_tools()
+
+ unique_number = 1
+
+ if unreal.EditorAssetLibrary.does_directory_exist(f"{root}/{asset}"):
+ asset_content = unreal.EditorAssetLibrary.list_assets(
+ f"{root}/{asset}", recursive=False, include_folder=True
+ )
+
+ # Get highest number to make a unique name
+ folders = [a for a in asset_content
+ if a[-1] == "/" and f"{name}_" in a]
+ f_numbers = []
+ for f in folders:
+ # Get number from folder name. Splits the string by "_" and
+ # removes the last element (which is a "/").
+ f_numbers.append(int(f.split("_")[-1][:-1]))
+ f_numbers.sort()
+ if not f_numbers:
+ unique_number = 1
+ else:
+ unique_number = f_numbers[-1] + 1
+
+ asset_dir, container_name = tools.create_unique_asset_name(
+ f"{root}/{asset}/{name}_{unique_number:02d}", suffix="")
+
+ container_name += suffix
+
+ unreal.EditorAssetLibrary.make_directory(asset_dir)
+
+ sequence = tools.create_asset(
+ asset_name=asset_name,
+ package_path=asset_dir,
+ asset_class=unreal.LevelSequence,
+ factory=unreal.LevelSequenceFactoryNew()
+ )
+
+ io_asset = io.Session["AVALON_ASSET"]
+ asset_doc = io.find_one({
+ "type": "asset",
+ "name": io_asset
+ })
+
+ data = asset_doc.get("data")
+
+ if data:
+ sequence.set_display_rate(unreal.FrameRate(data.get("fps"), 1.0))
+ sequence.set_playback_start(data.get("frameStart"))
+ sequence.set_playback_end(data.get("frameEnd"))
+
+ settings = unreal.MovieSceneUserImportFBXSettings()
+ settings.set_editor_property('reduce_keys', False)
+
+ unreal.SequencerTools.import_fbx(
+ unreal.EditorLevelLibrary.get_editor_world(),
+ sequence,
+ sequence.get_bindings(),
+ settings,
+ self.fname
+ )
+
+ # Create Asset Container
+ lib.create_avalon_container(container=container_name, path=asset_dir)
+
+ data = {
+ "schema": "openpype:container-2.0",
+ "id": pipeline.AVALON_CONTAINER_ID,
+ "asset": asset,
+ "namespace": asset_dir,
+ "container_name": container_name,
+ "asset_name": asset_name,
+ "loader": str(self.__class__.__name__),
+ "representation": context["representation"]["_id"],
+ "parent": context["representation"]["parent"],
+ "family": context["representation"]["context"]["family"]
+ }
+ unreal_pipeline.imprint(
+ "{}/{}".format(asset_dir, container_name), data)
+
+ asset_content = unreal.EditorAssetLibrary.list_assets(
+ asset_dir, recursive=True, include_folder=True
+ )
+
+ for a in asset_content:
+ unreal.EditorAssetLibrary.save_asset(a)
+
+ return asset_content
+
+ def update(self, container, representation):
+ path = container["namespace"]
+
+ ar = unreal.AssetRegistryHelpers.get_asset_registry()
+ tools = unreal.AssetToolsHelpers().get_asset_tools()
+
+ asset_content = unreal.EditorAssetLibrary.list_assets(
+ path, recursive=False, include_folder=False
+ )
+ asset_name = ""
+ for a in asset_content:
+ asset = ar.get_asset_by_object_path(a)
+ if a.endswith("_CON"):
+ loaded_asset = unreal.EditorAssetLibrary.load_asset(a)
+ unreal.EditorAssetLibrary.set_metadata_tag(
+ loaded_asset, "representation", str(representation["_id"])
+ )
+ unreal.EditorAssetLibrary.set_metadata_tag(
+ loaded_asset, "parent", str(representation["parent"])
+ )
+ asset_name = unreal.EditorAssetLibrary.get_metadata_tag(
+ loaded_asset, "asset_name"
+ )
+ elif asset.asset_class == "LevelSequence":
+ unreal.EditorAssetLibrary.delete_asset(a)
+
+ sequence = tools.create_asset(
+ asset_name=asset_name,
+ package_path=path,
+ asset_class=unreal.LevelSequence,
+ factory=unreal.LevelSequenceFactoryNew()
+ )
+
+ io_asset = io.Session["AVALON_ASSET"]
+ asset_doc = io.find_one({
+ "type": "asset",
+ "name": io_asset
+ })
+
+ data = asset_doc.get("data")
+
+ if data:
+ sequence.set_display_rate(unreal.FrameRate(data.get("fps"), 1.0))
+ sequence.set_playback_start(data.get("frameStart"))
+ sequence.set_playback_end(data.get("frameEnd"))
+
+ settings = unreal.MovieSceneUserImportFBXSettings()
+ settings.set_editor_property('reduce_keys', False)
+
+ unreal.SequencerTools.import_fbx(
+ unreal.EditorLevelLibrary.get_editor_world(),
+ sequence,
+ sequence.get_bindings(),
+ settings,
+ str(representation["data"]["path"])
+ )
+
+ def remove(self, container):
+ path = container["namespace"]
+ parent_path = os.path.dirname(path)
+
+ unreal.EditorAssetLibrary.delete_directory(path)
+
+ asset_content = unreal.EditorAssetLibrary.list_assets(
+ parent_path, recursive=False, include_folder=True
+ )
+
+ if len(asset_content) == 0:
+ unreal.EditorAssetLibrary.delete_directory(parent_path)
diff --git a/openpype/hosts/unreal/plugins/publish/extract_camera.py b/openpype/hosts/unreal/plugins/publish/extract_camera.py
new file mode 100644
index 0000000000..10862fc0ef
--- /dev/null
+++ b/openpype/hosts/unreal/plugins/publish/extract_camera.py
@@ -0,0 +1,54 @@
+import os
+
+import unreal
+from unreal import EditorAssetLibrary as eal
+from unreal import EditorLevelLibrary as ell
+
+import openpype.api
+
+
+class ExtractCamera(openpype.api.Extractor):
+ """Extract a camera."""
+
+ label = "Extract Camera"
+ hosts = ["unreal"]
+ families = ["camera"]
+ optional = True
+
+ def process(self, instance):
+ # Define extract output file path
+ stagingdir = self.staging_dir(instance)
+ fbx_filename = "{}.fbx".format(instance.name)
+
+ # Perform extraction
+ self.log.info("Performing extraction..")
+
+ # Check if the loaded level is the same of the instance
+ current_level = ell.get_editor_world().get_path_name()
+ assert current_level == instance.data.get("level"), \
+ "Wrong level loaded"
+
+ for member in instance[:]:
+ data = eal.find_asset_data(member)
+ if data.asset_class == "LevelSequence":
+ ar = unreal.AssetRegistryHelpers.get_asset_registry()
+ sequence = ar.get_asset_by_object_path(member).get_asset()
+ unreal.SequencerTools.export_fbx(
+ ell.get_editor_world(),
+ sequence,
+ sequence.get_bindings(),
+ unreal.FbxExportOption(),
+ os.path.join(stagingdir, fbx_filename)
+ )
+ break
+
+ if "representations" not in instance.data:
+ instance.data["representations"] = []
+
+ fbx_representation = {
+ 'name': 'fbx',
+ 'ext': 'fbx',
+ 'files': fbx_filename,
+ "stagingDir": stagingdir,
+ }
+ instance.data["representations"].append(fbx_representation)
diff --git a/openpype/hosts/webpublisher/plugins/publish/collect_published_files.py b/openpype/hosts/webpublisher/plugins/publish/collect_published_files.py
index 7e9b98956a..ecd65ebae4 100644
--- a/openpype/hosts/webpublisher/plugins/publish/collect_published_files.py
+++ b/openpype/hosts/webpublisher/plugins/publish/collect_published_files.py
@@ -15,6 +15,7 @@ import tempfile
import pyblish.api
from avalon import io
from openpype.lib import prepare_template_data
+from openpype.lib.plugin_tools import parse_json, get_batch_asset_task_info
class CollectPublishedFiles(pyblish.api.ContextPlugin):
@@ -33,22 +34,6 @@ class CollectPublishedFiles(pyblish.api.ContextPlugin):
# from Settings
task_type_to_family = {}
- def _load_json(self, path):
- path = path.strip('\"')
- assert os.path.isfile(path), (
- "Path to json file doesn't exist. \"{}\"".format(path)
- )
- data = None
- with open(path, "r") as json_file:
- try:
- data = json.load(json_file)
- except Exception as exc:
- self.log.error(
- "Error loading json: "
- "{} - Exception: {}".format(path, exc)
- )
- return data
-
def _process_batch(self, dir_url):
task_subfolders = [
os.path.join(dir_url, o)
@@ -56,22 +41,15 @@ class CollectPublishedFiles(pyblish.api.ContextPlugin):
if os.path.isdir(os.path.join(dir_url, o))]
self.log.info("task_sub:: {}".format(task_subfolders))
for task_dir in task_subfolders:
- task_data = self._load_json(os.path.join(task_dir,
- "manifest.json"))
+ task_data = parse_json(os.path.join(task_dir,
+ "manifest.json"))
self.log.info("task_data:: {}".format(task_data))
ctx = task_data["context"]
- task_type = "default_task_type"
- task_name = None
- if ctx["type"] == "task":
- items = ctx["path"].split('/')
- asset = items[-2]
- os.environ["AVALON_TASK"] = ctx["name"]
- task_name = ctx["name"]
- task_type = ctx["attributes"]["type"]
- else:
- asset = ctx["name"]
- os.environ["AVALON_TASK"] = ""
+ asset, task_name, task_type = get_batch_asset_task_info(ctx)
+
+ if task_name:
+ os.environ["AVALON_TASK"] = task_name
is_sequence = len(task_data["files"]) > 1
@@ -261,7 +239,7 @@ class CollectPublishedFiles(pyblish.api.ContextPlugin):
assert batch_dir, (
"Missing `OPENPYPE_PUBLISH_DATA`")
- assert batch_dir, \
+ assert os.path.exists(batch_dir), \
"Folder {} doesn't exist".format(batch_dir)
project_name = os.environ.get("AVALON_PROJECT")
diff --git a/openpype/hosts/webpublisher/webserver_service/webpublish_routes.py b/openpype/hosts/webpublisher/webserver_service/webpublish_routes.py
index 0014d1b344..920ed042dc 100644
--- a/openpype/hosts/webpublisher/webserver_service/webpublish_routes.py
+++ b/openpype/hosts/webpublisher/webserver_service/webpublish_routes.py
@@ -11,6 +11,7 @@ from avalon.api import AvalonMongoDB
from openpype.lib import OpenPypeMongoConnection
from openpype_modules.avalon_apps.rest_api import _RestApiEndpoint
+from openpype.lib.plugin_tools import parse_json
from openpype.lib import PypeLogger
@@ -175,6 +176,9 @@ class TaskNode(Node):
class WebpublisherBatchPublishEndpoint(_RestApiEndpoint):
"""Triggers headless publishing of batch."""
async def post(self, request) -> Response:
+ # for postprocessing in host, currently only PS
+ host_map = {"photoshop": [".psd", ".psb"]}
+
output = {}
log.info("WebpublisherBatchPublishEndpoint called")
content = await request.json()
@@ -182,10 +186,44 @@ class WebpublisherBatchPublishEndpoint(_RestApiEndpoint):
batch_path = os.path.join(self.resource.upload_dir,
content["batch"])
+ add_args = {
+ "host": "webpublisher",
+ "project": content["project_name"],
+ "user": content["user"]
+ }
+
+ command = "remotepublish"
+
+ if content.get("studio_processing"):
+ log.info("Post processing called")
+
+ batch_data = parse_json(os.path.join(batch_path, "manifest.json"))
+ if not batch_data:
+ raise ValueError(
+ "Cannot parse batch meta in {} folder".format(batch_path))
+ task_dir_name = batch_data["tasks"][0]
+ task_data = parse_json(os.path.join(batch_path, task_dir_name,
+ "manifest.json"))
+ if not task_data:
+ raise ValueError(
+ "Cannot parse batch meta in {} folder".format(task_data))
+
+ command = "remotepublishfromapp"
+ for host, extensions in host_map.items():
+ for ext in extensions:
+ for file_name in task_data["files"]:
+ if ext in file_name:
+ add_args["host"] = host
+ break
+
+ if not add_args.get("host"):
+ raise ValueError(
+ "Couldn't discern host from {}".format(task_data["files"]))
+
openpype_app = self.resource.executable
args = [
openpype_app,
- 'remotepublish',
+ command,
batch_path
]
@@ -193,12 +231,6 @@ class WebpublisherBatchPublishEndpoint(_RestApiEndpoint):
msg = "Non existent OpenPype executable {}".format(openpype_app)
raise RuntimeError(msg)
- add_args = {
- "host": "webpublisher",
- "project": content["project_name"],
- "user": content["user"]
- }
-
for key, value in add_args.items():
args.append("--{}".format(key))
args.append(value)
diff --git a/openpype/lib/applications.py b/openpype/lib/applications.py
index cc8cb8e7be..b9bcecd3a0 100644
--- a/openpype/lib/applications.py
+++ b/openpype/lib/applications.py
@@ -461,13 +461,8 @@ class ApplicationExecutable:
# On MacOS check if exists path to executable when ends with `.app`
# - it is common that path will lead to "/Applications/Blender" but
# real path is "/Applications/Blender.app"
- if (
- platform.system().lower() == "darwin"
- and not os.path.exists(executable)
- ):
- _executable = executable + ".app"
- if os.path.exists(_executable):
- executable = _executable
+ if platform.system().lower() == "darwin":
+ executable = self.macos_executable_prep(executable)
self.executable_path = executable
@@ -477,6 +472,45 @@ class ApplicationExecutable:
def __repr__(self):
return "<{}> {}".format(self.__class__.__name__, self.executable_path)
+ @staticmethod
+ def macos_executable_prep(executable):
+ """Try to find full path to executable file.
+
+ Real executable is stored in '*.app/Contents/MacOS/'.
+
+ Having path to '*.app' gives ability to read it's plist info and
+ use "CFBundleExecutable" key from plist to know what is "executable."
+
+ Plist is stored in '*.app/Contents/Info.plist'.
+
+ This is because some '*.app' directories don't have same permissions
+ as real executable.
+ """
+ # Try to find if there is `.app` file
+ if not os.path.exists(executable):
+ _executable = executable + ".app"
+ if os.path.exists(_executable):
+ executable = _executable
+
+ # Try to find real executable if executable has `Contents` subfolder
+ contents_dir = os.path.join(executable, "Contents")
+ if os.path.exists(contents_dir):
+ executable_filename = None
+ # Load plist file and check for bundle executable
+ plist_filepath = os.path.join(contents_dir, "Info.plist")
+ if os.path.exists(plist_filepath):
+ import plistlib
+
+ parsed_plist = plistlib.readPlist(plist_filepath)
+ executable_filename = parsed_plist.get("CFBundleExecutable")
+
+ if executable_filename:
+ executable = os.path.join(
+ contents_dir, "MacOS", executable_filename
+ )
+
+ return executable
+
def as_args(self):
return [self.executable_path]
diff --git a/openpype/lib/delivery.py b/openpype/lib/delivery.py
index 5735cbc99d..c89e2e7ae0 100644
--- a/openpype/lib/delivery.py
+++ b/openpype/lib/delivery.py
@@ -245,6 +245,27 @@ def process_sequence(
report_items["Source file was not found"].append(msg)
return report_items, 0
+ delivery_templates = anatomy.templates.get("delivery") or {}
+ delivery_template = delivery_templates.get(template_name)
+ if delivery_template is None:
+ msg = (
+ "Delivery template \"{}\" in anatomy of project \"{}\""
+ " was not found"
+ ).format(template_name, anatomy.project_name)
+ report_items[""].append(msg)
+ return report_items, 0
+
+ # Check if 'frame' key is available in template which is required
+ # for sequence delivery
+ if "{frame" not in delivery_template:
+ msg = (
+ "Delivery template \"{}\" in anatomy of project \"{}\""
+ "does not contain '{{frame}}' key to fill. Delivery of sequence"
+ " can't be processed."
+ ).format(template_name, anatomy.project_name)
+ report_items[""].append(msg)
+ return report_items, 0
+
dir_path, file_name = os.path.split(str(src_path))
context = repre["context"]
diff --git a/openpype/lib/plugin_tools.py b/openpype/lib/plugin_tools.py
index ac60f0291e..aa9e0c9b57 100644
--- a/openpype/lib/plugin_tools.py
+++ b/openpype/lib/plugin_tools.py
@@ -28,17 +28,15 @@ class TaskNotSetError(KeyError):
super(TaskNotSetError, self).__init__(msg)
-def _get_subset_name(
+def get_subset_name_with_asset_doc(
family,
variant,
task_name,
- asset_id,
asset_doc,
- project_name,
- host_name,
- default_template,
- dynamic_data,
- dbcon
+ project_name=None,
+ host_name=None,
+ default_template=None,
+ dynamic_data=None
):
"""Calculate subset name based on passed context and OpenPype settings.
@@ -54,8 +52,6 @@ def _get_subset_name(
family (str): Instance family.
variant (str): In most of cases it is user input during creation.
task_name (str): Task name on which context is instance created.
- asset_id (ObjectId): Id of object. Is optional if `asset_doc` is
- passed.
asset_doc (dict): Queried asset document with it's tasks in data.
Used to get task type.
project_name (str): Name of project on which is instance created.
@@ -84,25 +80,6 @@ def _get_subset_name(
project_name = avalon.api.Session["AVALON_PROJECT"]
- # Query asset document if was not passed
- if asset_doc is None:
- if dbcon is None:
- from avalon.api import AvalonMongoDB
-
- dbcon = AvalonMongoDB()
- dbcon.Session["AVALON_PROJECT"] = project_name
-
- dbcon.install()
-
- asset_doc = dbcon.find_one(
- {
- "type": "asset",
- "_id": asset_id
- },
- {
- "data.tasks": True
- }
- ) or {}
asset_tasks = asset_doc.get("data", {}).get("tasks") or {}
task_info = asset_tasks.get(task_name) or {}
task_type = task_info.get("type")
@@ -144,34 +121,6 @@ def _get_subset_name(
return template.format(**prepare_template_data(fill_pairs))
-def get_subset_name_with_asset_doc(
- family,
- variant,
- task_name,
- asset_doc,
- project_name=None,
- host_name=None,
- default_template=None,
- dynamic_data=None,
- dbcon=None
-):
- """Calculate subset name using OpenPype settings.
-
- This variant of function expects already queried asset document.
- """
- return _get_subset_name(
- family, variant,
- task_name,
- None,
- asset_doc,
- project_name,
- host_name,
- default_template,
- dynamic_data,
- dbcon
- )
-
-
def get_subset_name(
family,
variant,
@@ -190,17 +139,28 @@ def get_subset_name(
This is legacy function should be replaced with
`get_subset_name_with_asset_doc` where asset document is expected.
"""
- return _get_subset_name(
+ if dbcon is None:
+ from avalon.api import AvalonMongoDB
+
+ dbcon = AvalonMongoDB()
+ dbcon.Session["AVALON_PROJECT"] = project_name
+
+ dbcon.install()
+
+ asset_doc = dbcon.find_one(
+ {"_id": asset_id},
+ {"data.tasks": True}
+ ) or {}
+
+ return get_subset_name_with_asset_doc(
family,
variant,
task_name,
- asset_id,
- None,
+ asset_doc,
project_name,
host_name,
default_template,
- dynamic_data,
- dbcon
+ dynamic_data
)
@@ -578,3 +538,48 @@ def should_decompress(file_url):
"compression: \"dwab\"" in output
return False
+
+
+def parse_json(path):
+ """Parses json file at 'path' location
+
+ Returns:
+ (dict) or None if unparsable
+ Raises:
+ AsssertionError if 'path' doesn't exist
+ """
+ path = path.strip('\"')
+ assert os.path.isfile(path), (
+ "Path to json file doesn't exist. \"{}\"".format(path)
+ )
+ data = None
+ with open(path, "r") as json_file:
+ try:
+ data = json.load(json_file)
+ except Exception as exc:
+ log.error(
+ "Error loading json: "
+ "{} - Exception: {}".format(path, exc)
+ )
+ return data
+
+
+def get_batch_asset_task_info(ctx):
+ """Parses context data from webpublisher's batch metadata
+
+ Returns:
+ (tuple): asset, task_name (Optional), task_type
+ """
+ task_type = "default_task_type"
+ task_name = None
+ asset = None
+
+ if ctx["type"] == "task":
+ items = ctx["path"].split('/')
+ asset = items[-2]
+ task_name = ctx["name"]
+ task_type = ctx["attributes"]["type"]
+ else:
+ asset = ctx["name"]
+
+ return asset, task_name, task_type
diff --git a/openpype/lib/remote_publish.py b/openpype/lib/remote_publish.py
new file mode 100644
index 0000000000..51007cfad2
--- /dev/null
+++ b/openpype/lib/remote_publish.py
@@ -0,0 +1,159 @@
+import os
+from datetime import datetime
+import sys
+from bson.objectid import ObjectId
+
+import pyblish.util
+import pyblish.api
+
+from openpype import uninstall
+from openpype.lib.mongo import OpenPypeMongoConnection
+
+
+def get_webpublish_conn():
+ """Get connection to OP 'webpublishes' collection."""
+ mongo_client = OpenPypeMongoConnection.get_mongo_client()
+ database_name = os.environ["OPENPYPE_DATABASE_NAME"]
+ return mongo_client[database_name]["webpublishes"]
+
+
+def start_webpublish_log(dbcon, batch_id, user):
+ """Start new log record for 'batch_id'
+
+ Args:
+ dbcon (OpenPypeMongoConnection)
+ batch_id (str)
+ user (str)
+ Returns
+ (ObjectId) from DB
+ """
+ return dbcon.insert_one({
+ "batch_id": batch_id,
+ "start_date": datetime.now(),
+ "user": user,
+ "status": "in_progress"
+ }).inserted_id
+
+
+def publish_and_log(dbcon, _id, log, close_plugin_name=None):
+ """Loops through all plugins, logs ok and fails into OP DB.
+
+ Args:
+ dbcon (OpenPypeMongoConnection)
+ _id (str)
+ log (OpenPypeLogger)
+ close_plugin_name (str): name of plugin with responsibility to
+ close host app
+ """
+ # Error exit as soon as any error occurs.
+ error_format = "Failed {plugin.__name__}: {error} -- {error.traceback}"
+
+ close_plugin = _get_close_plugin(close_plugin_name, log)
+
+ if isinstance(_id, str):
+ _id = ObjectId(_id)
+
+ log_lines = []
+ for result in pyblish.util.publish_iter():
+ for record in result["records"]:
+ log_lines.append("{}: {}".format(
+ result["plugin"].label, record.msg))
+
+ if result["error"]:
+ log.error(error_format.format(**result))
+ uninstall()
+ log_lines.append(error_format.format(**result))
+ dbcon.update_one(
+ {"_id": _id},
+ {"$set":
+ {
+ "finish_date": datetime.now(),
+ "status": "error",
+ "log": os.linesep.join(log_lines)
+
+ }}
+ )
+ if close_plugin: # close host app explicitly after error
+ context = pyblish.api.Context()
+ close_plugin().process(context)
+ sys.exit(1)
+ else:
+ dbcon.update_one(
+ {"_id": _id},
+ {"$set":
+ {
+ "progress": max(result["progress"], 0.95),
+ "log": os.linesep.join(log_lines)
+ }}
+ )
+
+ # final update
+ dbcon.update_one(
+ {"_id": _id},
+ {"$set":
+ {
+ "finish_date": datetime.now(),
+ "status": "finished_ok",
+ "progress": 1,
+ "log": os.linesep.join(log_lines)
+ }}
+ )
+
+
+def fail_batch(_id, batches_in_progress, dbcon):
+ """Set current batch as failed as there are some stuck batches."""
+ running_batches = [str(batch["_id"])
+ for batch in batches_in_progress
+ if batch["_id"] != _id]
+ msg = "There are still running batches {}\n". \
+ format("\n".join(running_batches))
+ msg += "Ask admin to check them and reprocess current batch"
+ dbcon.update_one(
+ {"_id": _id},
+ {"$set":
+ {
+ "finish_date": datetime.now(),
+ "status": "error",
+ "log": msg
+
+ }}
+ )
+ raise ValueError(msg)
+
+
+def find_variant_key(application_manager, host):
+ """Searches for latest installed variant for 'host'
+
+ Args:
+ application_manager (ApplicationManager)
+ host (str)
+ Returns
+ (string) (optional)
+ Raises:
+ (ValueError) if no variant found
+ """
+ app_group = application_manager.app_groups.get(host)
+ if not app_group or not app_group.enabled:
+ raise ValueError("No application {} configured".format(host))
+
+ found_variant_key = None
+ # finds most up-to-date variant if any installed
+ for variant_key, variant in app_group.variants.items():
+ for executable in variant.executables:
+ if executable.exists():
+ found_variant_key = variant_key
+
+ if not found_variant_key:
+ raise ValueError("No executable for {} found".format(host))
+
+ return found_variant_key
+
+
+def _get_close_plugin(close_plugin_name, log):
+ if close_plugin_name:
+ plugins = pyblish.api.discover()
+ for plugin in plugins:
+ if plugin.__name__ == close_plugin_name:
+ return plugin
+
+ log.warning("Close plugin not found, app might not close.")
diff --git a/openpype/modules/default_modules/ftrack/plugins/publish/collect_username.py b/openpype/modules/default_modules/ftrack/plugins/publish/collect_username.py
index 39b7433e11..844a397066 100644
--- a/openpype/modules/default_modules/ftrack/plugins/publish/collect_username.py
+++ b/openpype/modules/default_modules/ftrack/plugins/publish/collect_username.py
@@ -26,14 +26,21 @@ class CollectUsername(pyblish.api.ContextPlugin):
"""
order = pyblish.api.CollectorOrder - 0.488
label = "Collect ftrack username"
- hosts = ["webpublisher"]
+ hosts = ["webpublisher", "photoshop"]
_context = None
def process(self, context):
+ self.log.info("CollectUsername")
+ # photoshop could be triggered remotely in webpublisher fashion
+ if os.environ["AVALON_APP"] == "photoshop":
+ if not os.environ.get("IS_HEADLESS"):
+ self.log.debug("Regular process, skipping")
+ return
+
os.environ["FTRACK_API_USER"] = os.environ["FTRACK_BOT_API_USER"]
os.environ["FTRACK_API_KEY"] = os.environ["FTRACK_BOT_API_KEY"]
- self.log.info("CollectUsername")
+
for instance in context:
email = instance.data["user_email"]
self.log.info("email:: {}".format(email))
diff --git a/openpype/plugins/publish/integrate_new.py b/openpype/plugins/publish/integrate_new.py
index 451ea1d80d..753ed78083 100644
--- a/openpype/plugins/publish/integrate_new.py
+++ b/openpype/plugins/publish/integrate_new.py
@@ -99,7 +99,8 @@ class IntegrateAssetNew(pyblish.api.InstancePlugin):
"camerarig",
"redshiftproxy",
"effect",
- "xgen"
+ "xgen",
+ "hda"
]
exclude_families = ["clip"]
db_representation_context_keys = [
diff --git a/openpype/plugins/publish/validate_containers.py b/openpype/plugins/publish/validate_containers.py
index 784221c3b6..ce91bd3396 100644
--- a/openpype/plugins/publish/validate_containers.py
+++ b/openpype/plugins/publish/validate_containers.py
@@ -9,9 +9,9 @@ class ShowInventory(pyblish.api.Action):
on = "failed"
def process(self, context, plugin):
- from avalon.tools import sceneinventory
+ from openpype.tools.utils import host_tools
- sceneinventory.show()
+ host_tools.show_scene_inventory()
class ValidateContainers(pyblish.api.ContextPlugin):
diff --git a/openpype/pype_commands.py b/openpype/pype_commands.py
index 5288749e8b..4bef3b7a15 100644
--- a/openpype/pype_commands.py
+++ b/openpype/pype_commands.py
@@ -3,10 +3,18 @@
import os
import sys
import json
-from datetime import datetime
+import time
from openpype.lib import PypeLogger
from openpype.api import get_app_environments_for_context
+from openpype.lib.plugin_tools import parse_json, get_batch_asset_task_info
+from openpype.lib.remote_publish import (
+ get_webpublish_conn,
+ start_webpublish_log,
+ publish_and_log,
+ fail_batch,
+ find_variant_key
+)
class PypeCommands:
@@ -110,10 +118,116 @@ class PypeCommands:
log.info("Publish finished.")
uninstall()
+ @staticmethod
+ def remotepublishfromapp(project, batch_dir, host, user, targets=None):
+ """Opens installed variant of 'host' and run remote publish there.
+
+ Currently implemented and tested for Photoshop where customer
+ wants to process uploaded .psd file and publish collected layers
+ from there.
+
+ Checks if no other batches are running (status =='in_progress). If
+ so, it sleeps for SLEEP (this is separate process),
+ waits for WAIT_FOR seconds altogether.
+
+ Requires installed host application on the machine.
+
+ Runs publish process as user would, in automatic fashion.
+ """
+ SLEEP = 5 # seconds for another loop check for concurrently runs
+ WAIT_FOR = 300 # seconds to wait for conc. runs
+
+ from openpype import install, uninstall
+ from openpype.api import Logger
+
+ log = Logger.get_logger()
+
+ log.info("remotepublishphotoshop command")
+
+ install()
+
+ from openpype.lib import ApplicationManager
+ application_manager = ApplicationManager()
+
+ found_variant_key = find_variant_key(application_manager, host)
+
+ app_name = "{}/{}".format(host, found_variant_key)
+
+ batch_data = None
+ if batch_dir and os.path.exists(batch_dir):
+ batch_data = parse_json(os.path.join(batch_dir, "manifest.json"))
+
+ if not batch_data:
+ raise ValueError(
+ "Cannot parse batch meta in {} folder".format(batch_dir))
+
+ asset, task_name, _task_type = get_batch_asset_task_info(
+ batch_data["context"])
+
+ # processing from app expects JUST ONE task in batch and 1 workfile
+ task_dir_name = batch_data["tasks"][0]
+ task_data = parse_json(os.path.join(batch_dir, task_dir_name,
+ "manifest.json"))
+
+ workfile_path = os.path.join(batch_dir,
+ task_dir_name,
+ task_data["files"][0])
+
+ print("workfile_path {}".format(workfile_path))
+
+ _, batch_id = os.path.split(batch_dir)
+ dbcon = get_webpublish_conn()
+ # safer to start logging here, launch might be broken altogether
+ _id = start_webpublish_log(dbcon, batch_id, user)
+
+ in_progress = True
+ slept_times = 0
+ while in_progress:
+ batches_in_progress = list(dbcon.find({
+ "status": "in_progress"
+ }))
+ if len(batches_in_progress) > 1:
+ if slept_times * SLEEP >= WAIT_FOR:
+ fail_batch(_id, batches_in_progress, dbcon)
+
+ print("Another batch running, sleeping for a bit")
+ time.sleep(SLEEP)
+ slept_times += 1
+ else:
+ in_progress = False
+
+ # must have for proper launch of app
+ env = get_app_environments_for_context(
+ project,
+ asset,
+ task_name,
+ app_name
+ )
+ os.environ.update(env)
+
+ os.environ["OPENPYPE_PUBLISH_DATA"] = batch_dir
+ os.environ["IS_HEADLESS"] = "true"
+ # must pass identifier to update log lines for a batch
+ os.environ["BATCH_LOG_ID"] = str(_id)
+
+ data = {
+ "last_workfile_path": workfile_path,
+ "start_last_workfile": True
+ }
+
+ launched_app = application_manager.launch(app_name, **data)
+
+ while launched_app.poll() is None:
+ time.sleep(0.5)
+
+ uninstall()
+
@staticmethod
def remotepublish(project, batch_path, host, user, targets=None):
"""Start headless publishing.
+ Used to publish rendered assets, workfiles etc.
+
Publish use json from passed paths argument.
Args:
@@ -134,7 +248,6 @@ class PypeCommands:
from openpype import install, uninstall
from openpype.api import Logger
- from openpype.lib import OpenPypeMongoConnection
# Register target and host
import pyblish.api
@@ -166,62 +279,11 @@ class PypeCommands:
log.info("Running publish ...")
- # Error exit as soon as any error occurs.
- error_format = "Failed {plugin.__name__}: {error} -- {error.traceback}"
-
- mongo_client = OpenPypeMongoConnection.get_mongo_client()
- database_name = os.environ["OPENPYPE_DATABASE_NAME"]
- dbcon = mongo_client[database_name]["webpublishes"]
-
_, batch_id = os.path.split(batch_path)
- _id = dbcon.insert_one({
- "batch_id": batch_id,
- "start_date": datetime.now(),
- "user": user,
- "status": "in_progress"
- }).inserted_id
+ dbcon = get_webpublish_conn()
+ _id = start_webpublish_log(dbcon, batch_id, user)
- log_lines = []
- for result in pyblish.util.publish_iter():
- for record in result["records"]:
- log_lines.append("{}: {}".format(
- result["plugin"].label, record.msg))
-
- if result["error"]:
- log.error(error_format.format(**result))
- uninstall()
- log_lines.append(error_format.format(**result))
- dbcon.update_one(
- {"_id": _id},
- {"$set":
- {
- "finish_date": datetime.now(),
- "status": "error",
- "log": os.linesep.join(log_lines)
-
- }}
- )
- sys.exit(1)
- else:
- dbcon.update_one(
- {"_id": _id},
- {"$set":
- {
- "progress": max(result["progress"], 0.95),
- "log": os.linesep.join(log_lines)
- }}
- )
-
- dbcon.update_one(
- {"_id": _id},
- {"$set":
- {
- "finish_date": datetime.now(),
- "status": "finished_ok",
- "progress": 1,
- "log": os.linesep.join(log_lines)
- }}
- )
+ publish_and_log(dbcon, _id, log)
log.info("Publish finished.")
uninstall()
diff --git a/openpype/resources/app_icons/flame.png b/openpype/resources/app_icons/flame.png
new file mode 100644
index 0000000000..ba9b69e45f
Binary files /dev/null and b/openpype/resources/app_icons/flame.png differ
diff --git a/openpype/settings/defaults/project_anatomy/imageio.json b/openpype/settings/defaults/project_anatomy/imageio.json
index 38313a3d84..25608f67c6 100644
--- a/openpype/settings/defaults/project_anatomy/imageio.json
+++ b/openpype/settings/defaults/project_anatomy/imageio.json
@@ -162,9 +162,7 @@
]
}
],
- "customNodes": [
-
- ]
+ "customNodes": []
},
"regexInputs": {
"inputs": [
diff --git a/openpype/settings/defaults/project_settings/photoshop.json b/openpype/settings/defaults/project_settings/photoshop.json
index 36c30bad6c..eb9f96e348 100644
--- a/openpype/settings/defaults/project_settings/photoshop.json
+++ b/openpype/settings/defaults/project_settings/photoshop.json
@@ -12,6 +12,16 @@
"optional": true,
"active": true
},
+ "CollectRemoteInstances": {
+ "color_code_mapping": [
+ {
+ "color_code": [],
+ "layer_name_regex": [],
+ "family": "",
+ "subset_template_name": ""
+ }
+ ]
+ },
"ExtractImage": {
"formats": [
"png",
diff --git a/openpype/settings/defaults/system_settings/applications.json b/openpype/settings/defaults/system_settings/applications.json
index cfdeca4b87..cc80a94d3f 100644
--- a/openpype/settings/defaults/system_settings/applications.json
+++ b/openpype/settings/defaults/system_settings/applications.json
@@ -97,6 +97,42 @@
}
}
},
+ "flame": {
+ "enabled": true,
+ "label": "Flame",
+ "icon": "{}/app_icons/flame.png",
+ "host_name": "flame",
+ "environment": {
+ "FLAME_SCRIPT_DIRS": {
+ "windows": "",
+ "darwin": "",
+ "linux": ""
+ }
+ },
+ "variants": {
+ "2021": {
+ "use_python_2": true,
+ "executables": {
+ "windows": [],
+ "darwin": [
+ "/opt/Autodesk/flame_2021/bin/flame.app/Contents/MacOS/startApp"
+ ],
+ "linux": [
+ "/opt/Autodesk/flame_2021/bin/flame"
+ ]
+ },
+ "arguments": {
+ "windows": [],
+ "darwin": [],
+ "linux": []
+ },
+ "environment": {}
+ },
+ "__dynamic_keys_labels__": {
+ "2021": "2021 (Testing Only)"
+ }
+ }
+ },
"nuke": {
"enabled": true,
"label": "Nuke",
@@ -620,12 +656,12 @@
"FUSION_UTILITY_SCRIPTS_SOURCE_DIR": [],
"FUSION_UTILITY_SCRIPTS_DIR": {
"windows": "{PROGRAMDATA}/Blackmagic Design/Fusion/Scripts/Comp",
- "darvin": "/Library/Application Support/Blackmagic Design/Fusion/Scripts/Comp",
+ "darwin": "/Library/Application Support/Blackmagic Design/Fusion/Scripts/Comp",
"linux": "/opt/Fusion/Scripts/Comp"
},
"PYTHON36": {
"windows": "{LOCALAPPDATA}/Programs/Python/Python36",
- "darvin": "~/Library/Python/3.6/bin",
+ "darwin": "~/Library/Python/3.6/bin",
"linux": "/opt/Python/3.6/bin"
},
"PYTHONPATH": [
@@ -686,22 +722,22 @@
"RESOLVE_UTILITY_SCRIPTS_SOURCE_DIR": [],
"RESOLVE_SCRIPT_API": {
"windows": "{PROGRAMDATA}/Blackmagic Design/DaVinci Resolve/Support/Developer/Scripting",
- "darvin": "/Library/Application Support/Blackmagic Design/DaVinci Resolve/Developer/Scripting",
+ "darwin": "/Library/Application Support/Blackmagic Design/DaVinci Resolve/Developer/Scripting",
"linux": "/opt/resolve/Developer/Scripting"
},
"RESOLVE_SCRIPT_LIB": {
"windows": "C:/Program Files/Blackmagic Design/DaVinci Resolve/fusionscript.dll",
- "darvin": "/Applications/DaVinci Resolve/DaVinci Resolve.app/Contents/Libraries/Fusion/fusionscript.so",
+ "darwin": "/Applications/DaVinci Resolve/DaVinci Resolve.app/Contents/Libraries/Fusion/fusionscript.so",
"linux": "/opt/resolve/libs/Fusion/fusionscript.so"
},
"RESOLVE_UTILITY_SCRIPTS_DIR": {
"windows": "{PROGRAMDATA}/Blackmagic Design/DaVinci Resolve/Fusion/Scripts/Comp",
- "darvin": "/Library/Application Support/Blackmagic Design/DaVinci Resolve/Fusion/Scripts/Comp",
+ "darwin": "/Library/Application Support/Blackmagic Design/DaVinci Resolve/Fusion/Scripts/Comp",
"linux": "/opt/resolve/Fusion/Scripts/Comp"
},
"PYTHON36_RESOLVE": {
"windows": "{LOCALAPPDATA}/Programs/Python/Python36",
- "darvin": "~/Library/Python/3.6/bin",
+ "darwin": "~/Library/Python/3.6/bin",
"linux": "/opt/Python/3.6/bin"
},
"PYTHONPATH": [
@@ -973,8 +1009,6 @@
},
"variants": {
"2020": {
- "enabled": true,
- "variant_label": "2020",
"executables": {
"windows": [
"C:\\Program Files\\Adobe\\Adobe Photoshop 2020\\Photoshop.exe"
@@ -990,8 +1024,6 @@
"environment": {}
},
"2021": {
- "enabled": true,
- "variant_label": "2021",
"executables": {
"windows": [
"C:\\Program Files\\Adobe\\Adobe Photoshop 2021\\Photoshop.exe"
@@ -1005,6 +1037,21 @@
"linux": []
},
"environment": {}
+ },
+ "2022": {
+ "executables": {
+ "windows": [
+ "C:\\Program Files\\Adobe\\Adobe Photoshop 2022\\Photoshop.exe"
+ ],
+ "darwin": [],
+ "linux": []
+ },
+ "arguments": {
+ "windows": [],
+ "darwin": [],
+ "linux": []
+ },
+ "environment": {}
}
}
},
diff --git a/openpype/settings/entities/__init__.py b/openpype/settings/entities/__init__.py
index aae2d1fa89..775bf40ac4 100644
--- a/openpype/settings/entities/__init__.py
+++ b/openpype/settings/entities/__init__.py
@@ -110,7 +110,10 @@ from .enum_entity import (
)
from .list_entity import ListEntity
-from .dict_immutable_keys_entity import DictImmutableKeysEntity
+from .dict_immutable_keys_entity import (
+ DictImmutableKeysEntity,
+ RootsDictEntity
+)
from .dict_mutable_keys_entity import DictMutableKeysEntity
from .dict_conditional import (
DictConditionalEntity,
@@ -169,6 +172,7 @@ __all__ = (
"ListEntity",
"DictImmutableKeysEntity",
+ "RootsDictEntity",
"DictMutableKeysEntity",
diff --git a/openpype/settings/entities/base_entity.py b/openpype/settings/entities/base_entity.py
index 0e8274d374..341968bd75 100644
--- a/openpype/settings/entities/base_entity.py
+++ b/openpype/settings/entities/base_entity.py
@@ -510,7 +510,7 @@ class BaseItemEntity(BaseEntity):
pass
@abstractmethod
- def _item_initalization(self):
+ def _item_initialization(self):
"""Entity specific initialization process."""
pass
@@ -920,7 +920,7 @@ class ItemEntity(BaseItemEntity):
_default_label_wrap["collapsed"]
)
- self._item_initalization()
+ self._item_initialization()
def save(self):
"""Call save on root item."""
diff --git a/openpype/settings/entities/color_entity.py b/openpype/settings/entities/color_entity.py
index dfaa75e761..3becf2d865 100644
--- a/openpype/settings/entities/color_entity.py
+++ b/openpype/settings/entities/color_entity.py
@@ -9,7 +9,7 @@ from .exceptions import (
class ColorEntity(InputEntity):
schema_types = ["color"]
- def _item_initalization(self):
+ def _item_initialization(self):
self.valid_value_types = (list, )
self.value_on_not_set = [0, 0, 0, 255]
self.use_alpha = self.schema_data.get("use_alpha", True)
diff --git a/openpype/settings/entities/dict_conditional.py b/openpype/settings/entities/dict_conditional.py
index 6f27760570..0cb8827991 100644
--- a/openpype/settings/entities/dict_conditional.py
+++ b/openpype/settings/entities/dict_conditional.py
@@ -107,7 +107,7 @@ class DictConditionalEntity(ItemEntity):
for _key, _value in new_value.items():
self.non_gui_children[self.current_enum][_key].set(_value)
- def _item_initalization(self):
+ def _item_initialization(self):
self._default_metadata = NOT_SET
self._studio_override_metadata = NOT_SET
self._project_override_metadata = NOT_SET
diff --git a/openpype/settings/entities/dict_immutable_keys_entity.py b/openpype/settings/entities/dict_immutable_keys_entity.py
index 57e21ff5f3..d0cd41d11c 100644
--- a/openpype/settings/entities/dict_immutable_keys_entity.py
+++ b/openpype/settings/entities/dict_immutable_keys_entity.py
@@ -4,7 +4,8 @@ import collections
from .lib import (
WRAPPER_TYPES,
OverrideState,
- NOT_SET
+ NOT_SET,
+ STRING_TYPE
)
from openpype.settings.constants import (
METADATA_KEYS,
@@ -18,6 +19,7 @@ from . import (
GUIEntity
)
from .exceptions import (
+ DefaultsNotDefined,
SchemaDuplicatedKeys,
EntitySchemaError,
InvalidKeySymbols
@@ -172,7 +174,7 @@ class DictImmutableKeysEntity(ItemEntity):
for child_obj in added_children:
self.gui_layout.append(child_obj)
- def _item_initalization(self):
+ def _item_initialization(self):
self._default_metadata = NOT_SET
self._studio_override_metadata = NOT_SET
self._project_override_metadata = NOT_SET
@@ -547,3 +549,178 @@ class DictImmutableKeysEntity(ItemEntity):
super(DictImmutableKeysEntity, self).reset_callbacks()
for child_entity in self.children:
child_entity.reset_callbacks()
+
+
+class RootsDictEntity(DictImmutableKeysEntity):
+ """Entity that adds ability to fill value for roots of current project.
+
+ Value schema is defined by `object_type`.
+
+ It is not possible to change override state (Studio values will always
+ contain studio overrides and same for project). That is because roots can
+ be totally different for each project.
+ """
+ _origin_schema_data = None
+ schema_types = ["dict-roots"]
+
+ def _item_initialization(self):
+ origin_schema_data = self.schema_data
+
+ self.separate_items = origin_schema_data.get("separate_items", True)
+ object_type = origin_schema_data.get("object_type")
+ if isinstance(object_type, STRING_TYPE):
+ object_type = {"type": object_type}
+ self.object_type = object_type
+
+ if not self.is_group:
+ self.is_group = True
+
+ schema_data = copy.deepcopy(self.schema_data)
+ schema_data["children"] = []
+
+ self.schema_data = schema_data
+ self._origin_schema_data = origin_schema_data
+
+ self._default_value = NOT_SET
+ self._studio_value = NOT_SET
+ self._project_value = NOT_SET
+
+ super(RootsDictEntity, self)._item_initialization()
+
+ def schema_validations(self):
+ if self.object_type is None:
+ reason = (
+ "Missing children definitions for root values"
+ " ('object_type' not filled)."
+ )
+ raise EntitySchemaError(self, reason)
+
+ if not isinstance(self.object_type, dict):
+ reason = (
+ "Children definitions for root values must be dictionary"
+ " ('object_type' is \"{}\")."
+ ).format(str(type(self.object_type)))
+ raise EntitySchemaError(self, reason)
+
+ super(RootsDictEntity, self).schema_validations()
+
+ def set_override_state(self, state, ignore_missing_defaults):
+ self.children = []
+ self.non_gui_children = {}
+ self.gui_layout = []
+
+ roots_entity = self.get_entity_from_path(
+ "project_anatomy/roots"
+ )
+ children = []
+ first = True
+ for key in roots_entity.keys():
+ if first:
+ first = False
+ elif self.separate_items:
+ children.append({"type": "separator"})
+ child = copy.deepcopy(self.object_type)
+ child["key"] = key
+ child["label"] = key
+ children.append(child)
+
+ schema_data = copy.deepcopy(self.schema_data)
+ schema_data["children"] = children
+
+ self._add_children(schema_data)
+
+ self._set_children_values(state)
+
+ super(RootsDictEntity, self).set_override_state(
+ state, True
+ )
+
+ if state == OverrideState.STUDIO:
+ self.add_to_studio_default()
+
+ elif state == OverrideState.PROJECT:
+ self.add_to_project_override()
+
+ def on_child_change(self, child_obj):
+ if self._override_state is OverrideState.STUDIO:
+ if not child_obj.has_studio_override:
+ self.add_to_studio_default()
+
+ elif self._override_state is OverrideState.PROJECT:
+ if not child_obj.has_project_override:
+ self.add_to_project_override()
+
+ return super(RootsDictEntity, self).on_child_change(child_obj)
+
+ def _set_children_values(self, state):
+ if state >= OverrideState.DEFAULTS:
+ default_value = self._default_value
+ if default_value is NOT_SET:
+ if state > OverrideState.DEFAULTS:
+ raise DefaultsNotDefined(self)
+ else:
+ default_value = {}
+
+ for key, child_obj in self.non_gui_children.items():
+ child_value = default_value.get(key, NOT_SET)
+ child_obj.update_default_value(child_value)
+
+ if state >= OverrideState.STUDIO:
+ value = self._studio_value
+ if value is NOT_SET:
+ value = {}
+
+ for key, child_obj in self.non_gui_children.items():
+ child_value = value.get(key, NOT_SET)
+ child_obj.update_studio_value(child_value)
+
+ if state >= OverrideState.PROJECT:
+ value = self._project_value
+ if value is NOT_SET:
+ value = {}
+
+ for key, child_obj in self.non_gui_children.items():
+ child_value = value.get(key, NOT_SET)
+ child_obj.update_project_value(child_value)
+
+ def _update_current_metadata(self):
+ """Override this method as this entity should not have metadata."""
+ self._metadata_are_modified = False
+ self._current_metadata = {}
+
+ def update_default_value(self, value):
+ """Update default values.
+
+ Not an api method, should be called by parent.
+ """
+ value = self._check_update_value(value, "default")
+ value, _ = self._prepare_value(value)
+
+ self._default_value = value
+ self._default_metadata = {}
+ self.has_default_value = value is not NOT_SET
+
+ def update_studio_value(self, value):
+ """Update studio override values.
+
+ Not an api method, should be called by parent.
+ """
+ value = self._check_update_value(value, "studio override")
+ value, _ = self._prepare_value(value)
+
+ self._studio_value = value
+ self._studio_override_metadata = {}
+ self.had_studio_override = value is not NOT_SET
+
+ def update_project_value(self, value):
+ """Update project override values.
+
+ Not an api method, should be called by parent.
+ """
+
+ value = self._check_update_value(value, "project override")
+ value, _metadata = self._prepare_value(value)
+
+ self._project_value = value
+ self._project_override_metadata = {}
+ self.had_project_override = value is not NOT_SET
diff --git a/openpype/settings/entities/dict_mutable_keys_entity.py b/openpype/settings/entities/dict_mutable_keys_entity.py
index f75fb23d82..cff346e9ea 100644
--- a/openpype/settings/entities/dict_mutable_keys_entity.py
+++ b/openpype/settings/entities/dict_mutable_keys_entity.py
@@ -191,7 +191,7 @@ class DictMutableKeysEntity(EndpointEntity):
child_entity = self.children_by_key[key]
self.set_child_label(child_entity, label)
- def _item_initalization(self):
+ def _item_initialization(self):
self._default_metadata = {}
self._studio_override_metadata = {}
self._project_override_metadata = {}
diff --git a/openpype/settings/entities/enum_entity.py b/openpype/settings/entities/enum_entity.py
index a5e734f039..ab3cebbd42 100644
--- a/openpype/settings/entities/enum_entity.py
+++ b/openpype/settings/entities/enum_entity.py
@@ -8,7 +8,7 @@ from .lib import (
class BaseEnumEntity(InputEntity):
- def _item_initalization(self):
+ def _item_initialization(self):
self.multiselection = True
self.value_on_not_set = None
self.enum_items = None
@@ -70,7 +70,7 @@ class BaseEnumEntity(InputEntity):
class EnumEntity(BaseEnumEntity):
schema_types = ["enum"]
- def _item_initalization(self):
+ def _item_initialization(self):
self.multiselection = self.schema_data.get("multiselection", False)
self.enum_items = self.schema_data.get("enum_items")
# Default is optional and non breaking attribute
@@ -143,6 +143,7 @@ class HostsEnumEntity(BaseEnumEntity):
"aftereffects",
"blender",
"celaction",
+ "flame",
"fusion",
"harmony",
"hiero",
@@ -156,7 +157,7 @@ class HostsEnumEntity(BaseEnumEntity):
"standalonepublisher"
]
- def _item_initalization(self):
+ def _item_initialization(self):
self.multiselection = self.schema_data.get("multiselection", True)
use_empty_value = False
if not self.multiselection:
@@ -249,7 +250,7 @@ class HostsEnumEntity(BaseEnumEntity):
class AppsEnumEntity(BaseEnumEntity):
schema_types = ["apps-enum"]
- def _item_initalization(self):
+ def _item_initialization(self):
self.multiselection = True
self.value_on_not_set = []
self.enum_items = []
@@ -316,7 +317,7 @@ class AppsEnumEntity(BaseEnumEntity):
class ToolsEnumEntity(BaseEnumEntity):
schema_types = ["tools-enum"]
- def _item_initalization(self):
+ def _item_initialization(self):
self.multiselection = True
self.value_on_not_set = []
self.enum_items = []
@@ -375,7 +376,7 @@ class ToolsEnumEntity(BaseEnumEntity):
class TaskTypeEnumEntity(BaseEnumEntity):
schema_types = ["task-types-enum"]
- def _item_initalization(self):
+ def _item_initialization(self):
self.multiselection = self.schema_data.get("multiselection", True)
if self.multiselection:
self.valid_value_types = (list, )
@@ -451,7 +452,7 @@ class TaskTypeEnumEntity(BaseEnumEntity):
class DeadlineUrlEnumEntity(BaseEnumEntity):
schema_types = ["deadline_url-enum"]
- def _item_initalization(self):
+ def _item_initialization(self):
self.multiselection = self.schema_data.get("multiselection", True)
self.enum_items = []
@@ -502,7 +503,7 @@ class DeadlineUrlEnumEntity(BaseEnumEntity):
class AnatomyTemplatesEnumEntity(BaseEnumEntity):
schema_types = ["anatomy-templates-enum"]
- def _item_initalization(self):
+ def _item_initialization(self):
self.multiselection = False
self.enum_items = []
diff --git a/openpype/settings/entities/input_entities.py b/openpype/settings/entities/input_entities.py
index 0ded3ab7e5..a0598d405e 100644
--- a/openpype/settings/entities/input_entities.py
+++ b/openpype/settings/entities/input_entities.py
@@ -362,7 +362,7 @@ class NumberEntity(InputEntity):
float_number_regex = re.compile(r"^\d+\.\d+$")
int_number_regex = re.compile(r"^\d+$")
- def _item_initalization(self):
+ def _item_initialization(self):
self.minimum = self.schema_data.get("minimum", -99999)
self.maximum = self.schema_data.get("maximum", 99999)
self.decimal = self.schema_data.get("decimal", 0)
@@ -420,7 +420,7 @@ class NumberEntity(InputEntity):
class BoolEntity(InputEntity):
schema_types = ["boolean"]
- def _item_initalization(self):
+ def _item_initialization(self):
self.valid_value_types = (bool, )
value_on_not_set = self.convert_to_valid_type(
self.schema_data.get("default", True)
@@ -431,7 +431,7 @@ class BoolEntity(InputEntity):
class TextEntity(InputEntity):
schema_types = ["text"]
- def _item_initalization(self):
+ def _item_initialization(self):
self.valid_value_types = (STRING_TYPE, )
self.value_on_not_set = ""
@@ -449,7 +449,7 @@ class TextEntity(InputEntity):
class PathInput(InputEntity):
schema_types = ["path-input"]
- def _item_initalization(self):
+ def _item_initialization(self):
self.valid_value_types = (STRING_TYPE, )
self.value_on_not_set = ""
@@ -460,7 +460,7 @@ class PathInput(InputEntity):
class RawJsonEntity(InputEntity):
schema_types = ["raw-json"]
- def _item_initalization(self):
+ def _item_initialization(self):
# Schema must define if valid value is dict or list
store_as_string = self.schema_data.get("store_as_string", False)
is_list = self.schema_data.get("is_list", False)
diff --git a/openpype/settings/entities/item_entities.py b/openpype/settings/entities/item_entities.py
index c7c9c3097e..ff0a982900 100644
--- a/openpype/settings/entities/item_entities.py
+++ b/openpype/settings/entities/item_entities.py
@@ -48,7 +48,7 @@ class PathEntity(ItemEntity):
raise AttributeError(self.attribute_error_msg.format("items"))
return self.child_obj.items()
- def _item_initalization(self):
+ def _item_initialization(self):
if self.group_item is None and not self.is_group:
self.is_group = True
@@ -216,7 +216,7 @@ class ListStrictEntity(ItemEntity):
return self.children[idx]
return default
- def _item_initalization(self):
+ def _item_initialization(self):
self.valid_value_types = (list, )
self.require_key = True
diff --git a/openpype/settings/entities/list_entity.py b/openpype/settings/entities/list_entity.py
index b06f4d7a2e..5d89a81351 100644
--- a/openpype/settings/entities/list_entity.py
+++ b/openpype/settings/entities/list_entity.py
@@ -149,7 +149,7 @@ class ListEntity(EndpointEntity):
return list(value)
return NOT_SET
- def _item_initalization(self):
+ def _item_initialization(self):
self.valid_value_types = (list, )
self.children = []
self.value_on_not_set = []
diff --git a/openpype/settings/entities/root_entities.py b/openpype/settings/entities/root_entities.py
index 05d20ee60b..b8baed8a93 100644
--- a/openpype/settings/entities/root_entities.py
+++ b/openpype/settings/entities/root_entities.py
@@ -65,7 +65,7 @@ class RootEntity(BaseItemEntity):
super(RootEntity, self).__init__(schema_data)
self._require_restart_callbacks = []
self._item_ids_require_restart = set()
- self._item_initalization()
+ self._item_initialization()
if reset:
self.reset()
@@ -176,7 +176,7 @@ class RootEntity(BaseItemEntity):
for child_obj in added_children:
self.gui_layout.append(child_obj)
- def _item_initalization(self):
+ def _item_initialization(self):
# Store `self` to `root_item` for children entities
self.root_item = self
diff --git a/openpype/settings/entities/schemas/README.md b/openpype/settings/entities/schemas/README.md
index 5258fef9ec..4e8dcc36ce 100644
--- a/openpype/settings/entities/schemas/README.md
+++ b/openpype/settings/entities/schemas/README.md
@@ -208,6 +208,25 @@
}
```
+## dict-roots
+- entity can be used only in Project settings
+- keys of dictionary are based on current project roots
+- they are not updated "live" it is required to save root changes and then
+ modify values on this entity
+ # TODO do live updates
+```
+{
+ "type": "dict-roots",
+ "key": "roots",
+ "label": "Roots",
+ "object_type": {
+ "type": "path",
+ "multiplatform": true,
+ "multipath": false
+ }
+}
+```
+
## dict-conditional
- is similar to `dict` but has always available one enum entity
- the enum entity has single selection and it's value define other children entities
diff --git a/openpype/settings/entities/schemas/projects_schema/schema_project_photoshop.json b/openpype/settings/entities/schemas/projects_schema/schema_project_photoshop.json
index 6f5577650c..f00bf78fe4 100644
--- a/openpype/settings/entities/schemas/projects_schema/schema_project_photoshop.json
+++ b/openpype/settings/entities/schemas/projects_schema/schema_project_photoshop.json
@@ -43,6 +43,61 @@
}
]
},
+ {
+ "type": "dict",
+ "collapsible": true,
+ "is_group": true,
+ "key": "CollectRemoteInstances",
+ "label": "Collect Instances for Webpublish",
+ "children": [
+ {
+ "type": "label",
+ "label": "Set color for publishable layers, set publishable families."
+ },
+ {
+ "type": "list",
+ "key": "color_code_mapping",
+ "label": "Color code mappings",
+ "use_label_wrap": false,
+ "collapsible": false,
+ "object_type": {
+ "type": "dict",
+ "children": [
+ {
+ "type": "list",
+ "key": "color_code",
+ "label": "Color codes for layers",
+ "object_type": "text"
+ },
+ {
+ "type": "list",
+ "key": "layer_name_regex",
+ "label": "Layer name regex",
+ "object_type": "text"
+ },
+ {
+ "type": "splitter"
+ },
+ {
+ "key": "family",
+ "label": "Resulting family",
+ "type": "enum",
+ "enum_items": [
+ {
+ "image": "image"
+ }
+ ]
+ },
+ {
+ "type": "text",
+ "key": "subset_template_name",
+ "label": "Subset template name"
+ }
+ ]
+ }
+ }
+ ]
+ },
{
"type": "dict",
"collapsible": true,
diff --git a/openpype/settings/entities/schemas/system_schema/host_settings/schema_flame.json b/openpype/settings/entities/schemas/system_schema/host_settings/schema_flame.json
new file mode 100644
index 0000000000..1a9d8d4716
--- /dev/null
+++ b/openpype/settings/entities/schemas/system_schema/host_settings/schema_flame.json
@@ -0,0 +1,39 @@
+{
+ "type": "dict",
+ "key": "flame",
+ "label": "Autodesk Flame",
+ "collapsible": true,
+ "checkbox_key": "enabled",
+ "children": [
+ {
+ "type": "boolean",
+ "key": "enabled",
+ "label": "Enabled"
+ },
+ {
+ "type": "schema_template",
+ "name": "template_host_unchangables"
+ },
+ {
+ "key": "environment",
+ "label": "Environment",
+ "type": "raw-json"
+ },
+ {
+ "type": "dict-modifiable",
+ "key": "variants",
+ "collapsible_key": true,
+ "use_label_wrap": false,
+ "object_type": {
+ "type": "dict",
+ "collapsible": true,
+ "children": [
+ {
+ "type": "schema_template",
+ "name": "template_host_variant_items"
+ }
+ ]
+ }
+ }
+ ]
+}
diff --git a/openpype/settings/entities/schemas/system_schema/host_settings/schema_photoshop.json b/openpype/settings/entities/schemas/system_schema/host_settings/schema_photoshop.json
index 7bcd89c650..0687b9699b 100644
--- a/openpype/settings/entities/schemas/system_schema/host_settings/schema_photoshop.json
+++ b/openpype/settings/entities/schemas/system_schema/host_settings/schema_photoshop.json
@@ -20,26 +20,21 @@
"type": "raw-json"
},
{
- "type": "dict",
+ "type": "dict-modifiable",
"key": "variants",
- "children": [
- {
- "type": "schema_template",
- "name": "template_host_variant",
- "template_data": [
- {
- "app_variant_label": "2020",
- "app_variant": "2020",
- "variant_skip_paths": ["use_python_2"]
- },
- {
- "app_variant_label": "2021",
- "app_variant": "2021",
- "variant_skip_paths": ["use_python_2"]
- }
- ]
- }
- ]
+ "collapsible_key": true,
+ "use_label_wrap": false,
+ "object_type": {
+ "type": "dict",
+ "collapsible": true,
+ "children": [
+ {
+ "type": "schema_template",
+ "name": "template_host_variant_items",
+ "skip_paths": ["use_python_2"]
+ }
+ ]
+ }
}
]
}
diff --git a/openpype/settings/entities/schemas/system_schema/schema_applications.json b/openpype/settings/entities/schemas/system_schema/schema_applications.json
index efdd021ede..1767250aae 100644
--- a/openpype/settings/entities/schemas/system_schema/schema_applications.json
+++ b/openpype/settings/entities/schemas/system_schema/schema_applications.json
@@ -9,6 +9,10 @@
"type": "schema",
"name": "schema_maya"
},
+ {
+ "type": "schema",
+ "name": "schema_flame"
+ },
{
"type": "schema_template",
"name": "template_nuke",
diff --git a/openpype/tools/launcher/window.py b/openpype/tools/launcher/window.py
index 9ed7fb0fe6..454445824e 100644
--- a/openpype/tools/launcher/window.py
+++ b/openpype/tools/launcher/window.py
@@ -8,8 +8,7 @@ from avalon.api import AvalonMongoDB
from openpype import style
from openpype.api import resources
-from avalon.tools import lib as tools_lib
-from avalon.tools.widgets import AssetWidget
+from openpype.tools.utils.widgets import AssetWidget
from avalon.vendor import qtawesome
from .models import ProjectModel
from .lib import get_action_label, ProjectHandler
diff --git a/openpype/tools/loader/app.py b/openpype/tools/loader/app.py
index dac5e11d4c..04da08326f 100644
--- a/openpype/tools/loader/app.py
+++ b/openpype/tools/loader/app.py
@@ -164,8 +164,9 @@ class LoaderWindow(QtWidgets.QDialog):
subsets_widget.load_started.connect(self._on_load_start)
subsets_widget.load_ended.connect(self._on_load_end)
- repres_widget.load_started.connect(self._on_load_start)
- repres_widget.load_ended.connect(self._on_load_end)
+ if repres_widget:
+ repres_widget.load_started.connect(self._on_load_start)
+ repres_widget.load_ended.connect(self._on_load_end)
self._sync_server_enabled = sync_server_enabled
diff --git a/openpype/tools/mayalookassigner/app.py b/openpype/tools/mayalookassigner/app.py
index 1fa3a3868a..0f5a930902 100644
--- a/openpype/tools/mayalookassigner/app.py
+++ b/openpype/tools/mayalookassigner/app.py
@@ -2,20 +2,27 @@ import sys
import time
import logging
+from Qt import QtWidgets, QtCore
+
from openpype.hosts.maya.api.lib import assign_look_by_version
from avalon import style, io
from avalon.tools import lib
-from avalon.vendor.Qt import QtWidgets, QtCore
from maya import cmds
# old api for MFileIO
import maya.OpenMaya
import maya.api.OpenMaya as om
-from . import widgets
-from . import commands
-from . vray_proxies import vrayproxy_assign_look
+from .widgets import (
+ AssetOutliner,
+ LookOutliner
+)
+from .commands import (
+ get_workfile,
+ remove_unused_looks
+)
+from .vray_proxies import vrayproxy_assign_look
module = sys.modules[__name__]
@@ -32,7 +39,7 @@ class App(QtWidgets.QWidget):
# Store callback references
self._callbacks = []
- filename = commands.get_workfile()
+ filename = get_workfile()
self.setObjectName("lookManager")
self.setWindowTitle("Look Manager 1.3.0 - [{}]".format(filename))
@@ -57,13 +64,13 @@ class App(QtWidgets.QWidget):
"""Build the UI"""
# Assets (left)
- asset_outliner = widgets.AssetOutliner()
+ asset_outliner = AssetOutliner()
# Looks (right)
looks_widget = QtWidgets.QWidget()
looks_layout = QtWidgets.QVBoxLayout(looks_widget)
- look_outliner = widgets.LookOutliner() # Database look overview
+ look_outliner = LookOutliner() # Database look overview
assign_selected = QtWidgets.QCheckBox("Assign to selected only")
assign_selected.setToolTip("Whether to assign only to selected nodes "
@@ -124,7 +131,7 @@ class App(QtWidgets.QWidget):
lambda: self.echo("Loaded assets.."))
self.look_outliner.menu_apply_action.connect(self.on_process_selected)
- self.remove_unused.clicked.connect(commands.remove_unused_looks)
+ self.remove_unused.clicked.connect(remove_unused_looks)
# Maya renderlayer switch callback
callback = om.MEventMessage.addEventCallback(
diff --git a/openpype/tools/mayalookassigner/commands.py b/openpype/tools/mayalookassigner/commands.py
index a53251cdef..f7d26f9adb 100644
--- a/openpype/tools/mayalookassigner/commands.py
+++ b/openpype/tools/mayalookassigner/commands.py
@@ -9,7 +9,7 @@ from openpype.hosts.maya.api import lib
from avalon import io, api
-import vray_proxies
+from .vray_proxies import get_alembic_ids_cache
log = logging.getLogger(__name__)
@@ -146,7 +146,7 @@ def create_items_from_nodes(nodes):
vray_proxy_nodes = cmds.ls(nodes, type="VRayProxy")
for vp in vray_proxy_nodes:
path = cmds.getAttr("{}.fileName".format(vp))
- ids = vray_proxies.get_alembic_ids_cache(path)
+ ids = get_alembic_ids_cache(path)
parent_id = {}
for k, _ in ids.items():
pid = k.split(":")[0]
diff --git a/openpype/tools/mayalookassigner/models.py b/openpype/tools/mayalookassigner/models.py
index 7c5133de82..80de6c1897 100644
--- a/openpype/tools/mayalookassigner/models.py
+++ b/openpype/tools/mayalookassigner/models.py
@@ -1,7 +1,8 @@
from collections import defaultdict
-from avalon.tools import models
-from avalon.vendor.Qt import QtCore
+from Qt import QtCore
+
+from avalon.tools import models
from avalon.vendor import qtawesome
from avalon.style import colors
diff --git a/openpype/tools/mayalookassigner/views.py b/openpype/tools/mayalookassigner/views.py
index decf04ee57..993023bb45 100644
--- a/openpype/tools/mayalookassigner/views.py
+++ b/openpype/tools/mayalookassigner/views.py
@@ -1,4 +1,4 @@
-from avalon.vendor.Qt import QtWidgets, QtCore
+from Qt import QtWidgets, QtCore
DEFAULT_COLOR = "#fb9c15"
diff --git a/openpype/tools/mayalookassigner/widgets.py b/openpype/tools/mayalookassigner/widgets.py
index 2dab266af9..625e9ef8c6 100644
--- a/openpype/tools/mayalookassigner/widgets.py
+++ b/openpype/tools/mayalookassigner/widgets.py
@@ -1,13 +1,16 @@
import logging
from collections import defaultdict
-from avalon.vendor.Qt import QtWidgets, QtCore
+from Qt import QtWidgets, QtCore
# TODO: expose this better in avalon core
from avalon.tools import lib
from avalon.tools.models import TreeModel
-from . import models
+from .models import (
+ AssetModel,
+ LookModel
+)
from . import commands
from . import views
@@ -30,7 +33,7 @@ class AssetOutliner(QtWidgets.QWidget):
title.setAlignment(QtCore.Qt.AlignCenter)
title.setStyleSheet("font-weight: bold; font-size: 12px")
- model = models.AssetModel()
+ model = AssetModel()
view = views.View()
view.setModel(model)
view.customContextMenuRequested.connect(self.right_mouse_menu)
@@ -201,7 +204,7 @@ class LookOutliner(QtWidgets.QWidget):
title.setStyleSheet("font-weight: bold; font-size: 12px")
title.setAlignment(QtCore.Qt.AlignCenter)
- model = models.LookModel()
+ model = LookModel()
# Proxy for dynamic sorting
proxy = QtCore.QSortFilterProxyModel()
@@ -257,5 +260,3 @@ class LookOutliner(QtWidgets.QWidget):
menu.addAction(apply_action)
menu.exec_(globalpos)
-
-
diff --git a/openpype/tools/project_manager/project_manager/model.py b/openpype/tools/project_manager/project_manager/model.py
index 5b6ed78b50..b7ab9e40d0 100644
--- a/openpype/tools/project_manager/project_manager/model.py
+++ b/openpype/tools/project_manager/project_manager/model.py
@@ -1456,7 +1456,11 @@ class HierarchyModel(QtCore.QAbstractItemModel):
return
raw_data = mime_data.data("application/copy_task")
- encoded_data = QtCore.QByteArray.fromRawData(raw_data)
+ if isinstance(raw_data, QtCore.QByteArray):
+ # Raw data are already QByteArrat and we don't have to load them
+ encoded_data = raw_data
+ else:
+ encoded_data = QtCore.QByteArray.fromRawData(raw_data)
stream = QtCore.QDataStream(encoded_data, QtCore.QIODevice.ReadOnly)
text = stream.readQString()
try:
diff --git a/openpype/tools/pyblish_pype/control.py b/openpype/tools/pyblish_pype/control.py
index 234135fd9a..1fa3ee657b 100644
--- a/openpype/tools/pyblish_pype/control.py
+++ b/openpype/tools/pyblish_pype/control.py
@@ -7,7 +7,6 @@ an active window manager; such as via Travis-CI.
"""
import os
import sys
-import traceback
import inspect
import logging
diff --git a/openpype/tools/tray_app/app.py b/openpype/tools/tray_app/app.py
index 03f8321464..f1363d0cab 100644
--- a/openpype/tools/tray_app/app.py
+++ b/openpype/tools/tray_app/app.py
@@ -142,18 +142,23 @@ class ConsoleTrayApp:
self.tray_reconnect = False
ConsoleTrayApp.webserver_client.close()
- def _send_text(self, new_text):
+ def _send_text_queue(self):
+ """Sends lines and purges queue"""
+ lines = tuple(self.new_text)
+ self.new_text.clear()
+
+ if lines:
+ self._send_lines(lines)
+
+ def _send_lines(self, lines):
""" Send console content. """
if not ConsoleTrayApp.webserver_client:
return
- if isinstance(new_text, str):
- new_text = collections.deque(new_text.split("\n"))
-
payload = {
"host": self.host_id,
"action": host_console_listener.MsgAction.ADD,
- "text": "\n".join(new_text)
+ "text": "\n".join(lines)
}
self._send(payload)
@@ -174,14 +179,7 @@ class ConsoleTrayApp:
if self.tray_reconnect:
self._connect() # reconnect
- if ConsoleTrayApp.webserver_client and self.new_text:
- self._send_text(self.new_text)
- self.new_text = collections.deque()
-
- if self.new_text: # no webserver_client, text keeps stashing
- start = max(len(self.new_text) - self.MAX_LINES, 0)
- self.new_text = itertools.islice(self.new_text,
- start, self.MAX_LINES)
+ self._send_text_queue()
if not self.initialized:
if self.initializing:
@@ -191,7 +189,7 @@ class ConsoleTrayApp:
elif not host_connected:
text = "{} process is not alive. Exiting".format(self.host)
print(text)
- self._send_text([text])
+ self._send_lines([text])
ConsoleTrayApp.websocket_server.stop()
sys.exit(1)
elif host_connected:
@@ -205,14 +203,15 @@ class ConsoleTrayApp:
self.initializing = True
self.launch_method(*self.subprocess_args)
- elif ConsoleTrayApp.process.poll() is not None:
- self.exit()
- elif ConsoleTrayApp.callback_queue:
+ elif ConsoleTrayApp.callback_queue and \
+ not ConsoleTrayApp.callback_queue.empty():
try:
callback = ConsoleTrayApp.callback_queue.get(block=False)
callback()
except queue.Empty:
pass
+ elif ConsoleTrayApp.process.poll() is not None:
+ self.exit()
@classmethod
def execute_in_main_thread(cls, func_to_call_from_main_thread):
@@ -232,8 +231,9 @@ class ConsoleTrayApp:
self._close()
if ConsoleTrayApp.websocket_server:
ConsoleTrayApp.websocket_server.stop()
- ConsoleTrayApp.process.kill()
- ConsoleTrayApp.process.wait()
+ if ConsoleTrayApp.process:
+ ConsoleTrayApp.process.kill()
+ ConsoleTrayApp.process.wait()
if self.timer:
self.timer.stop()
QtCore.QCoreApplication.exit()
diff --git a/openpype/tools/utils/lib.py b/openpype/tools/utils/lib.py
index d01dbbd169..aad00f886c 100644
--- a/openpype/tools/utils/lib.py
+++ b/openpype/tools/utils/lib.py
@@ -35,26 +35,6 @@ def application():
yield app
-def defer(delay, func):
- """Append artificial delay to `func`
-
- This aids in keeping the GUI responsive, but complicates logic
- when producing tests. To combat this, the environment variable ensures
- that every operation is synchonous.
-
- Arguments:
- delay (float): Delay multiplier; default 1, 0 means no delay
- func (callable): Any callable
-
- """
-
- delay *= float(os.getenv("PYBLISH_DELAY", 1))
- if delay > 0:
- return QtCore.QTimer.singleShot(delay, func)
- else:
- return func()
-
-
class SharedObjects:
jobs = {}
@@ -82,18 +62,6 @@ def schedule(func, time, channel="default"):
SharedObjects.jobs[channel] = timer
-@contextlib.contextmanager
-def dummy():
- """Dummy context manager
-
- Usage:
- >> with some_context() if False else dummy():
- .. pass
-
- """
- yield
-
-
def iter_model_rows(model, column, include_root=False):
"""Iterate over all row indices in a model"""
indices = [QtCore.QModelIndex()] # start iteration at root
@@ -111,76 +79,6 @@ def iter_model_rows(model, column, include_root=False):
yield index
-@contextlib.contextmanager
-def preserve_states(tree_view,
- column=0,
- role=None,
- preserve_expanded=True,
- preserve_selection=True,
- expanded_role=QtCore.Qt.DisplayRole,
- selection_role=QtCore.Qt.DisplayRole):
- """Preserves row selection in QTreeView by column's data role.
- This function is created to maintain the selection status of
- the model items. When refresh is triggered the items which are expanded
- will stay expanded and vise versa.
- tree_view (QWidgets.QTreeView): the tree view nested in the application
- column (int): the column to retrieve the data from
- role (int): the role which dictates what will be returned
- Returns:
- None
- """
- # When `role` is set then override both expanded and selection roles
- if role:
- expanded_role = role
- selection_role = role
-
- model = tree_view.model()
- selection_model = tree_view.selectionModel()
- flags = selection_model.Select | selection_model.Rows
-
- expanded = set()
-
- if preserve_expanded:
- for index in iter_model_rows(
- model, column=column, include_root=False
- ):
- if tree_view.isExpanded(index):
- value = index.data(expanded_role)
- expanded.add(value)
-
- selected = None
-
- if preserve_selection:
- selected_rows = selection_model.selectedRows()
- if selected_rows:
- selected = set(row.data(selection_role) for row in selected_rows)
-
- try:
- yield
- finally:
- if expanded:
- for index in iter_model_rows(
- model, column=0, include_root=False
- ):
- value = index.data(expanded_role)
- is_expanded = value in expanded
- # skip if new index was created meanwhile
- if is_expanded is None:
- continue
- tree_view.setExpanded(index, is_expanded)
-
- if selected:
- # Go through all indices, select the ones with similar data
- for index in iter_model_rows(
- model, column=column, include_root=False
- ):
- value = index.data(selection_role)
- state = value in selected
- if state:
- tree_view.scrollTo(index) # Ensure item is visible
- selection_model.select(index, flags)
-
-
@contextlib.contextmanager
def preserve_expanded_rows(tree_view, column=0, role=None):
"""Preserves expanded row in QTreeView by column's data role.
diff --git a/openpype/vendor/python/common/capture.py b/openpype/vendor/python/common/capture.py
index 83816ec92a..02d86952df 100644
--- a/openpype/vendor/python/common/capture.py
+++ b/openpype/vendor/python/common/capture.py
@@ -161,37 +161,62 @@ def capture(camera=None,
cmds.currentTime(cmds.currentTime(query=True))
padding = 10 # Extend panel to accommodate for OS window manager
+
with _independent_panel(width=width + padding,
height=height + padding,
off_screen=off_screen) as panel:
cmds.setFocus(panel)
- with contextlib.nested(
- _disabled_inview_messages(),
- _maintain_camera(panel, camera),
- _applied_viewport_options(viewport_options, panel),
- _applied_camera_options(camera_options, panel),
- _applied_display_options(display_options),
- _applied_viewport2_options(viewport2_options),
- _isolated_nodes(isolate, panel),
- _maintained_time()):
+ all_playblast_kwargs = {
+ "compression": compression,
+ "format": format,
+ "percent": 100,
+ "quality": quality,
+ "viewer": viewer,
+ "startTime": start_frame,
+ "endTime": end_frame,
+ "offScreen": off_screen,
+ "showOrnaments": show_ornaments,
+ "forceOverwrite": overwrite,
+ "filename": filename,
+ "widthHeight": [width, height],
+ "rawFrameNumbers": raw_frame_numbers,
+ "framePadding": frame_padding
+ }
+ all_playblast_kwargs.update(playblast_kwargs)
- output = cmds.playblast(
- compression=compression,
- format=format,
- percent=100,
- quality=quality,
- viewer=viewer,
- startTime=start_frame,
- endTime=end_frame,
- offScreen=off_screen,
- showOrnaments=show_ornaments,
- forceOverwrite=overwrite,
- filename=filename,
- widthHeight=[width, height],
- rawFrameNumbers=raw_frame_numbers,
- framePadding=frame_padding,
- **playblast_kwargs)
+ if getattr(contextlib, "nested", None):
+ with contextlib.nested(
+ _disabled_inview_messages(),
+ _maintain_camera(panel, camera),
+ _applied_viewport_options(viewport_options, panel),
+ _applied_camera_options(camera_options, panel),
+ _applied_display_options(display_options),
+ _applied_viewport2_options(viewport2_options),
+ _isolated_nodes(isolate, panel),
+ _maintained_time()
+ ):
+ output = cmds.playblast(**all_playblast_kwargs)
+ else:
+ with contextlib.ExitStack() as stack:
+ stack.enter_context(_disabled_inview_messages())
+ stack.enter_context(_maintain_camera(panel, camera))
+ stack.enter_context(
+ _applied_viewport_options(viewport_options, panel)
+ )
+ stack.enter_context(
+ _applied_camera_options(camera_options, panel)
+ )
+ stack.enter_context(
+ _applied_display_options(display_options)
+ )
+ stack.enter_context(
+ _applied_viewport2_options(viewport2_options)
+ )
+ stack.enter_context(_isolated_nodes(isolate, panel))
+ stack.enter_context(_maintained_time())
+
+ output = cmds.playblast(**all_playblast_kwargs)
return output
@@ -364,7 +389,8 @@ def apply_view(panel, **options):
# Display options
display_options = options.get("display_options", {})
- for key, value in display_options.iteritems():
+ _iteritems = getattr(display_options, "iteritems", display_options.items)
+ for key, value in _iteritems():
if key in _DisplayOptionsRGB:
cmds.displayRGBColor(key, *value)
else:
@@ -372,16 +398,21 @@ def apply_view(panel, **options):
# Camera options
camera_options = options.get("camera_options", {})
- for key, value in camera_options.iteritems():
+ _iteritems = getattr(camera_options, "iteritems", camera_options.items)
+ for key, value in _iteritems:
cmds.setAttr("{0}.{1}".format(camera, key), value)
# Viewport options
viewport_options = options.get("viewport_options", {})
- for key, value in viewport_options.iteritems():
+ _iteritems = getattr(viewport_options, "iteritems", viewport_options.items)
+ for key, value in _iteritems():
cmds.modelEditor(panel, edit=True, **{key: value})
viewport2_options = options.get("viewport2_options", {})
- for key, value in viewport2_options.iteritems():
+ _iteritems = getattr(
+ viewport2_options, "iteritems", viewport2_options.items
+ )
+ for key, value in _iteritems():
attr = "hardwareRenderingGlobals.{0}".format(key)
cmds.setAttr(attr, value)
@@ -629,14 +660,16 @@ def _applied_camera_options(options, panel):
"for capture: %s" % opt)
options.pop(opt)
- for opt, value in options.iteritems():
+ _iteritems = getattr(options, "iteritems", options.items)
+ for opt, value in _iteritems():
cmds.setAttr(camera + "." + opt, value)
try:
yield
finally:
if old_options:
- for opt, value in old_options.iteritems():
+ _iteritems = getattr(old_options, "iteritems", old_options.items)
+ for opt, value in _iteritems():
cmds.setAttr(camera + "." + opt, value)
@@ -722,14 +755,16 @@ def _applied_viewport2_options(options):
options.pop(opt)
# Apply settings
- for opt, value in options.iteritems():
+ _iteritems = getattr(options, "iteritems", options.items)
+ for opt, value in _iteritems():
cmds.setAttr("hardwareRenderingGlobals." + opt, value)
try:
yield
finally:
# Restore previous settings
- for opt, value in original.iteritems():
+ _iteritems = getattr(original, "iteritems", original.items)
+ for opt, value in _iteritems():
cmds.setAttr("hardwareRenderingGlobals." + opt, value)
@@ -769,7 +804,8 @@ def _maintain_camera(panel, camera):
try:
yield
finally:
- for camera, renderable in state.iteritems():
+ _iteritems = getattr(state, "iteritems", state.items)
+ for camera, renderable in _iteritems():
cmds.setAttr(camera + ".rnd", renderable)
diff --git a/openpype/version.py b/openpype/version.py
index d88d79b995..5906507813 100644
--- a/openpype/version.py
+++ b/openpype/version.py
@@ -1,3 +1,3 @@
# -*- coding: utf-8 -*-
"""Package declaring Pype version."""
-__version__ = "3.5.0"
+__version__ = "3.6.0-nightly.2"
diff --git a/pyproject.toml b/pyproject.toml
index dade0a2f57..46c6723098 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -1,6 +1,6 @@
[tool.poetry]
name = "OpenPype"
-version = "3.0.0"
+version = "3.6.0-nightly.2" # OpenPype
description = "Open VFX and Animation pipeline with support."
authors = ["OpenPype Team "]
license = "MIT License"
diff --git a/tests/integration/hosts/photoshop/test_publish_in_photoshop.py b/tests/integration/hosts/photoshop/test_publish_in_photoshop.py
new file mode 100644
index 0000000000..396468a966
--- /dev/null
+++ b/tests/integration/hosts/photoshop/test_publish_in_photoshop.py
@@ -0,0 +1,94 @@
+import pytest
+import os
+import shutil
+
+from tests.lib.testing_classes import PublishTest
+
+
+class TestPublishInPhotoshop(PublishTest):
+ """Basic test case for publishing in Photoshop
+
+ Uses generic TestCase to prepare fixtures for test data, testing DBs,
+ env vars.
+
+ Opens Maya, run publish on prepared workile.
+
+ Then checks content of DB (if subset, version, representations were
+ created.
+ Checks tmp folder if all expected files were published.
+
+ """
+ PERSIST = True
+
+ TEST_FILES = [
+ ("1Bciy2pCwMKl1UIpxuPnlX_LHMo_Xkq0K", "test_photoshop_publish.zip", "")
+ ]
+
+ APP = "photoshop"
+ APP_VARIANT = "2020"
+
+ APP_NAME = "{}/{}".format(APP, APP_VARIANT)
+
+ TIMEOUT = 120 # publish timeout
+
+ @pytest.fixture(scope="module")
+ def last_workfile_path(self, download_test_data):
+ """Get last_workfile_path from source data.
+
+ Maya expects workfile in proper folder, so copy is done first.
+ """
+ src_path = os.path.join(download_test_data,
+ "input",
+ "workfile",
+ "test_project_test_asset_TestTask_v001.psd")
+ dest_folder = os.path.join(download_test_data,
+ self.PROJECT,
+ self.ASSET,
+ "work",
+ self.TASK)
+ os.makedirs(dest_folder)
+ dest_path = os.path.join(dest_folder,
+ "test_project_test_asset_TestTask_v001.psd")
+ shutil.copy(src_path, dest_path)
+
+ yield dest_path
+
+ @pytest.fixture(scope="module")
+ def startup_scripts(self, monkeypatch_session, download_test_data):
+ """Points Maya to userSetup file from input data"""
+ os.environ["IS_HEADLESS"] = "true"
+
+ def test_db_asserts(self, dbcon, publish_finished):
+ """Host and input data dependent expected results in DB."""
+ print("test_db_asserts")
+ assert 5 == dbcon.count_documents({"type": "version"}), \
+ "Not expected no of versions"
+
+ assert 0 == dbcon.count_documents({"type": "version",
+ "name": {"$ne": 1}}), \
+ "Only versions with 1 expected"
+
+ assert 1 == dbcon.count_documents({"type": "subset",
+ "name": "modelMain"}), \
+ "modelMain subset must be present"
+
+ assert 1 == dbcon.count_documents({"type": "subset",
+ "name": "workfileTest_task"}), \
+ "workfileTest_task subset must be present"
+
+ assert 11 == dbcon.count_documents({"type": "representation"}), \
+ "Not expected no of representations"
+
+ assert 2 == dbcon.count_documents({"type": "representation",
+ "context.subset": "modelMain",
+ "context.ext": "abc"}), \
+ "Not expected no of representations with ext 'abc'"
+
+ assert 2 == dbcon.count_documents({"type": "representation",
+ "context.subset": "modelMain",
+ "context.ext": "ma"}), \
+ "Not expected no of representations with ext 'abc'"
+
+
+if __name__ == "__main__":
+ test_case = TestPublishInPhotoshop()
diff --git a/tests/lib/testing_classes.py b/tests/lib/testing_classes.py
index 1832efb7ed..59d4abb3aa 100644
--- a/tests/lib/testing_classes.py
+++ b/tests/lib/testing_classes.py
@@ -228,6 +228,7 @@ class PublishTest(ModuleUnitTest):
while launched_app.poll() is None:
time.sleep(0.5)
if time.time() - time_start > self.TIMEOUT:
+ launched_app.terminate()
raise ValueError("Timeout reached")
# some clean exit test possible?
diff --git a/website/docs/artist_hosts_houdini.md b/website/docs/artist_hosts_houdini.md
index d2aadf05cb..bd422b046e 100644
--- a/website/docs/artist_hosts_houdini.md
+++ b/website/docs/artist_hosts_houdini.md
@@ -76,3 +76,28 @@ I've selected `vdb1` and went **OpenPype -> Create** and selected **VDB Cache**.
geometry ROP in `/out` and sets its paths to output vdb files. During the publishing process
whole dops are cooked.
+## Publishing Houdini Digital Assets (HDA)
+
+You can publish most of the nodes in Houdini as hda for easy interchange of data between Houdini instances or even
+other DCCs with Houdini Engine.
+
+## Creating HDA
+
+Simply select nodes you want to include in hda and go **OpenPype -> Create** and select **Houdini digital asset (hda)**.
+You can even use already existing hda as a selected node, and it will be published (see below for limitation).
+
+:::caution HDA Workflow limitations
+As long as the hda is of same type - it is created from different nodes but using the same (subset) name, everything
+is ok. But once you've published version of hda subset, you cannot change its type. For example, you create hda **Foo**
+from *Cube* and *Sphere* - it will create hda subset named `hdaFoo` with the same type. You publish it as version 1.
+Then you create version 2 with added *Torus*. Then you create version 3 from the scratch from completely different nodes,
+but still using resulting subset name `hdaFoo`. Everything still works as expected. But then you use already
+existing hda as a base, for example from different artist. Its type cannot be changed from what it was and so even if
+it is named `hdaFoo` it has different type. It could be published, but you would never load it and retain ability to
+switch versions between different hda types.
+:::
+
+## Loading HDA
+
+When you load hda, it will install its type in your hip file and add published version as its definition file. When
+you switch version via Scene Manager, it will add its definition and set it as preferred.
\ No newline at end of file