diff --git a/.gitmodules b/.gitmodules
index 67b820a247..9920ceaad6 100644
--- a/.gitmodules
+++ b/.gitmodules
@@ -1,6 +1,3 @@
[submodule "repos/avalon-core"]
path = repos/avalon-core
- url = https://github.com/pypeclub/avalon-core.git
-[submodule "repos/avalon-unreal-integration"]
- path = repos/avalon-unreal-integration
- url = https://github.com/pypeclub/avalon-unreal-integration.git
\ No newline at end of file
+ url = https://github.com/pypeclub/avalon-core.git
\ No newline at end of file
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 3babdceafb..348f7dc1b8 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,121 +1,76 @@
# Changelog
-## [3.9.0-nightly.3](https://github.com/pypeclub/OpenPype/tree/HEAD)
+## [3.9.0-nightly.5](https://github.com/pypeclub/OpenPype/tree/HEAD)
[Full Changelog](https://github.com/pypeclub/OpenPype/compare/3.8.2...HEAD)
**Deprecated:**
-- Loader: Remove default family states for hosts from code [\#2706](https://github.com/pypeclub/OpenPype/pull/2706)
+- Houdini: Remove unused code [\#2779](https://github.com/pypeclub/OpenPype/pull/2779)
### 📖 Documentation
+- Documentation: fixed broken links [\#2799](https://github.com/pypeclub/OpenPype/pull/2799)
+- Documentation: broken link fix [\#2785](https://github.com/pypeclub/OpenPype/pull/2785)
+- Documentation: link fixes [\#2772](https://github.com/pypeclub/OpenPype/pull/2772)
- Update docusaurus to latest version [\#2760](https://github.com/pypeclub/OpenPype/pull/2760)
-- documentation: add example to `repack-version` command [\#2669](https://github.com/pypeclub/OpenPype/pull/2669)
-
-**🆕 New features**
-
-- Flame: loading clips to reels [\#2622](https://github.com/pypeclub/OpenPype/pull/2622)
**🚀 Enhancements**
+- General: Color dialog UI fixes [\#2817](https://github.com/pypeclub/OpenPype/pull/2817)
+- General: Set context environments for non host applications [\#2803](https://github.com/pypeclub/OpenPype/pull/2803)
+- Tray publisher: New Tray Publisher host \(beta\) [\#2778](https://github.com/pypeclub/OpenPype/pull/2778)
+- Houdini: Implement Reset Frame Range [\#2770](https://github.com/pypeclub/OpenPype/pull/2770)
- Pyblish Pype: Remove redundant new line in installed fonts printing [\#2758](https://github.com/pypeclub/OpenPype/pull/2758)
+- Flame: use Shot Name on segment for asset name [\#2751](https://github.com/pypeclub/OpenPype/pull/2751)
- Flame: adding validator source clip [\#2746](https://github.com/pypeclub/OpenPype/pull/2746)
-- Work Files: Preserve subversion comment of current filename by default [\#2734](https://github.com/pypeclub/OpenPype/pull/2734)
- Ftrack: Disable ftrack module by default [\#2732](https://github.com/pypeclub/OpenPype/pull/2732)
-- Project Manager: Disable add task, add asset and save button when not in a project [\#2727](https://github.com/pypeclub/OpenPype/pull/2727)
-- dropbox handle big file [\#2718](https://github.com/pypeclub/OpenPype/pull/2718)
-- Fusion Move PR: Minor tweaks to Fusion integration [\#2716](https://github.com/pypeclub/OpenPype/pull/2716)
-- Nuke: prerender with review knob [\#2691](https://github.com/pypeclub/OpenPype/pull/2691)
-- Maya configurable unit validator [\#2680](https://github.com/pypeclub/OpenPype/pull/2680)
-- General: Add settings for CleanUpFarm and disable the plugin by default [\#2679](https://github.com/pypeclub/OpenPype/pull/2679)
-- Project Manager: Only allow scroll wheel edits when spinbox is active [\#2678](https://github.com/pypeclub/OpenPype/pull/2678)
-- Ftrack: Sync description to assets [\#2670](https://github.com/pypeclub/OpenPype/pull/2670)
-- General: FFmpeg conversion also check attribute string length [\#2635](https://github.com/pypeclub/OpenPype/pull/2635)
-- Global: adding studio name/code to anatomy template formatting data [\#2630](https://github.com/pypeclub/OpenPype/pull/2630)
-- Houdini: Load Arnold .ass procedurals into Houdini [\#2606](https://github.com/pypeclub/OpenPype/pull/2606)
-- Deadline: Simplify GlobalJobPreLoad logic [\#2605](https://github.com/pypeclub/OpenPype/pull/2605)
-- Houdini: Implement Arnold .ass standin extraction from Houdini \(also support .ass.gz\) [\#2603](https://github.com/pypeclub/OpenPype/pull/2603)
+- RoyalRender: Minor enhancements [\#2700](https://github.com/pypeclub/OpenPype/pull/2700)
**🐛 Bug fixes**
+- Settings: Missing document with OP versions may break start of OpenPype [\#2825](https://github.com/pypeclub/OpenPype/pull/2825)
+- Settings UI: Fix "Apply from" action [\#2820](https://github.com/pypeclub/OpenPype/pull/2820)
+- Settings UI: Search case sensitivity [\#2810](https://github.com/pypeclub/OpenPype/pull/2810)
+- Flame Babypublisher optimalization [\#2806](https://github.com/pypeclub/OpenPype/pull/2806)
+- resolve: fixing fusion module loading [\#2802](https://github.com/pypeclub/OpenPype/pull/2802)
+- Flame: Fix version string in default settings [\#2783](https://github.com/pypeclub/OpenPype/pull/2783)
+- After Effects: Fix typo in name `afftereffects` -\> `aftereffects` [\#2768](https://github.com/pypeclub/OpenPype/pull/2768)
+- Avoid renaming udim indexes [\#2765](https://github.com/pypeclub/OpenPype/pull/2765)
+- Maya: Fix `unique\_namespace` when in an namespace that is empty [\#2759](https://github.com/pypeclub/OpenPype/pull/2759)
- Loader UI: Fix right click in representation widget [\#2757](https://github.com/pypeclub/OpenPype/pull/2757)
- Aftereffects 2022 and Deadline [\#2748](https://github.com/pypeclub/OpenPype/pull/2748)
- Flame: bunch of bugs [\#2745](https://github.com/pypeclub/OpenPype/pull/2745)
- Maya: Save current scene on workfile publish [\#2744](https://github.com/pypeclub/OpenPype/pull/2744)
- Version Up: Preserve parts of filename after version number \(like subversion\) on version\_up [\#2741](https://github.com/pypeclub/OpenPype/pull/2741)
-- Loader UI: Multiple asset selection and underline colors fixed [\#2731](https://github.com/pypeclub/OpenPype/pull/2731)
-- General: Fix loading of unused chars in xml format [\#2729](https://github.com/pypeclub/OpenPype/pull/2729)
-- TVPaint: Set objectName with members [\#2725](https://github.com/pypeclub/OpenPype/pull/2725)
-- General: Don't use 'objectName' from loaded references [\#2715](https://github.com/pypeclub/OpenPype/pull/2715)
-- Settings: Studio Project anatomy is queried using right keys [\#2711](https://github.com/pypeclub/OpenPype/pull/2711)
-- Local Settings: Additional applications don't break UI [\#2710](https://github.com/pypeclub/OpenPype/pull/2710)
-- Houdini: Fix refactor of Houdini host move for CreateArnoldAss [\#2704](https://github.com/pypeclub/OpenPype/pull/2704)
-- LookAssigner: Fix imports after moving code to OpenPype repository [\#2701](https://github.com/pypeclub/OpenPype/pull/2701)
-- Multiple hosts: unify menu style across hosts [\#2693](https://github.com/pypeclub/OpenPype/pull/2693)
-- Maya Redshift fixes [\#2692](https://github.com/pypeclub/OpenPype/pull/2692)
-- Maya: fix fps validation popup [\#2685](https://github.com/pypeclub/OpenPype/pull/2685)
-- Houdini Explicitly collect correct frame name even in case of single frame render when `frameStart` is provided [\#2676](https://github.com/pypeclub/OpenPype/pull/2676)
-- hiero: fix effect collector name and order [\#2673](https://github.com/pypeclub/OpenPype/pull/2673)
-- Maya: Fix menu callbacks [\#2671](https://github.com/pypeclub/OpenPype/pull/2671)
-- Launcher: Fix access to 'data' attribute on actions [\#2659](https://github.com/pypeclub/OpenPype/pull/2659)
-- Houdini: fix usd family in loader and integrators [\#2631](https://github.com/pypeclub/OpenPype/pull/2631)
+- Maya: Remove some unused code [\#2709](https://github.com/pypeclub/OpenPype/pull/2709)
**Merged pull requests:**
+- Move Unreal Implementation to OpenPype [\#2823](https://github.com/pypeclub/OpenPype/pull/2823)
+- Ftrack: Job killer with missing user [\#2819](https://github.com/pypeclub/OpenPype/pull/2819)
+- Ftrack: Unset task ids from asset versions before tasks are removed [\#2800](https://github.com/pypeclub/OpenPype/pull/2800)
+- Slack: fail gracefully if slack exception [\#2798](https://github.com/pypeclub/OpenPype/pull/2798)
+- Ftrack: Moved module one hierarchy level higher [\#2792](https://github.com/pypeclub/OpenPype/pull/2792)
+- SyncServer: Moved module one hierarchy level higher [\#2791](https://github.com/pypeclub/OpenPype/pull/2791)
+- Royal render: Move module one hierarchy level higher [\#2790](https://github.com/pypeclub/OpenPype/pull/2790)
+- Deadline: Move module one hierarchy level higher [\#2789](https://github.com/pypeclub/OpenPype/pull/2789)
+- Houdini: Remove duplicate ValidateOutputNode plug-in [\#2780](https://github.com/pypeclub/OpenPype/pull/2780)
+- Slack: Added regex for filtering on subset names [\#2775](https://github.com/pypeclub/OpenPype/pull/2775)
+- Houdini: Fix open last workfile [\#2767](https://github.com/pypeclub/OpenPype/pull/2767)
+- General: Extract template formatting from anatomy [\#2766](https://github.com/pypeclub/OpenPype/pull/2766)
- Harmony: Rendering in Deadline didn't work in other machines than submitter [\#2754](https://github.com/pypeclub/OpenPype/pull/2754)
+- Houdini: Move Houdini Save Current File to beginning of ExtractorOrder [\#2747](https://github.com/pypeclub/OpenPype/pull/2747)
- Maya: set Deadline job/batch name to original source workfile name instead of published workfile [\#2733](https://github.com/pypeclub/OpenPype/pull/2733)
-- Fusion: Moved implementation into OpenPype [\#2713](https://github.com/pypeclub/OpenPype/pull/2713)
-- TVPaint: Plugin build without dependencies [\#2705](https://github.com/pypeclub/OpenPype/pull/2705)
-- Webpublisher: Photoshop create a beauty png [\#2689](https://github.com/pypeclub/OpenPype/pull/2689)
-- Ftrack: Hierarchical attributes are queried properly [\#2682](https://github.com/pypeclub/OpenPype/pull/2682)
-- Maya: Add Validate Frame Range settings [\#2661](https://github.com/pypeclub/OpenPype/pull/2661)
-- Harmony: move to Openpype [\#2657](https://github.com/pypeclub/OpenPype/pull/2657)
-- General: Show applications without integration in project [\#2656](https://github.com/pypeclub/OpenPype/pull/2656)
-- Maya: cleanup duplicate rendersetup code [\#2642](https://github.com/pypeclub/OpenPype/pull/2642)
## [3.8.2](https://github.com/pypeclub/OpenPype/tree/3.8.2) (2022-02-07)
[Full Changelog](https://github.com/pypeclub/OpenPype/compare/CI/3.8.2-nightly.3...3.8.2)
-### 📖 Documentation
-
-- Cosmetics: Fix common typos in openpype/website [\#2617](https://github.com/pypeclub/OpenPype/pull/2617)
-
-**🚀 Enhancements**
-
-- General: Project backup tools [\#2629](https://github.com/pypeclub/OpenPype/pull/2629)
-- nuke: adding clear button to write nodes [\#2627](https://github.com/pypeclub/OpenPype/pull/2627)
-- Ftrack: Family to Asset type mapping is in settings [\#2602](https://github.com/pypeclub/OpenPype/pull/2602)
-
-**🐛 Bug fixes**
-
-- Fix pulling of cx\_freeze 6.10 [\#2628](https://github.com/pypeclub/OpenPype/pull/2628)
-
-**Merged pull requests:**
-
-- Docker: enhance dockerfiles with metadata, fix pyenv initialization [\#2647](https://github.com/pypeclub/OpenPype/pull/2647)
-- WebPublisher: fix instance duplicates [\#2641](https://github.com/pypeclub/OpenPype/pull/2641)
-- Fix - safer pulling of task name for webpublishing from PS [\#2613](https://github.com/pypeclub/OpenPype/pull/2613)
-
## [3.8.1](https://github.com/pypeclub/OpenPype/tree/3.8.1) (2022-02-01)
[Full Changelog](https://github.com/pypeclub/OpenPype/compare/CI/3.8.1-nightly.3...3.8.1)
-**🚀 Enhancements**
-
-- Webpublisher: Thumbnail extractor [\#2600](https://github.com/pypeclub/OpenPype/pull/2600)
-
-**🐛 Bug fixes**
-
-- Release/3.8.0 [\#2619](https://github.com/pypeclub/OpenPype/pull/2619)
-- hotfix: OIIO tool path - add extension on windows [\#2618](https://github.com/pypeclub/OpenPype/pull/2618)
-- Settings: Enum does not store empty string if has single item to select [\#2615](https://github.com/pypeclub/OpenPype/pull/2615)
-
-**Merged pull requests:**
-
-- Bump pillow from 8.4.0 to 9.0.0 [\#2595](https://github.com/pypeclub/OpenPype/pull/2595)
-
## [3.8.0](https://github.com/pypeclub/OpenPype/tree/3.8.0) (2022-01-24)
[Full Changelog](https://github.com/pypeclub/OpenPype/compare/CI/3.8.0-nightly.7...3.8.0)
diff --git a/openpype/cli.py b/openpype/cli.py
index 0597c387d0..155e07dea3 100644
--- a/openpype/cli.py
+++ b/openpype/cli.py
@@ -42,6 +42,12 @@ def standalonepublisher():
PypeCommands().launch_standalone_publisher()
+@main.command()
+def traypublisher():
+ """Show new OpenPype Standalone publisher UI."""
+ PypeCommands().launch_traypublisher()
+
+
@main.command()
@click.option("-d", "--debug",
is_flag=True, help=("Run pype tray in debug mode"))
@@ -371,10 +377,15 @@ def run(script):
"--app_variant",
help="Provide specific app variant for test, empty for latest",
default=None)
-def runtests(folder, mark, pyargs, test_data_folder, persist, app_variant):
+@click.option("-t",
+ "--timeout",
+ help="Provide specific timeout value for test case",
+ default=None)
+def runtests(folder, mark, pyargs, test_data_folder, persist, app_variant,
+ timeout):
"""Run all automatic tests after proper initialization via start.py"""
PypeCommands().run_tests(folder, mark, pyargs, test_data_folder,
- persist, app_variant)
+ persist, app_variant, timeout)
@main.command()
diff --git a/openpype/hooks/pre_global_host_data.py b/openpype/hooks/pre_global_host_data.py
index bae967e25f..4c85a511ed 100644
--- a/openpype/hooks/pre_global_host_data.py
+++ b/openpype/hooks/pre_global_host_data.py
@@ -2,7 +2,7 @@ from openpype.api import Anatomy
from openpype.lib import (
PreLaunchHook,
EnvironmentPrepData,
- prepare_host_environments,
+ prepare_app_environments,
prepare_context_environments
)
@@ -14,14 +14,6 @@ class GlobalHostDataHook(PreLaunchHook):
def execute(self):
"""Prepare global objects to `data` that will be used for sure."""
- if not self.application.is_host:
- self.log.info(
- "Skipped hook {}. Application is not marked as host.".format(
- self.__class__.__name__
- )
- )
- return
-
self.prepare_global_data()
if not self.data.get("asset_doc"):
@@ -49,7 +41,7 @@ class GlobalHostDataHook(PreLaunchHook):
"log": self.log
})
- prepare_host_environments(temp_data, self.launch_context.env_group)
+ prepare_app_environments(temp_data, self.launch_context.env_group)
prepare_context_environments(temp_data)
temp_data.pop("log")
diff --git a/openpype/hosts/blender/api/pipeline.py b/openpype/hosts/blender/api/pipeline.py
index 0e5104fea9..6da0ba3dcb 100644
--- a/openpype/hosts/blender/api/pipeline.py
+++ b/openpype/hosts/blender/api/pipeline.py
@@ -202,13 +202,10 @@ def reload_pipeline(*args):
avalon.api.uninstall()
for module in (
- "avalon.io",
- "avalon.lib",
- "avalon.pipeline",
- "avalon.tools.creator.app",
- "avalon.tools.manager.app",
- "avalon.api",
- "avalon.tools",
+ "avalon.io",
+ "avalon.lib",
+ "avalon.pipeline",
+ "avalon.api",
):
module = importlib.import_module(module)
importlib.reload(module)
diff --git a/openpype/hosts/flame/startup/openpype_flame_to_ftrack/export_preset/openpype_seg_thumbnails_jpg.xml b/openpype/hosts/flame/startup/openpype_babypublisher/export_preset/openpype_seg_thumbnails_jpg.xml
similarity index 97%
rename from openpype/hosts/flame/startup/openpype_flame_to_ftrack/export_preset/openpype_seg_thumbnails_jpg.xml
rename to openpype/hosts/flame/startup/openpype_babypublisher/export_preset/openpype_seg_thumbnails_jpg.xml
index fa43ceece7..44a7bd9770 100644
--- a/openpype/hosts/flame/startup/openpype_flame_to_ftrack/export_preset/openpype_seg_thumbnails_jpg.xml
+++ b/openpype/hosts/flame/startup/openpype_babypublisher/export_preset/openpype_seg_thumbnails_jpg.xml
@@ -29,7 +29,7 @@
Jpeg
923688
- <segment name>
+ <shot name>
100
2
4
diff --git a/openpype/hosts/flame/startup/openpype_flame_to_ftrack/export_preset/openpype_seg_video_h264.xml b/openpype/hosts/flame/startup/openpype_babypublisher/export_preset/openpype_seg_video_h264.xml
similarity index 95%
rename from openpype/hosts/flame/startup/openpype_flame_to_ftrack/export_preset/openpype_seg_video_h264.xml
rename to openpype/hosts/flame/startup/openpype_babypublisher/export_preset/openpype_seg_video_h264.xml
index 3ca185b8b4..1d2c5a28bb 100644
--- a/openpype/hosts/flame/startup/openpype_flame_to_ftrack/export_preset/openpype_seg_video_h264.xml
+++ b/openpype/hosts/flame/startup/openpype_babypublisher/export_preset/openpype_seg_video_h264.xml
@@ -27,7 +27,7 @@
QuickTime
- <segment name>
+ <shot name>
0
PCS_709
None
@@ -43,7 +43,7 @@
2021
/profiles/.33622016/HDTV_720p_8Mbits.cdxprof
- <segment name>_<video codec>
+ <shot name>_<video codec>
50
2
4
diff --git a/openpype/hosts/flame/startup/openpype_flame_to_ftrack/modules/__init__.py b/openpype/hosts/flame/startup/openpype_babypublisher/modules/__init__.py
similarity index 100%
rename from openpype/hosts/flame/startup/openpype_flame_to_ftrack/modules/__init__.py
rename to openpype/hosts/flame/startup/openpype_babypublisher/modules/__init__.py
diff --git a/openpype/hosts/flame/startup/openpype_flame_to_ftrack/modules/app_utils.py b/openpype/hosts/flame/startup/openpype_babypublisher/modules/app_utils.py
similarity index 98%
rename from openpype/hosts/flame/startup/openpype_flame_to_ftrack/modules/app_utils.py
rename to openpype/hosts/flame/startup/openpype_babypublisher/modules/app_utils.py
index b255d8d3f5..e639c3f482 100644
--- a/openpype/hosts/flame/startup/openpype_flame_to_ftrack/modules/app_utils.py
+++ b/openpype/hosts/flame/startup/openpype_babypublisher/modules/app_utils.py
@@ -8,7 +8,7 @@ PLUGIN_DIR = os.path.dirname(os.path.dirname(__file__))
EXPORT_PRESETS_DIR = os.path.join(PLUGIN_DIR, "export_preset")
CONFIG_DIR = os.path.join(os.path.expanduser(
- "~/.openpype"), "openpype_flame_to_ftrack")
+ "~/.openpype"), "openpype_babypublisher")
@contextmanager
diff --git a/openpype/hosts/flame/startup/openpype_flame_to_ftrack/modules/ftrack_lib.py b/openpype/hosts/flame/startup/openpype_babypublisher/modules/ftrack_lib.py
similarity index 96%
rename from openpype/hosts/flame/startup/openpype_flame_to_ftrack/modules/ftrack_lib.py
rename to openpype/hosts/flame/startup/openpype_babypublisher/modules/ftrack_lib.py
index c2168016c6..0e84a5ef52 100644
--- a/openpype/hosts/flame/startup/openpype_flame_to_ftrack/modules/ftrack_lib.py
+++ b/openpype/hosts/flame/startup/openpype_babypublisher/modules/ftrack_lib.py
@@ -360,6 +360,8 @@ class FtrackComponentCreator:
class FtrackEntityOperator:
+ existing_tasks = []
+
def __init__(self, session, project_entity):
self.session = session
self.project_entity = project_entity
@@ -392,10 +394,7 @@ class FtrackEntityOperator:
query = '{} where name is "{}" and project_id is "{}"'.format(
type, name, self.project_entity["id"])
- try:
- entity = session.query(query).one()
- except Exception:
- entity = None
+ entity = session.query(query).first()
# if entity doesnt exist then create one
if not entity:
@@ -430,10 +429,21 @@ class FtrackEntityOperator:
return parents
def create_task(self, task_type, task_types, parent):
- existing_task = [
+ _exising_tasks = [
child for child in parent['children']
if child.entity_type.lower() == 'task'
- if child['name'].lower() in task_type.lower()
+ ]
+
+ # add task into existing tasks if they are not already there
+ for _t in _exising_tasks:
+ if _t in self.existing_tasks:
+ continue
+ self.existing_tasks.append(_t)
+
+ existing_task = [
+ task for task in self.existing_tasks
+ if task['name'].lower() in task_type.lower()
+ if task['parent'] == parent
]
if existing_task:
@@ -445,4 +455,5 @@ class FtrackEntityOperator:
})
task["type"] = task_types[task_type]
+ self.existing_tasks.append(task)
return task
diff --git a/openpype/hosts/flame/startup/openpype_flame_to_ftrack/modules/panel_app.py b/openpype/hosts/flame/startup/openpype_babypublisher/modules/panel_app.py
similarity index 96%
rename from openpype/hosts/flame/startup/openpype_flame_to_ftrack/modules/panel_app.py
rename to openpype/hosts/flame/startup/openpype_babypublisher/modules/panel_app.py
index 648f902872..1e8011efaa 100644
--- a/openpype/hosts/flame/startup/openpype_flame_to_ftrack/modules/panel_app.py
+++ b/openpype/hosts/flame/startup/openpype_babypublisher/modules/panel_app.py
@@ -1,4 +1,4 @@
-from PySide2 import QtWidgets, QtCore
+from Qt import QtWidgets, QtCore
import uiwidgets
import app_utils
@@ -33,11 +33,12 @@ class MainWindow(QtWidgets.QWidget):
self.panel_class.clear_temp_data()
self.panel_class.close()
clear_inner_modules()
+ ftrack_lib.FtrackEntityOperator.existing_tasks = []
# now the panel can be closed
event.accept()
-class FlameToFtrackPanel(object):
+class FlameBabyPublisherPanel(object):
session = None
temp_data_dir = None
processed_components = []
@@ -78,7 +79,7 @@ class FlameToFtrackPanel(object):
# creating ui
self.window.setMinimumSize(1500, 600)
- self.window.setWindowTitle('Sequence Shots to Ftrack')
+ self.window.setWindowTitle('OpenPype: Baby-publisher')
self.window.setWindowFlags(QtCore.Qt.WindowStaysOnTopHint)
self.window.setAttribute(QtCore.Qt.WA_DeleteOnClose)
self.window.setFocusPolicy(QtCore.Qt.StrongFocus)
@@ -469,10 +470,14 @@ class FlameToFtrackPanel(object):
for sequence in self.selection:
frame_rate = float(str(sequence.frame_rate)[:-4])
for ver in sequence.versions:
- for tracks in ver.tracks:
- for segment in tracks.segments:
+ for track in ver.tracks:
+ if len(track.segments) == 0 and track.hidden:
+ continue
+ for segment in track.segments:
print(segment.attributes)
- if str(segment.name)[1:-1] == "":
+ if segment.name.get_value() == "":
+ continue
+ if segment.hidden.get_value() is True:
continue
# get clip frame duration
record_duration = str(segment.record_duration)[1:-1]
@@ -492,11 +497,11 @@ class FlameToFtrackPanel(object):
# Add timeline segment to tree
QtWidgets.QTreeWidgetItem(self.tree, [
- str(sequence.name)[1:-1], # seq
- str(segment.name)[1:-1], # shot
+ sequence.name.get_value(), # seq name
+ segment.shot_name.get_value(), # shot name
str(clip_duration), # clip duration
shot_description, # shot description
- str(segment.comment)[1:-1] # task description
+ segment.comment.get_value() # task description
]).setFlags(
QtCore.Qt.ItemIsEditable
| QtCore.Qt.ItemIsEnabled
diff --git a/openpype/hosts/flame/startup/openpype_flame_to_ftrack/modules/uiwidgets.py b/openpype/hosts/flame/startup/openpype_babypublisher/modules/uiwidgets.py
similarity index 99%
rename from openpype/hosts/flame/startup/openpype_flame_to_ftrack/modules/uiwidgets.py
rename to openpype/hosts/flame/startup/openpype_babypublisher/modules/uiwidgets.py
index 0d4807a4ea..c6db875df0 100644
--- a/openpype/hosts/flame/startup/openpype_flame_to_ftrack/modules/uiwidgets.py
+++ b/openpype/hosts/flame/startup/openpype_babypublisher/modules/uiwidgets.py
@@ -1,4 +1,4 @@
-from PySide2 import QtWidgets, QtCore
+from Qt import QtWidgets, QtCore
class FlameLabel(QtWidgets.QLabel):
diff --git a/openpype/hosts/flame/startup/openpype_flame_to_ftrack/openpype_flame_to_ftrack.py b/openpype/hosts/flame/startup/openpype_babypublisher/openpype_babypublisher.py
similarity index 85%
rename from openpype/hosts/flame/startup/openpype_flame_to_ftrack/openpype_flame_to_ftrack.py
rename to openpype/hosts/flame/startup/openpype_babypublisher/openpype_babypublisher.py
index 5a72706ba1..4675d163e3 100644
--- a/openpype/hosts/flame/startup/openpype_flame_to_ftrack/openpype_flame_to_ftrack.py
+++ b/openpype/hosts/flame/startup/openpype_babypublisher/openpype_babypublisher.py
@@ -16,10 +16,11 @@ def flame_panel_executor(selection):
if "panel_app" in sys.modules.keys():
print("panel_app module is already loaded")
del sys.modules["panel_app"]
+ import panel_app
+ reload(panel_app) # noqa
print("panel_app module removed from sys.modules")
- import panel_app
- panel_app.FlameToFtrackPanel(selection)
+ panel_app.FlameBabyPublisherPanel(selection)
def scope_sequence(selection):
@@ -30,7 +31,7 @@ def scope_sequence(selection):
def get_media_panel_custom_ui_actions():
return [
{
- "name": "OpenPype: Ftrack",
+ "name": "OpenPype: Baby-publisher",
"actions": [
{
"name": "Create Shots",
diff --git a/openpype/hosts/fusion/scripts/fusion_switch_shot.py b/openpype/hosts/fusion/scripts/fusion_switch_shot.py
index 041b53f6c9..9dd8a351e4 100644
--- a/openpype/hosts/fusion/scripts/fusion_switch_shot.py
+++ b/openpype/hosts/fusion/scripts/fusion_switch_shot.py
@@ -176,7 +176,7 @@ def update_frame_range(comp, representations):
versions = list(versions)
versions = [v for v in versions
- if v["data"].get("startFrame", None) is not None]
+ if v["data"].get("frameStart", None) is not None]
if not versions:
log.warning("No versions loaded to match frame range to.\n")
diff --git a/openpype/hosts/harmony/api/lib.py b/openpype/hosts/harmony/api/lib.py
index 134f670dc4..66eeac1e3a 100644
--- a/openpype/hosts/harmony/api/lib.py
+++ b/openpype/hosts/harmony/api/lib.py
@@ -361,7 +361,7 @@ def zip_and_move(source, destination):
log.debug(f"Saved '{source}' to '{destination}'")
-def show(module_name):
+def show(tool_name):
"""Call show on "module_name".
This allows to make a QApplication ahead of time and always "exec_" to
@@ -375,13 +375,6 @@ def show(module_name):
# requests to be received properly.
time.sleep(1)
- # Get tool name from module name
- # TODO this is for backwards compatibility not sure if `TB_sceneOpened.js`
- # is automatically updated.
- # Previous javascript sent 'module_name' which contained whole tool import
- # string e.g. "avalon.tools.workfiles" now it should be only "workfiles"
- tool_name = module_name.split(".")[-1]
-
kwargs = {}
if tool_name == "loader":
kwargs["use_context"] = True
diff --git a/openpype/hosts/houdini/api/__init__.py b/openpype/hosts/houdini/api/__init__.py
index e1500aa5f5..fddf7ab98d 100644
--- a/openpype/hosts/houdini/api/__init__.py
+++ b/openpype/hosts/houdini/api/__init__.py
@@ -24,8 +24,7 @@ from .lib import (
lsattrs,
read,
- maintained_selection,
- unique_name
+ maintained_selection
)
@@ -51,8 +50,7 @@ __all__ = [
"lsattrs",
"read",
- "maintained_selection",
- "unique_name"
+ "maintained_selection"
]
# Backwards API compatibility
diff --git a/openpype/hosts/houdini/api/lib.py b/openpype/hosts/houdini/api/lib.py
index 5a087ea276..bd41618856 100644
--- a/openpype/hosts/houdini/api/lib.py
+++ b/openpype/hosts/houdini/api/lib.py
@@ -99,65 +99,6 @@ def get_id_required_nodes():
return list(nodes)
-def get_additional_data(container):
- """Not implemented yet!"""
- return container
-
-
-def set_parameter_callback(node, parameter, language, callback):
- """Link a callback to a parameter of a node
-
- Args:
- node(hou.Node): instance of the nodee
- parameter(str): name of the parameter
- language(str): name of the language, e.g.: python
- callback(str): command which needs to be triggered
-
- Returns:
- None
-
- """
-
- template_grp = node.parmTemplateGroup()
- template = template_grp.find(parameter)
- if not template:
- return
-
- script_language = (hou.scriptLanguage.Python if language == "python" else
- hou.scriptLanguage.Hscript)
-
- template.setScriptCallbackLanguage(script_language)
- template.setScriptCallback(callback)
-
- template.setTags({"script_callback": callback,
- "script_callback_language": language.lower()})
-
- # Replace the existing template with the adjusted one
- template_grp.replace(parameter, template)
-
- node.setParmTemplateGroup(template_grp)
-
-
-def set_parameter_callbacks(node, parameter_callbacks):
- """Set callbacks for multiple parameters of a node
-
- Args:
- node(hou.Node): instance of a hou.Node
- parameter_callbacks(dict): collection of parameter and callback data
- example: {"active" :
- {"language": "python",
- "callback": "print('hello world)'"}
- }
- Returns:
- None
- """
- for parameter, data in parameter_callbacks.items():
- language = data["language"]
- callback = data["callback"]
-
- set_parameter_callback(node, parameter, language, callback)
-
-
def get_output_parameter(node):
"""Return the render output parameter name of the given node
@@ -189,19 +130,6 @@ def get_output_parameter(node):
raise TypeError("Node type '%s' not supported" % node_type)
-@contextmanager
-def attribute_values(node, data):
-
- previous_attrs = {key: node.parm(key).eval() for key in data.keys()}
- try:
- node.setParms(data)
- yield
- except Exception as exc:
- pass
- finally:
- node.setParms(previous_attrs)
-
-
def set_scene_fps(fps):
hou.setFps(fps)
@@ -349,10 +277,6 @@ def render_rop(ropnode):
raise RuntimeError("Render failed: {0}".format(exc))
-def children_as_string(node):
- return [c.name() for c in node.children()]
-
-
def imprint(node, data):
"""Store attributes with value on a node
@@ -473,53 +397,6 @@ def read(node):
parameter in node.spareParms()}
-def unique_name(name, format="%03d", namespace="", prefix="", suffix="",
- separator="_"):
- """Return unique `name`
-
- The function takes into consideration an optional `namespace`
- and `suffix`. The suffix is included in evaluating whether a
- name exists - such as `name` + "_GRP" - but isn't included
- in the returned value.
-
- If a namespace is provided, only names within that namespace
- are considered when evaluating whether the name is unique.
-
- Arguments:
- format (str, optional): The `name` is given a number, this determines
- how this number is formatted. Defaults to a padding of 2.
- E.g. my_name01, my_name02.
- namespace (str, optional): Only consider names within this namespace.
- suffix (str, optional): Only consider names with this suffix.
-
- Example:
- >>> name = hou.node("/obj").createNode("geo", name="MyName")
- >>> assert hou.node("/obj/MyName")
- True
- >>> unique = unique_name(name)
- >>> assert hou.node("/obj/{}".format(unique))
- False
-
- """
-
- iteration = 1
-
- parts = [prefix, name, format % iteration, suffix]
- if namespace:
- parts.insert(0, namespace)
-
- unique = separator.join(parts)
- children = children_as_string(hou.node("/obj"))
- while unique in children:
- iteration += 1
- unique = separator.join(parts)
-
- if suffix:
- return unique[:-len(suffix)]
-
- return unique
-
-
@contextmanager
def maintained_selection():
"""Maintain selection during context
diff --git a/openpype/hosts/houdini/plugins/load/actions.py b/openpype/hosts/houdini/plugins/load/actions.py
index 6e9410ff58..acdb998c16 100644
--- a/openpype/hosts/houdini/plugins/load/actions.py
+++ b/openpype/hosts/houdini/plugins/load/actions.py
@@ -29,8 +29,8 @@ class SetFrameRangeLoader(api.Loader):
version = context["version"]
version_data = version.get("data", {})
- start = version_data.get("startFrame", None)
- end = version_data.get("endFrame", None)
+ start = version_data.get("frameStart", None)
+ end = version_data.get("frameEnd", None)
if start is None or end is None:
print(
@@ -67,8 +67,8 @@ class SetFrameRangeWithHandlesLoader(api.Loader):
version = context["version"]
version_data = version.get("data", {})
- start = version_data.get("startFrame", None)
- end = version_data.get("endFrame", None)
+ start = version_data.get("frameStart", None)
+ end = version_data.get("frameEnd", None)
if start is None or end is None:
print(
@@ -78,9 +78,8 @@ class SetFrameRangeWithHandlesLoader(api.Loader):
return
# Include handles
- handles = version_data.get("handles", 0)
- start -= handles
- end += handles
+ start -= version_data.get("handleStart", 0)
+ end += version_data.get("handleEnd", 0)
hou.playbar.setFrameRange(start, end)
hou.playbar.setPlaybackRange(start, end)
diff --git a/openpype/hosts/houdini/plugins/publish/validate_abc_primitive_to_detail.py b/openpype/hosts/houdini/plugins/publish/validate_abc_primitive_to_detail.py
index 8fe1b44b7a..3e17d3e8de 100644
--- a/openpype/hosts/houdini/plugins/publish/validate_abc_primitive_to_detail.py
+++ b/openpype/hosts/houdini/plugins/publish/validate_abc_primitive_to_detail.py
@@ -65,7 +65,7 @@ class ValidateAbcPrimitiveToDetail(pyblish.api.InstancePlugin):
cls.log.debug("Checking with path attribute: %s" % path_attr)
# Check if the primitive attribute exists
- frame = instance.data.get("startFrame", 0)
+ frame = instance.data.get("frameStart", 0)
geo = output.geometryAtFrame(frame)
# If there are no primitives on the start frame then it might be
diff --git a/openpype/hosts/houdini/plugins/publish/validate_alembic_input_node.py b/openpype/hosts/houdini/plugins/publish/validate_alembic_input_node.py
index 17c9da837a..8d7e3b611f 100644
--- a/openpype/hosts/houdini/plugins/publish/validate_alembic_input_node.py
+++ b/openpype/hosts/houdini/plugins/publish/validate_alembic_input_node.py
@@ -38,7 +38,7 @@ class ValidateAlembicInputNode(pyblish.api.InstancePlugin):
cls.log.warning("No geometry output node found, skipping check..")
return
- frame = instance.data.get("startFrame", 0)
+ frame = instance.data.get("frameStart", 0)
geo = node.geometryAtFrame(frame)
invalid = False
diff --git a/openpype/hosts/houdini/plugins/publish/validate_primitive_hierarchy_paths.py b/openpype/hosts/houdini/plugins/publish/validate_primitive_hierarchy_paths.py
index 3c15532be8..1eb36763bb 100644
--- a/openpype/hosts/houdini/plugins/publish/validate_primitive_hierarchy_paths.py
+++ b/openpype/hosts/houdini/plugins/publish/validate_primitive_hierarchy_paths.py
@@ -51,7 +51,7 @@ class ValidatePrimitiveHierarchyPaths(pyblish.api.InstancePlugin):
cls.log.debug("Checking for attribute: %s" % path_attr)
# Check if the primitive attribute exists
- frame = instance.data.get("startFrame", 0)
+ frame = instance.data.get("frameStart", 0)
geo = output.geometryAtFrame(frame)
# If there are no primitives on the current frame then we can't
diff --git a/openpype/hosts/maya/api/commands.py b/openpype/hosts/maya/api/commands.py
index c774afcc12..a1e0be2cfe 100644
--- a/openpype/hosts/maya/api/commands.py
+++ b/openpype/hosts/maya/api/commands.py
@@ -37,17 +37,17 @@ class ToolWindows:
def edit_shader_definitions():
- from avalon.tools import lib
from Qt import QtWidgets
from openpype.hosts.maya.api.shader_definition_editor import (
ShaderDefinitionsEditor
)
+ from openpype.tools.utils import qt_app_context
top_level_widgets = QtWidgets.QApplication.topLevelWidgets()
main_window = next(widget for widget in top_level_widgets
if widget.objectName() == "MayaWindow")
- with lib.application():
+ with qt_app_context():
window = ToolWindows.get_window("shader_definition_editor")
if not window:
window = ShaderDefinitionsEditor(parent=main_window)
diff --git a/openpype/hosts/maya/api/customize.py b/openpype/hosts/maya/api/customize.py
index 37fd543315..683e6b24b0 100644
--- a/openpype/hosts/maya/api/customize.py
+++ b/openpype/hosts/maya/api/customize.py
@@ -5,7 +5,7 @@ import logging
from functools import partial
-import maya.cmds as mc
+import maya.cmds as cmds
import maya.mel as mel
from openpype.api import resources
@@ -30,9 +30,9 @@ def override_component_mask_commands():
log.info("Installing override_component_mask_commands..")
# Get all object mask buttons
- buttons = mc.formLayout("objectMaskIcons",
- query=True,
- childArray=True)
+ buttons = cmds.formLayout("objectMaskIcons",
+ query=True,
+ childArray=True)
# Skip the triangle list item
buttons = [btn for btn in buttons if btn != "objPickMenuLayout"]
@@ -43,14 +43,14 @@ def override_component_mask_commands():
# toggle the others based on whether any of the buttons
# was remaining active after the toggle, if not then
# enable all
- if mc.getModifiers() == 4: # = CTRL
+ if cmds.getModifiers() == 4: # = CTRL
state = True
- active = [mc.iconTextCheckBox(btn, query=True, value=True) for btn
- in buttons]
+ active = [cmds.iconTextCheckBox(btn, query=True, value=True)
+ for btn in buttons]
if any(active):
- mc.selectType(allObjects=False)
+ cmds.selectType(allObjects=False)
else:
- mc.selectType(allObjects=True)
+ cmds.selectType(allObjects=True)
# Replace #1 with the current button state
cmd = raw_command.replace(" #1", " {}".format(int(state)))
@@ -63,13 +63,13 @@ def override_component_mask_commands():
# try to implement the fix. (This also allows us to
# "uninstall" the behavior later)
if btn not in COMPONENT_MASK_ORIGINAL:
- original = mc.iconTextCheckBox(btn, query=True, cc=True)
+ original = cmds.iconTextCheckBox(btn, query=True, cc=True)
COMPONENT_MASK_ORIGINAL[btn] = original
# Assign the special callback
original = COMPONENT_MASK_ORIGINAL[btn]
new_fn = partial(on_changed_callback, original)
- mc.iconTextCheckBox(btn, edit=True, cc=new_fn)
+ cmds.iconTextCheckBox(btn, edit=True, cc=new_fn)
def override_toolbox_ui():
@@ -78,25 +78,36 @@ def override_toolbox_ui():
parent_widget = get_main_window()
# Ensure the maya web icon on toolbox exists
- web_button = "ToolBox|MainToolboxLayout|mayaWebButton"
- if not mc.iconTextButton(web_button, query=True, exists=True):
+ button_names = [
+ # Maya 2022.1+ with maya.cmds.iconTextStaticLabel
+ "ToolBox|MainToolboxLayout|mayaHomeToolboxButton",
+ # Older with maya.cmds.iconTextButton
+ "ToolBox|MainToolboxLayout|mayaWebButton"
+ ]
+ for name in button_names:
+ if cmds.control(name, query=True, exists=True):
+ web_button = name
+ break
+ else:
+ # Button does not exist
+ log.warning("Can't find Maya Home/Web button to override toolbox ui..")
return
- mc.iconTextButton(web_button, edit=True, visible=False)
+ cmds.control(web_button, edit=True, visible=False)
# real = 32, but 36 with padding - according to toolbox mel script
icon_size = 36
parent = web_button.rsplit("|", 1)[0]
# Ensure the parent is a formLayout
- if not mc.objectTypeUI(parent) == "formLayout":
+ if not cmds.objectTypeUI(parent) == "formLayout":
return
# Create our controls
controls = []
controls.append(
- mc.iconTextButton(
+ cmds.iconTextButton(
"pype_toolbox_lookmanager",
annotation="Look Manager",
label="Look Manager",
@@ -109,7 +120,7 @@ def override_toolbox_ui():
)
controls.append(
- mc.iconTextButton(
+ cmds.iconTextButton(
"pype_toolbox_workfiles",
annotation="Work Files",
label="Work Files",
@@ -124,7 +135,7 @@ def override_toolbox_ui():
)
controls.append(
- mc.iconTextButton(
+ cmds.iconTextButton(
"pype_toolbox_loader",
annotation="Loader",
label="Loader",
@@ -139,7 +150,7 @@ def override_toolbox_ui():
)
controls.append(
- mc.iconTextButton(
+ cmds.iconTextButton(
"pype_toolbox_manager",
annotation="Inventory",
label="Inventory",
@@ -159,7 +170,7 @@ def override_toolbox_ui():
for i, control in enumerate(controls):
previous = controls[i - 1] if i > 0 else web_button
- mc.formLayout(parent, edit=True,
- attachControl=[control, "bottom", 0, previous],
- attachForm=([control, "left", 1],
- [control, "right", 1]))
+ cmds.formLayout(parent, edit=True,
+ attachControl=[control, "bottom", 0, previous],
+ attachForm=([control, "left", 1],
+ [control, "right", 1]))
diff --git a/openpype/hosts/maya/api/lib.py b/openpype/hosts/maya/api/lib.py
index 0b05182bb1..41c67a6209 100644
--- a/openpype/hosts/maya/api/lib.py
+++ b/openpype/hosts/maya/api/lib.py
@@ -8,7 +8,6 @@ import math
import json
import logging
-import itertools
import contextlib
from collections import OrderedDict, defaultdict
from math import ceil
@@ -267,8 +266,10 @@ def float_round(num, places=0, direction=ceil):
def pairwise(iterable):
"""s -> (s0,s1), (s2,s3), (s4, s5), ..."""
+ from six.moves import zip
+
a = iter(iterable)
- return itertools.izip(a, a)
+ return zip(a, a)
def export_alembic(nodes,
@@ -354,7 +355,8 @@ def collect_animation_data(fps=False):
data = OrderedDict()
data["frameStart"] = start
data["frameEnd"] = end
- data["handles"] = 0
+ data["handleStart"] = 0
+ data["handleEnd"] = 0
data["step"] = 1.0
if fps:
@@ -2986,7 +2988,27 @@ def set_colorspace():
"""
project_name = os.getenv("AVALON_PROJECT")
imageio = get_anatomy_settings(project_name)["imageio"]["maya"]
- root_dict = imageio["colorManagementPreference"]
+
+ # Maya 2022+ introduces new OCIO v2 color management settings that
+ # can override the old color managenement preferences. OpenPype has
+ # separate settings for both so we fall back when necessary.
+ use_ocio_v2 = imageio["colorManagementPreference_v2"]["enabled"]
+ required_maya_version = 2022
+ maya_version = int(cmds.about(version=True))
+ maya_supports_ocio_v2 = maya_version >= required_maya_version
+ if use_ocio_v2 and not maya_supports_ocio_v2:
+ # Fallback to legacy behavior with a warning
+ log.warning("Color Management Preference v2 is enabled but not "
+ "supported by current Maya version: {} (< {}). Falling "
+ "back to legacy settings.".format(
+ maya_version, required_maya_version)
+ )
+ use_ocio_v2 = False
+
+ if use_ocio_v2:
+ root_dict = imageio["colorManagementPreference_v2"]
+ else:
+ root_dict = imageio["colorManagementPreference"]
if not isinstance(root_dict, dict):
msg = "set_colorspace(): argument should be dictionary"
@@ -2994,11 +3016,12 @@ def set_colorspace():
log.debug(">> root_dict: {}".format(root_dict))
- # first enable color management
+ # enable color management
cmds.colorManagementPrefs(e=True, cmEnabled=True)
cmds.colorManagementPrefs(e=True, ocioRulesEnabled=True)
- # second set config path
+ # set config path
+ custom_ocio_config = False
if root_dict.get("configFilePath"):
unresolved_path = root_dict["configFilePath"]
ocio_paths = unresolved_path[platform.system().lower()]
@@ -3015,16 +3038,50 @@ def set_colorspace():
cmds.colorManagementPrefs(e=True, cmConfigFileEnabled=True)
log.debug("maya '{}' changed to: {}".format(
"configFilePath", resolved_path))
- root_dict.pop("configFilePath")
+ custom_ocio_config = True
else:
cmds.colorManagementPrefs(e=True, cmConfigFileEnabled=False)
- cmds.colorManagementPrefs(e=True, configFilePath="" )
+ cmds.colorManagementPrefs(e=True, configFilePath="")
- # third set rendering space and view transform
- renderSpace = root_dict["renderSpace"]
- cmds.colorManagementPrefs(e=True, renderingSpaceName=renderSpace)
- viewTransform = root_dict["viewTransform"]
- cmds.colorManagementPrefs(e=True, viewTransformName=viewTransform)
+ # If no custom OCIO config file was set we make sure that Maya 2022+
+ # either chooses between Maya's newer default v2 or legacy config based
+ # on OpenPype setting to use ocio v2 or not.
+ if maya_supports_ocio_v2 and not custom_ocio_config:
+ if use_ocio_v2:
+ # Use Maya 2022+ default OCIO v2 config
+ log.info("Setting default Maya OCIO v2 config")
+ cmds.colorManagementPrefs(edit=True, configFilePath="")
+ else:
+ # Set the Maya default config file path
+ log.info("Setting default Maya OCIO v1 legacy config")
+ cmds.colorManagementPrefs(edit=True, configFilePath="legacy")
+
+ # set color spaces for rendering space and view transforms
+ def _colormanage(**kwargs):
+ """Wrapper around `cmds.colorManagementPrefs`.
+
+ This logs errors instead of raising an error so color management
+ settings get applied as much as possible.
+
+ """
+ assert len(kwargs) == 1, "Must receive one keyword argument"
+ try:
+ cmds.colorManagementPrefs(edit=True, **kwargs)
+ log.debug("Setting Color Management Preference: {}".format(kwargs))
+ except RuntimeError as exc:
+ log.error(exc)
+
+ if use_ocio_v2:
+ _colormanage(renderingSpaceName=root_dict["renderSpace"])
+ _colormanage(displayName=root_dict["displayName"])
+ _colormanage(viewName=root_dict["viewName"])
+ else:
+ _colormanage(renderingSpaceName=root_dict["renderSpace"])
+ if maya_supports_ocio_v2:
+ _colormanage(viewName=root_dict["viewTransform"])
+ _colormanage(displayName="legacy")
+ else:
+ _colormanage(viewTransformName=root_dict["viewTransform"])
@contextlib.contextmanager
diff --git a/openpype/hosts/maya/api/menu.py b/openpype/hosts/maya/api/menu.py
index b1934c757d..5f0fc39bf3 100644
--- a/openpype/hosts/maya/api/menu.py
+++ b/openpype/hosts/maya/api/menu.py
@@ -36,7 +36,7 @@ def install():
return
def deferred():
- from avalon.tools import publish
+ pyblish_icon = host_tools.get_pyblish_icon()
parent_widget = get_main_window()
cmds.menu(
MENU_NAME,
@@ -80,7 +80,7 @@ def install():
command=lambda *args: host_tools.show_publish(
parent=parent_widget
),
- image=publish.ICON
+ image=pyblish_icon
)
cmds.menuItem(
diff --git a/openpype/hosts/maya/plugins/create/create_render.py b/openpype/hosts/maya/plugins/create/create_render.py
index 97a190d57d..743ec26778 100644
--- a/openpype/hosts/maya/plugins/create/create_render.py
+++ b/openpype/hosts/maya/plugins/create/create_render.py
@@ -253,7 +253,7 @@ class CreateRender(plugin.Creator):
# get pools
pool_names = []
- self.server_aliases = self.deadline_servers.keys()
+ self.server_aliases = list(self.deadline_servers.keys())
self.data["deadlineServers"] = self.server_aliases
self.data["suspendPublishJob"] = False
self.data["review"] = True
@@ -286,15 +286,12 @@ class CreateRender(plugin.Creator):
raise RuntimeError("Both Deadline and Muster are enabled")
if deadline_enabled:
- # if default server is not between selected, use first one for
- # initial list of pools.
try:
deadline_url = self.deadline_servers["default"]
except KeyError:
- deadline_url = [
- self.deadline_servers[k]
- for k in self.deadline_servers.keys()
- ][0]
+ # if 'default' server is not between selected,
+ # use first one for initial list of pools.
+ deadline_url = next(iter(self.deadline_servers.values()))
pool_names = self._get_deadline_pools(deadline_url)
diff --git a/openpype/hosts/maya/plugins/load/actions.py b/openpype/hosts/maya/plugins/load/actions.py
index 1cc7ee0c03..1cb63c8a7a 100644
--- a/openpype/hosts/maya/plugins/load/actions.py
+++ b/openpype/hosts/maya/plugins/load/actions.py
@@ -72,9 +72,8 @@ class SetFrameRangeWithHandlesLoader(api.Loader):
return
# Include handles
- handles = version_data.get("handles", 0)
- start -= handles
- end += handles
+ start -= version_data.get("handleStart", 0)
+ end += version_data.get("handleEnd", 0)
cmds.playbackOptions(minTime=start,
maxTime=end,
diff --git a/openpype/hosts/maya/plugins/load/load_vrayscene.py b/openpype/hosts/maya/plugins/load/load_vrayscene.py
index 2e85514938..dfe2b85edc 100644
--- a/openpype/hosts/maya/plugins/load/load_vrayscene.py
+++ b/openpype/hosts/maya/plugins/load/load_vrayscene.py
@@ -1,5 +1,6 @@
+# -*- coding: utf-8 -*-
import os
-import maya.cmds as cmds
+import maya.cmds as cmds # noqa
from avalon import api
from openpype.api import get_project_settings
from openpype.hosts.maya.api.lib import (
@@ -42,20 +43,20 @@ class VRaySceneLoader(api.Loader):
with maintained_selection():
cmds.namespace(addNamespace=namespace)
with namespaced(namespace, new=False):
- nodes, group_node = self.create_vray_scene(name,
- filename=self.fname)
+ nodes, root_node = self.create_vray_scene(name,
+ filename=self.fname)
self[:] = nodes
if not nodes:
return
# colour the group node
- presets = get_project_settings(os.environ['AVALON_PROJECT'])
- colors = presets['maya']['load']['colors']
+ settings = get_project_settings(os.environ['AVALON_PROJECT'])
+ colors = settings['maya']['load']['colors']
c = colors.get(family)
if c is not None:
- cmds.setAttr("{0}.useOutlinerColor".format(group_node), 1)
- cmds.setAttr("{0}.outlinerColor".format(group_node),
+ cmds.setAttr("{0}.useOutlinerColor".format(root_node), 1)
+ cmds.setAttr("{0}.outlinerColor".format(root_node),
(float(c[0])/255),
(float(c[1])/255),
(float(c[2])/255)
@@ -123,17 +124,21 @@ class VRaySceneLoader(api.Loader):
mesh_node_name = "VRayScene_{}".format(name)
trans = cmds.createNode(
- "transform", name="{}".format(mesh_node_name))
- mesh = cmds.createNode(
- "mesh", name="{}_Shape".format(mesh_node_name), parent=trans)
+ "transform", name=mesh_node_name)
vray_scene = cmds.createNode(
"VRayScene", name="{}_VRSCN".format(mesh_node_name), parent=trans)
+ mesh = cmds.createNode(
+ "mesh", name="{}_Shape".format(mesh_node_name), parent=trans)
cmds.connectAttr(
"{}.outMesh".format(vray_scene), "{}.inMesh".format(mesh))
cmds.setAttr("{}.FilePath".format(vray_scene), filename, type="string")
+ # Lock the shape nodes so the user cannot delete these
+ cmds.lockNode(mesh, lock=True)
+ cmds.lockNode(vray_scene, lock=True)
+
# Create important connections
cmds.connectAttr("time1.outTime",
"{0}.inputTime".format(trans))
@@ -141,11 +146,9 @@ class VRaySceneLoader(api.Loader):
# Connect mesh to initialShadingGroup
cmds.sets([mesh], forceElement="initialShadingGroup")
- group_node = cmds.group(empty=True, name="{}_GRP".format(name))
- cmds.parent(trans, group_node)
- nodes = [trans, vray_scene, mesh, group_node]
+ nodes = [trans, vray_scene, mesh]
# Fix: Force refresh so the mesh shows correctly after creation
cmds.refresh()
- return nodes, group_node
+ return nodes, trans
diff --git a/openpype/hosts/maya/plugins/publish/collect_look.py b/openpype/hosts/maya/plugins/publish/collect_look.py
index d39750e917..b6a76f1e21 100644
--- a/openpype/hosts/maya/plugins/publish/collect_look.py
+++ b/openpype/hosts/maya/plugins/publish/collect_look.py
@@ -320,7 +320,7 @@ class CollectLook(pyblish.api.InstancePlugin):
# Collect file nodes used by shading engines (if we have any)
files = []
- look_sets = sets.keys()
+ look_sets = list(sets.keys())
shader_attrs = [
"surfaceShader",
"volumeShader",
diff --git a/openpype/hosts/maya/plugins/publish/collect_render.py b/openpype/hosts/maya/plugins/publish/collect_render.py
index 13ae1924b9..d99e81573b 100644
--- a/openpype/hosts/maya/plugins/publish/collect_render.py
+++ b/openpype/hosts/maya/plugins/publish/collect_render.py
@@ -234,13 +234,14 @@ class CollectMayaRender(pyblish.api.ContextPlugin):
publish_meta_path = None
for aov in exp_files:
full_paths = []
- for file in aov[aov.keys()[0]]:
+ aov_first_key = list(aov.keys())[0]
+ for file in aov[aov_first_key]:
full_path = os.path.join(workspace, default_render_file,
file)
full_path = full_path.replace("\\", "/")
full_paths.append(full_path)
publish_meta_path = os.path.dirname(full_path)
- aov_dict[aov.keys()[0]] = full_paths
+ aov_dict[aov_first_key] = full_paths
frame_start_render = int(self.get_render_attribute(
"startFrame", layer=layer_name))
diff --git a/openpype/hosts/maya/plugins/publish/extract_animation.py b/openpype/hosts/maya/plugins/publish/extract_animation.py
index 269972d996..8a8bd67cd8 100644
--- a/openpype/hosts/maya/plugins/publish/extract_animation.py
+++ b/openpype/hosts/maya/plugins/publish/extract_animation.py
@@ -38,12 +38,8 @@ class ExtractAnimation(openpype.api.Extractor):
fullPath=True) or []
# Collect the start and end including handles
- start = instance.data["frameStart"]
- end = instance.data["frameEnd"]
- handles = instance.data.get("handles", 0) or 0
- if handles:
- start -= handles
- end += handles
+ start = instance.data["frameStartHandle"]
+ end = instance.data["frameEndHandle"]
self.log.info("Extracting animation..")
dirname = self.staging_dir(instance)
diff --git a/openpype/hosts/maya/plugins/publish/extract_ass.py b/openpype/hosts/maya/plugins/publish/extract_ass.py
index ab149de700..760f410f91 100644
--- a/openpype/hosts/maya/plugins/publish/extract_ass.py
+++ b/openpype/hosts/maya/plugins/publish/extract_ass.py
@@ -38,13 +38,9 @@ class ExtractAssStandin(openpype.api.Extractor):
self.log.info("Extracting ass sequence")
# Collect the start and end including handles
- start = instance.data.get("frameStart", 1)
- end = instance.data.get("frameEnd", 1)
- handles = instance.data.get("handles", 0)
+ start = instance.data.get("frameStartHandle", 1)
+ end = instance.data.get("frameEndHandle", 1)
step = instance.data.get("step", 0)
- if handles:
- start -= handles
- end += handles
exported_files = cmds.arnoldExportAss(filename=file_path,
selected=True,
diff --git a/openpype/hosts/maya/plugins/publish/extract_camera_alembic.py b/openpype/hosts/maya/plugins/publish/extract_camera_alembic.py
index 806a079940..5ad6b79d5c 100644
--- a/openpype/hosts/maya/plugins/publish/extract_camera_alembic.py
+++ b/openpype/hosts/maya/plugins/publish/extract_camera_alembic.py
@@ -21,17 +21,9 @@ class ExtractCameraAlembic(openpype.api.Extractor):
def process(self, instance):
- # get settings
- framerange = [instance.data.get("frameStart", 1),
- instance.data.get("frameEnd", 1)]
- handle_start = instance.data.get("handleStart", 0)
- handle_end = instance.data.get("handleEnd", 0)
-
- # TODO: deprecated attribute "handles"
-
- if handle_start is None:
- handle_start = instance.data.get("handles", 0)
- handle_end = instance.data.get("handles", 0)
+ # Collect the start and end including handles
+ start = instance.data["frameStartHandle"]
+ end = instance.data["frameEndHandle"]
step = instance.data.get("step", 1.0)
bake_to_worldspace = instance.data("bakeToWorldSpace", True)
@@ -61,10 +53,7 @@ class ExtractCameraAlembic(openpype.api.Extractor):
job_str = ' -selection -dataFormat "ogawa" '
job_str += ' -attrPrefix cb'
- job_str += ' -frameRange {0} {1} '.format(framerange[0]
- - handle_start,
- framerange[1]
- + handle_end)
+ job_str += ' -frameRange {0} {1} '.format(start, end)
job_str += ' -step {0} '.format(step)
if bake_to_worldspace:
diff --git a/openpype/hosts/maya/plugins/publish/extract_camera_mayaScene.py b/openpype/hosts/maya/plugins/publish/extract_camera_mayaScene.py
index 9d25b147de..49c156f9cd 100644
--- a/openpype/hosts/maya/plugins/publish/extract_camera_mayaScene.py
+++ b/openpype/hosts/maya/plugins/publish/extract_camera_mayaScene.py
@@ -43,7 +43,8 @@ def grouper(iterable, n, fillvalue=None):
"""
args = [iter(iterable)] * n
- return itertools.izip_longest(fillvalue=fillvalue, *args)
+ from six.moves import zip_longest
+ return zip_longest(fillvalue=fillvalue, *args)
def unlock(plug):
@@ -117,19 +118,9 @@ class ExtractCameraMayaScene(openpype.api.Extractor):
# no preset found
pass
- framerange = [instance.data.get("frameStart", 1),
- instance.data.get("frameEnd", 1)]
- handle_start = instance.data.get("handleStart", 0)
- handle_end = instance.data.get("handleEnd", 0)
-
- # TODO: deprecated attribute "handles"
-
- if handle_start is None:
- handle_start = instance.data.get("handles", 0)
- handle_end = instance.data.get("handles", 0)
-
- range_with_handles = [framerange[0] - handle_start,
- framerange[1] + handle_end]
+ # Collect the start and end including handles
+ start = instance.data["frameStartHandle"]
+ end = instance.data["frameEndHandle"]
step = instance.data.get("step", 1.0)
bake_to_worldspace = instance.data("bakeToWorldSpace", True)
@@ -164,7 +155,7 @@ class ExtractCameraMayaScene(openpype.api.Extractor):
"Performing camera bakes: {}".format(transform))
baked = lib.bake_to_world_space(
transform,
- frame_range=range_with_handles,
+ frame_range=[start, end],
step=step
)
baked_shapes = cmds.ls(baked,
diff --git a/openpype/hosts/maya/plugins/publish/extract_fbx.py b/openpype/hosts/maya/plugins/publish/extract_fbx.py
index 844084b9ab..a2adcb3091 100644
--- a/openpype/hosts/maya/plugins/publish/extract_fbx.py
+++ b/openpype/hosts/maya/plugins/publish/extract_fbx.py
@@ -168,12 +168,8 @@ class ExtractFBX(openpype.api.Extractor):
self.log.info("Export options: {0}".format(options))
# Collect the start and end including handles
- start = instance.data["frameStart"]
- end = instance.data["frameEnd"]
- handles = instance.data.get("handles", 0)
- if handles:
- start -= handles
- end += handles
+ start = instance.data["frameStartHandle"]
+ end = instance.data["frameEndHandle"]
options['bakeComplexStart'] = start
options['bakeComplexEnd'] = end
diff --git a/openpype/hosts/maya/plugins/publish/extract_look.py b/openpype/hosts/maya/plugins/publish/extract_look.py
index fe89038a24..a9a2a7b60c 100644
--- a/openpype/hosts/maya/plugins/publish/extract_look.py
+++ b/openpype/hosts/maya/plugins/publish/extract_look.py
@@ -4,6 +4,7 @@ import os
import sys
import json
import tempfile
+import platform
import contextlib
import subprocess
from collections import OrderedDict
@@ -62,6 +63,11 @@ def maketx(source, destination, *args):
from openpype.lib import get_oiio_tools_path
maketx_path = get_oiio_tools_path("maketx")
+
+ if platform.system().lower() == "windows":
+ # Ensure .exe extension
+ maketx_path += ".exe"
+
if not os.path.exists(maketx_path):
print(
"OIIO tool not found in {}".format(maketx_path))
@@ -216,7 +222,7 @@ class ExtractLook(openpype.api.Extractor):
self.log.info("Extract sets (%s) ..." % _scene_type)
lookdata = instance.data["lookData"]
relationships = lookdata["relationships"]
- sets = relationships.keys()
+ sets = list(relationships.keys())
if not sets:
self.log.info("No sets found")
return
diff --git a/openpype/hosts/maya/plugins/publish/extract_vrayproxy.py b/openpype/hosts/maya/plugins/publish/extract_vrayproxy.py
index 615bc27878..562ca078e1 100644
--- a/openpype/hosts/maya/plugins/publish/extract_vrayproxy.py
+++ b/openpype/hosts/maya/plugins/publish/extract_vrayproxy.py
@@ -28,14 +28,19 @@ class ExtractVRayProxy(openpype.api.Extractor):
if not anim_on:
# Remove animation information because it is not required for
# non-animated subsets
- instance.data.pop("frameStart", None)
- instance.data.pop("frameEnd", None)
+ keys = ["frameStart", "frameEnd",
+ "handleStart", "handleEnd",
+ "frameStartHandle", "frameEndHandle",
+ # Backwards compatibility
+ "handles"]
+ for key in keys:
+ instance.data.pop(key, None)
start_frame = 1
end_frame = 1
else:
- start_frame = instance.data["frameStart"]
- end_frame = instance.data["frameEnd"]
+ start_frame = instance.data["frameStartHandle"]
+ end_frame = instance.data["frameEndHandle"]
vertex_colors = instance.data.get("vertexColors", False)
diff --git a/openpype/hosts/maya/plugins/publish/extract_yeti_cache.py b/openpype/hosts/maya/plugins/publish/extract_yeti_cache.py
index 05fe79ecc5..0d85708789 100644
--- a/openpype/hosts/maya/plugins/publish/extract_yeti_cache.py
+++ b/openpype/hosts/maya/plugins/publish/extract_yeti_cache.py
@@ -29,8 +29,8 @@ class ExtractYetiCache(openpype.api.Extractor):
data_file = os.path.join(dirname, "yeti.fursettings")
# Collect information for writing cache
- start_frame = instance.data.get("frameStart")
- end_frame = instance.data.get("frameEnd")
+ start_frame = instance.data.get("frameStartHandle")
+ end_frame = instance.data.get("frameEndHandle")
preroll = instance.data.get("preroll")
if preroll > 0:
start_frame -= preroll
diff --git a/openpype/hosts/maya/plugins/publish/validate_ass_relative_paths.py b/openpype/hosts/maya/plugins/publish/validate_ass_relative_paths.py
index 3625d4ab32..5fb9bd98b1 100644
--- a/openpype/hosts/maya/plugins/publish/validate_ass_relative_paths.py
+++ b/openpype/hosts/maya/plugins/publish/validate_ass_relative_paths.py
@@ -110,9 +110,9 @@ class ValidateAssRelativePaths(pyblish.api.InstancePlugin):
Maya API will return a list of values, which need to be properly
handled to evaluate properly.
"""
- if isinstance(attr_val, types.BooleanType):
+ if isinstance(attr_val, bool):
return attr_val
- elif isinstance(attr_val, (types.ListType, types.GeneratorType)):
+ elif isinstance(attr_val, (list, types.GeneratorType)):
return any(attr_val)
else:
return bool(attr_val)
diff --git a/openpype/hosts/maya/plugins/publish/validate_mesh_overlapping_uvs.py b/openpype/hosts/maya/plugins/publish/validate_mesh_overlapping_uvs.py
index 5ce422239d..bf95d8ba09 100644
--- a/openpype/hosts/maya/plugins/publish/validate_mesh_overlapping_uvs.py
+++ b/openpype/hosts/maya/plugins/publish/validate_mesh_overlapping_uvs.py
@@ -5,6 +5,8 @@ import math
import maya.api.OpenMaya as om
import pymel.core as pm
+from six.moves import xrange
+
class GetOverlappingUVs(object):
diff --git a/openpype/hosts/maya/plugins/publish/validate_vray_referenced_aovs.py b/openpype/hosts/maya/plugins/publish/validate_vray_referenced_aovs.py
index 6cfbd4049b..7a48c29b7d 100644
--- a/openpype/hosts/maya/plugins/publish/validate_vray_referenced_aovs.py
+++ b/openpype/hosts/maya/plugins/publish/validate_vray_referenced_aovs.py
@@ -82,9 +82,9 @@ class ValidateVrayReferencedAOVs(pyblish.api.InstancePlugin):
bool: cast Maya attribute to Pythons boolean value.
"""
- if isinstance(attr_val, types.BooleanType):
+ if isinstance(attr_val, bool):
return attr_val
- elif isinstance(attr_val, (types.ListType, types.GeneratorType)):
+ elif isinstance(attr_val, (list, types.GeneratorType)):
return any(attr_val)
else:
return bool(attr_val)
diff --git a/openpype/hosts/nuke/api/lib.py b/openpype/hosts/nuke/api/lib.py
index 6faf6cd108..dba7ec1b85 100644
--- a/openpype/hosts/nuke/api/lib.py
+++ b/openpype/hosts/nuke/api/lib.py
@@ -1,6 +1,5 @@
import os
import re
-import sys
import six
import platform
import contextlib
@@ -679,10 +678,10 @@ def get_render_path(node):
}
nuke_imageio_writes = get_created_node_imageio_setting(**data_preset)
+ host_name = os.environ.get("AVALON_APP")
- application = lib.get_application(os.environ["AVALON_APP_NAME"])
data.update({
- "application": application,
+ "app": host_name,
"nuke_imageio_writes": nuke_imageio_writes
})
@@ -805,18 +804,14 @@ def create_write_node(name, data, input=None, prenodes=None,
'''
imageio_writes = get_created_node_imageio_setting(**data)
- app_manager = ApplicationManager()
- app_name = os.environ.get("AVALON_APP_NAME")
- if app_name:
- app = app_manager.applications.get(app_name)
-
for knob in imageio_writes["knobs"]:
if knob["name"] == "file_type":
representation = knob["value"]
+ host_name = os.environ.get("AVALON_APP")
try:
data.update({
- "app": app.host_name,
+ "app": host_name,
"imageio_writes": imageio_writes,
"representation": representation,
})
diff --git a/openpype/hosts/photoshop/api/launch_logic.py b/openpype/hosts/photoshop/api/launch_logic.py
index 16a1d23244..112cd8fe3f 100644
--- a/openpype/hosts/photoshop/api/launch_logic.py
+++ b/openpype/hosts/photoshop/api/launch_logic.py
@@ -175,7 +175,7 @@ class ProcessLauncher(QtCore.QObject):
def start(self):
if self._started:
return
- self.log.info("Started launch logic of AfterEffects")
+ self.log.info("Started launch logic of Photoshop")
self._started = True
self._start_process_timer.start()
diff --git a/openpype/hosts/photoshop/api/ws_stub.py b/openpype/hosts/photoshop/api/ws_stub.py
index fd8377d4e0..d4406d17b9 100644
--- a/openpype/hosts/photoshop/api/ws_stub.py
+++ b/openpype/hosts/photoshop/api/ws_stub.py
@@ -344,6 +344,28 @@ class PhotoshopServerStub:
)
)
+ def hide_all_others_layers(self, layers):
+ """hides all layers that are not part of the list or that are not
+ children of this list
+
+ Args:
+ layers (list): list of PSItem - highest hierarchy
+ """
+ extract_ids = set([ll.id for ll in self.get_layers_in_layers(layers)])
+
+ self.hide_all_others_layers_ids(extract_ids)
+
+ def hide_all_others_layers_ids(self, extract_ids):
+ """hides all layers that are not part of the list or that are not
+ children of this list
+
+ Args:
+ extract_ids (list): list of integer that should be visible
+ """
+ for layer in self.get_layers():
+ if layer.visible and layer.id not in extract_ids:
+ self.set_visible(layer.id, False)
+
def get_layers_metadata(self):
"""Reads layers metadata from Headline from active document in PS.
(Headline accessible by File > File Info)
diff --git a/openpype/hosts/photoshop/plugins/publish/collect_color_coded_instances.py b/openpype/hosts/photoshop/plugins/publish/collect_color_coded_instances.py
index c1ae88fbbb..7d44d55a80 100644
--- a/openpype/hosts/photoshop/plugins/publish/collect_color_coded_instances.py
+++ b/openpype/hosts/photoshop/plugins/publish/collect_color_coded_instances.py
@@ -38,10 +38,15 @@ class CollectColorCodedInstances(pyblish.api.ContextPlugin):
def process(self, context):
self.log.info("CollectColorCodedInstances")
- self.log.debug("mapping:: {}".format(self.color_code_mapping))
+ batch_dir = os.environ.get("OPENPYPE_PUBLISH_DATA")
+ if (os.environ.get("IS_TEST") and
+ (not batch_dir or not os.path.exists(batch_dir))):
+ self.log.debug("Automatic testing, no batch data, skipping")
+ return
existing_subset_names = self._get_existing_subset_names(context)
- asset_name, task_name, variant = self._parse_batch()
+
+ asset_name, task_name, variant = self._parse_batch(batch_dir)
stub = photoshop.stub()
layers = stub.get_layers()
@@ -125,9 +130,8 @@ class CollectColorCodedInstances(pyblish.api.ContextPlugin):
return existing_subset_names
- def _parse_batch(self):
+ def _parse_batch(self, batch_dir):
"""Parses asset_name, task_name, variant from batch manifest."""
- batch_dir = os.environ.get("OPENPYPE_PUBLISH_DATA")
task_data = None
if batch_dir and os.path.exists(batch_dir):
task_data = parse_json(os.path.join(batch_dir,
diff --git a/openpype/hosts/photoshop/plugins/publish/extract_image.py b/openpype/hosts/photoshop/plugins/publish/extract_image.py
index beb904215b..04ce77ee34 100644
--- a/openpype/hosts/photoshop/plugins/publish/extract_image.py
+++ b/openpype/hosts/photoshop/plugins/publish/extract_image.py
@@ -26,7 +26,6 @@ class ExtractImage(openpype.api.Extractor):
with photoshop.maintained_selection():
self.log.info("Extracting %s" % str(list(instance)))
with photoshop.maintained_visibility():
- # Hide all other layers.
layer = instance.data.get("layer")
ids = set([layer.id])
add_ids = instance.data.pop("ids", None)
@@ -34,11 +33,7 @@ class ExtractImage(openpype.api.Extractor):
ids.update(set(add_ids))
extract_ids = set([ll.id for ll in stub.
get_layers_in_layers_ids(ids)])
-
- for layer in stub.get_layers():
- # limit unnecessary calls to client
- if layer.visible and layer.id not in extract_ids:
- stub.set_visible(layer.id, False)
+ stub.hide_all_others_layers_ids(extract_ids)
file_basename = os.path.splitext(
stub.get_active_document_name()
diff --git a/openpype/hosts/photoshop/plugins/publish/extract_review.py b/openpype/hosts/photoshop/plugins/publish/extract_review.py
index b6c7e2d189..b8f4470c7b 100644
--- a/openpype/hosts/photoshop/plugins/publish/extract_review.py
+++ b/openpype/hosts/photoshop/plugins/publish/extract_review.py
@@ -1,4 +1,5 @@
import os
+import shutil
import openpype.api
import openpype.lib
@@ -7,7 +8,7 @@ from openpype.hosts.photoshop import api as photoshop
class ExtractReview(openpype.api.Extractor):
"""
- Produce a flattened image file from all 'image' instances.
+ Produce a flattened or sequence image file from all 'image' instances.
If no 'image' instance is created, it produces flattened image from
all visible layers.
@@ -20,54 +21,58 @@ class ExtractReview(openpype.api.Extractor):
# Extract Options
jpg_options = None
mov_options = None
+ make_image_sequence = None
def process(self, instance):
staging_dir = self.staging_dir(instance)
self.log.info("Outputting image to {}".format(staging_dir))
+ fps = instance.data.get("fps", 25)
stub = photoshop.stub()
+ self.output_seq_filename = os.path.splitext(
+ stub.get_active_document_name())[0] + ".%04d.jpg"
- layers = []
- for image_instance in instance.context:
- if image_instance.data["family"] != "image":
- continue
- layers.append(image_instance.data.get("layer"))
+ layers = self._get_layers_from_image_instances(instance)
+ self.log.info("Layers image instance found: {}".format(layers))
- # Perform extraction
- output_image = "{}.jpg".format(
- os.path.splitext(stub.get_active_document_name())[0]
- )
- output_image_path = os.path.join(staging_dir, output_image)
- with photoshop.maintained_visibility():
- if layers:
- # Hide all other layers.
- extract_ids = set([ll.id for ll in stub.
- get_layers_in_layers(layers)])
- self.log.debug("extract_ids {}".format(extract_ids))
- for layer in stub.get_layers():
- # limit unnecessary calls to client
- if layer.visible and layer.id not in extract_ids:
- stub.set_visible(layer.id, False)
+ if self.make_image_sequence and len(layers) > 1:
+ self.log.info("Extract layers to image sequence.")
+ img_list = self._saves_sequences_layers(staging_dir, layers)
- stub.saveAs(output_image_path, 'jpg', True)
+ instance.data["representations"].append({
+ "name": "jpg",
+ "ext": "jpg",
+ "files": img_list,
+ "frameStart": 0,
+ "frameEnd": len(img_list),
+ "fps": fps,
+ "stagingDir": staging_dir,
+ "tags": self.jpg_options['tags'],
+ })
+
+ else:
+ self.log.info("Extract layers to flatten image.")
+ img_list = self._saves_flattened_layers(staging_dir, layers)
+
+ instance.data["representations"].append({
+ "name": "jpg",
+ "ext": "jpg",
+ "files": img_list,
+ "stagingDir": staging_dir,
+ "tags": self.jpg_options['tags']
+ })
ffmpeg_path = openpype.lib.get_ffmpeg_tool_path("ffmpeg")
- instance.data["representations"].append({
- "name": "jpg",
- "ext": "jpg",
- "files": output_image,
- "stagingDir": staging_dir,
- "tags": self.jpg_options['tags']
- })
instance.data["stagingDir"] = staging_dir
# Generate thumbnail.
thumbnail_path = os.path.join(staging_dir, "thumbnail.jpg")
+ self.log.info(f"Generate thumbnail {thumbnail_path}")
args = [
ffmpeg_path,
"-y",
- "-i", output_image_path,
+ "-i", os.path.join(staging_dir, self.output_seq_filename),
"-vf", "scale=300:-1",
"-vframes", "1",
thumbnail_path
@@ -81,14 +86,17 @@ class ExtractReview(openpype.api.Extractor):
"stagingDir": staging_dir,
"tags": ["thumbnail"]
})
+
# Generate mov.
mov_path = os.path.join(staging_dir, "review.mov")
+ self.log.info(f"Generate mov review: {mov_path}")
+ img_number = len(img_list)
args = [
ffmpeg_path,
"-y",
- "-i", output_image_path,
+ "-i", os.path.join(staging_dir, self.output_seq_filename),
"-vf", "pad=ceil(iw/2)*2:ceil(ih/2)*2",
- "-vframes", "1",
+ "-vframes", str(img_number),
mov_path
]
output = openpype.lib.run_subprocess(args)
@@ -99,15 +107,86 @@ class ExtractReview(openpype.api.Extractor):
"files": os.path.basename(mov_path),
"stagingDir": staging_dir,
"frameStart": 1,
- "frameEnd": 1,
- "fps": 25,
+ "frameEnd": img_number,
+ "fps": fps,
"preview": True,
"tags": self.mov_options['tags']
})
# Required for extract_review plugin (L222 onwards).
instance.data["frameStart"] = 1
- instance.data["frameEnd"] = 1
+ instance.data["frameEnd"] = img_number
instance.data["fps"] = 25
self.log.info(f"Extracted {instance} to {staging_dir}")
+
+ def _get_image_path_from_instances(self, instance):
+ img_list = []
+
+ for instance in sorted(instance.context):
+ if instance.data["family"] != "image":
+ continue
+
+ for rep in instance.data["representations"]:
+ img_path = os.path.join(
+ rep["stagingDir"],
+ rep["files"]
+ )
+ img_list.append(img_path)
+
+ return img_list
+
+ def _copy_image_to_staging_dir(self, staging_dir, img_list):
+ copy_files = []
+ for i, img_src in enumerate(img_list):
+ img_filename = self.output_seq_filename % i
+ img_dst = os.path.join(staging_dir, img_filename)
+
+ self.log.debug(
+ "Copying file .. {} -> {}".format(img_src, img_dst)
+ )
+ shutil.copy(img_src, img_dst)
+ copy_files.append(img_filename)
+
+ return copy_files
+
+ def _get_layers_from_image_instances(self, instance):
+ layers = []
+ for image_instance in instance.context:
+ if image_instance.data["family"] != "image":
+ continue
+ layers.append(image_instance.data.get("layer"))
+
+ return sorted(layers)
+
+ def _saves_flattened_layers(self, staging_dir, layers):
+ img_filename = self.output_seq_filename % 0
+ output_image_path = os.path.join(staging_dir, img_filename)
+ stub = photoshop.stub()
+
+ with photoshop.maintained_visibility():
+ self.log.info("Extracting {}".format(layers))
+ if layers:
+ stub.hide_all_others_layers(layers)
+
+ stub.saveAs(output_image_path, 'jpg', True)
+
+ return img_filename
+
+ def _saves_sequences_layers(self, staging_dir, layers):
+ stub = photoshop.stub()
+
+ list_img_filename = []
+ with photoshop.maintained_visibility():
+ for i, layer in enumerate(layers):
+ self.log.info("Extracting {}".format(layer))
+
+ img_filename = self.output_seq_filename % i
+ output_image_path = os.path.join(staging_dir, img_filename)
+ list_img_filename.append(img_filename)
+
+ with photoshop.maintained_visibility():
+ stub.hide_all_others_layers([layer])
+ stub.saveAs(output_image_path, 'jpg', True)
+
+ return list_img_filename
diff --git a/openpype/hosts/testhost/api/instances.json b/openpype/hosts/testhost/api/instances.json
index 84021eff91..d955012514 100644
--- a/openpype/hosts/testhost/api/instances.json
+++ b/openpype/hosts/testhost/api/instances.json
@@ -8,7 +8,7 @@
"asset": "sq01_sh0010",
"task": "Compositing",
"variant": "myVariant",
- "uuid": "a485f148-9121-46a5-8157-aa64df0fb449",
+ "instance_id": "a485f148-9121-46a5-8157-aa64df0fb449",
"creator_attributes": {
"number_key": 10,
"ha": 10
@@ -29,8 +29,8 @@
"asset": "sq01_sh0010",
"task": "Compositing",
"variant": "myVariant2",
- "uuid": "a485f148-9121-46a5-8157-aa64df0fb444",
"creator_attributes": {},
+ "instance_id": "a485f148-9121-46a5-8157-aa64df0fb444",
"publish_attributes": {
"CollectFtrackApi": {
"add_ftrack_family": true
@@ -47,8 +47,8 @@
"asset": "sq01_sh0010",
"task": "Compositing",
"variant": "Main",
- "uuid": "3607bc95-75f6-4648-a58d-e699f413d09f",
"creator_attributes": {},
+ "instance_id": "3607bc95-75f6-4648-a58d-e699f413d09f",
"publish_attributes": {
"CollectFtrackApi": {
"add_ftrack_family": true
@@ -65,7 +65,7 @@
"asset": "sq01_sh0020",
"task": "Compositing",
"variant": "Main2",
- "uuid": "4ccf56f6-9982-4837-967c-a49695dbe8eb",
+ "instance_id": "4ccf56f6-9982-4837-967c-a49695dbe8eb",
"creator_attributes": {},
"publish_attributes": {
"CollectFtrackApi": {
@@ -83,7 +83,7 @@
"asset": "sq01_sh0020",
"task": "Compositing",
"variant": "Main2",
- "uuid": "4ccf56f6-9982-4837-967c-a49695dbe8ec",
+ "instance_id": "4ccf56f6-9982-4837-967c-a49695dbe8ec",
"creator_attributes": {},
"publish_attributes": {
"CollectFtrackApi": {
@@ -101,7 +101,7 @@
"asset": "Alpaca_01",
"task": "modeling",
"variant": "Main",
- "uuid": "7c9ddfc7-9f9c-4c1c-b233-38c966735fb6",
+ "instance_id": "7c9ddfc7-9f9c-4c1c-b233-38c966735fb6",
"creator_attributes": {},
"publish_attributes": {}
}
diff --git a/openpype/hosts/testhost/api/pipeline.py b/openpype/hosts/testhost/api/pipeline.py
index 49f1d3f33d..1f5d680705 100644
--- a/openpype/hosts/testhost/api/pipeline.py
+++ b/openpype/hosts/testhost/api/pipeline.py
@@ -114,7 +114,7 @@ def update_instances(update_list):
instances = HostContext.get_instances()
for instance_data in instances:
- instance_id = instance_data["uuid"]
+ instance_id = instance_data["instance_id"]
if instance_id in updated_instances:
new_instance_data = updated_instances[instance_id]
old_keys = set(instance_data.keys())
@@ -132,10 +132,10 @@ def remove_instances(instances):
current_instances = HostContext.get_instances()
for instance in instances:
- instance_id = instance.data["uuid"]
+ instance_id = instance.data["instance_id"]
found_idx = None
for idx, _instance in enumerate(current_instances):
- if instance_id == _instance["uuid"]:
+ if instance_id == _instance["instance_id"]:
found_idx = idx
break
diff --git a/openpype/hosts/traypublisher/api/__init__.py b/openpype/hosts/traypublisher/api/__init__.py
new file mode 100644
index 0000000000..c461c0c526
--- /dev/null
+++ b/openpype/hosts/traypublisher/api/__init__.py
@@ -0,0 +1,20 @@
+from .pipeline import (
+ install,
+ ls,
+
+ set_project_name,
+ get_context_title,
+ get_context_data,
+ update_context_data,
+)
+
+
+__all__ = (
+ "install",
+ "ls",
+
+ "set_project_name",
+ "get_context_title",
+ "get_context_data",
+ "update_context_data",
+)
diff --git a/openpype/hosts/traypublisher/api/pipeline.py b/openpype/hosts/traypublisher/api/pipeline.py
new file mode 100644
index 0000000000..a39e5641ae
--- /dev/null
+++ b/openpype/hosts/traypublisher/api/pipeline.py
@@ -0,0 +1,180 @@
+import os
+import json
+import tempfile
+import atexit
+
+from avalon import io
+import avalon.api
+import pyblish.api
+
+from openpype.pipeline import BaseCreator
+
+ROOT_DIR = os.path.dirname(os.path.dirname(
+ os.path.abspath(__file__)
+))
+PUBLISH_PATH = os.path.join(ROOT_DIR, "plugins", "publish")
+CREATE_PATH = os.path.join(ROOT_DIR, "plugins", "create")
+
+
+class HostContext:
+ _context_json_path = None
+
+ @staticmethod
+ def _on_exit():
+ if (
+ HostContext._context_json_path
+ and os.path.exists(HostContext._context_json_path)
+ ):
+ os.remove(HostContext._context_json_path)
+
+ @classmethod
+ def get_context_json_path(cls):
+ if cls._context_json_path is None:
+ output_file = tempfile.NamedTemporaryFile(
+ mode="w", prefix="traypub_", suffix=".json"
+ )
+ output_file.close()
+ cls._context_json_path = output_file.name
+ atexit.register(HostContext._on_exit)
+ print(cls._context_json_path)
+ return cls._context_json_path
+
+ @classmethod
+ def _get_data(cls, group=None):
+ json_path = cls.get_context_json_path()
+ data = {}
+ if not os.path.exists(json_path):
+ with open(json_path, "w") as json_stream:
+ json.dump(data, json_stream)
+ else:
+ with open(json_path, "r") as json_stream:
+ content = json_stream.read()
+ if content:
+ data = json.loads(content)
+ if group is None:
+ return data
+ return data.get(group)
+
+ @classmethod
+ def _save_data(cls, group, new_data):
+ json_path = cls.get_context_json_path()
+ data = cls._get_data()
+ data[group] = new_data
+ with open(json_path, "w") as json_stream:
+ json.dump(data, json_stream)
+
+ @classmethod
+ def add_instance(cls, instance):
+ instances = cls.get_instances()
+ instances.append(instance)
+ cls.save_instances(instances)
+
+ @classmethod
+ def get_instances(cls):
+ return cls._get_data("instances") or []
+
+ @classmethod
+ def save_instances(cls, instances):
+ cls._save_data("instances", instances)
+
+ @classmethod
+ def get_context_data(cls):
+ return cls._get_data("context") or {}
+
+ @classmethod
+ def save_context_data(cls, data):
+ cls._save_data("context", data)
+
+ @classmethod
+ def get_project_name(cls):
+ return cls._get_data("project_name")
+
+ @classmethod
+ def set_project_name(cls, project_name):
+ cls._save_data("project_name", project_name)
+
+ @classmethod
+ def get_data_to_store(cls):
+ return {
+ "project_name": cls.get_project_name(),
+ "instances": cls.get_instances(),
+ "context": cls.get_context_data(),
+ }
+
+
+def list_instances():
+ return HostContext.get_instances()
+
+
+def update_instances(update_list):
+ updated_instances = {}
+ for instance, _changes in update_list:
+ updated_instances[instance.id] = instance.data_to_store()
+
+ instances = HostContext.get_instances()
+ for instance_data in instances:
+ instance_id = instance_data["instance_id"]
+ if instance_id in updated_instances:
+ new_instance_data = updated_instances[instance_id]
+ old_keys = set(instance_data.keys())
+ new_keys = set(new_instance_data.keys())
+ instance_data.update(new_instance_data)
+ for key in (old_keys - new_keys):
+ instance_data.pop(key)
+
+ HostContext.save_instances(instances)
+
+
+def remove_instances(instances):
+ if not isinstance(instances, (tuple, list)):
+ instances = [instances]
+
+ current_instances = HostContext.get_instances()
+ for instance in instances:
+ instance_id = instance.data["instance_id"]
+ found_idx = None
+ for idx, _instance in enumerate(current_instances):
+ if instance_id == _instance["instance_id"]:
+ found_idx = idx
+ break
+
+ if found_idx is not None:
+ current_instances.pop(found_idx)
+ HostContext.save_instances(current_instances)
+
+
+def get_context_data():
+ return HostContext.get_context_data()
+
+
+def update_context_data(data, changes):
+ HostContext.save_context_data(data)
+
+
+def get_context_title():
+ return HostContext.get_project_name()
+
+
+def ls():
+ """Probably will never return loaded containers."""
+ return []
+
+
+def install():
+ """This is called before a project is known.
+
+ Project is defined with 'set_project_name'.
+ """
+ os.environ["AVALON_APP"] = "traypublisher"
+
+ pyblish.api.register_host("traypublisher")
+ pyblish.api.register_plugin_path(PUBLISH_PATH)
+ avalon.api.register_plugin_path(BaseCreator, CREATE_PATH)
+
+
+def set_project_name(project_name):
+ # TODO Deregister project specific plugins and register new project plugins
+ os.environ["AVALON_PROJECT"] = project_name
+ avalon.api.Session["AVALON_PROJECT"] = project_name
+ io.install()
+ HostContext.set_project_name(project_name)
diff --git a/openpype/hosts/traypublisher/plugins/create/create_workfile.py b/openpype/hosts/traypublisher/plugins/create/create_workfile.py
new file mode 100644
index 0000000000..2db4770bbc
--- /dev/null
+++ b/openpype/hosts/traypublisher/plugins/create/create_workfile.py
@@ -0,0 +1,97 @@
+from openpype.hosts.traypublisher.api import pipeline
+from openpype.pipeline import (
+ Creator,
+ CreatedInstance,
+ lib
+)
+
+
+class WorkfileCreator(Creator):
+ identifier = "workfile"
+ label = "Workfile"
+ family = "workfile"
+ description = "Publish backup of workfile"
+
+ create_allow_context_change = True
+
+ extensions = [
+ # Maya
+ ".ma", ".mb",
+ # Nuke
+ ".nk",
+ # Hiero
+ ".hrox",
+ # Houdini
+ ".hip", ".hiplc", ".hipnc",
+ # Blender
+ ".blend",
+ # Celaction
+ ".scn",
+ # TVPaint
+ ".tvpp",
+ # Fusion
+ ".comp",
+ # Harmony
+ ".zip",
+ # Premiere
+ ".prproj",
+ # Resolve
+ ".drp",
+ # Photoshop
+ ".psd", ".psb",
+ # Aftereffects
+ ".aep"
+ ]
+
+ def get_icon(self):
+ return "fa.file"
+
+ def collect_instances(self):
+ for instance_data in pipeline.list_instances():
+ creator_id = instance_data.get("creator_identifier")
+ if creator_id == self.identifier:
+ instance = CreatedInstance.from_existing(
+ instance_data, self
+ )
+ self._add_instance_to_context(instance)
+
+ def update_instances(self, update_list):
+ pipeline.update_instances(update_list)
+
+ def remove_instances(self, instances):
+ pipeline.remove_instances(instances)
+ for instance in instances:
+ self._remove_instance_from_context(instance)
+
+ def create(self, subset_name, data, pre_create_data):
+ # Pass precreate data to creator attributes
+ data["creator_attributes"] = pre_create_data
+ # Create new instance
+ new_instance = CreatedInstance(self.family, subset_name, data, self)
+ # Host implementation of storing metadata about instance
+ pipeline.HostContext.add_instance(new_instance.data_to_store())
+ # Add instance to current context
+ self._add_instance_to_context(new_instance)
+
+ def get_default_variants(self):
+ return [
+ "Main"
+ ]
+
+ def get_instance_attr_defs(self):
+ output = [
+ lib.FileDef(
+ "filepath",
+ folders=False,
+ extensions=self.extensions,
+ label="Filepath"
+ )
+ ]
+ return output
+
+ def get_pre_create_attr_defs(self):
+ # Use same attributes as for instance attrobites
+ return self.get_instance_attr_defs()
+
+ def get_detail_description(self):
+ return """# Publish workfile backup"""
diff --git a/openpype/hosts/traypublisher/plugins/publish/collect_source.py b/openpype/hosts/traypublisher/plugins/publish/collect_source.py
new file mode 100644
index 0000000000..6ff22be13a
--- /dev/null
+++ b/openpype/hosts/traypublisher/plugins/publish/collect_source.py
@@ -0,0 +1,24 @@
+import pyblish.api
+
+
+class CollectSource(pyblish.api.ContextPlugin):
+ """Collecting instances from traypublisher host."""
+
+ label = "Collect source"
+ order = pyblish.api.CollectorOrder - 0.49
+ hosts = ["traypublisher"]
+
+ def process(self, context):
+ # get json paths from os and load them
+ source_name = "traypublisher"
+ for instance in context:
+ source = instance.data.get("source")
+ if not source:
+ instance.data["source"] = source_name
+ self.log.info((
+ "Source of instance \"{}\" is changed to \"{}\""
+ ).format(instance.data["name"], source_name))
+ else:
+ self.log.info((
+ "Source of instance \"{}\" was already set to \"{}\""
+ ).format(instance.data["name"], source))
diff --git a/openpype/hosts/traypublisher/plugins/publish/collect_workfile.py b/openpype/hosts/traypublisher/plugins/publish/collect_workfile.py
new file mode 100644
index 0000000000..d48bace047
--- /dev/null
+++ b/openpype/hosts/traypublisher/plugins/publish/collect_workfile.py
@@ -0,0 +1,31 @@
+import os
+import pyblish.api
+
+
+class CollectWorkfile(pyblish.api.InstancePlugin):
+ """Collect representation of workfile instances."""
+
+ label = "Collect Workfile"
+ order = pyblish.api.CollectorOrder - 0.49
+ families = ["workfile"]
+ hosts = ["traypublisher"]
+
+ def process(self, instance):
+ if "representations" not in instance.data:
+ instance.data["representations"] = []
+ repres = instance.data["representations"]
+
+ creator_attributes = instance.data["creator_attributes"]
+ filepath = creator_attributes["filepath"]
+ instance.data["sourceFilepath"] = filepath
+
+ staging_dir = os.path.dirname(filepath)
+ filename = os.path.basename(filepath)
+ ext = os.path.splitext(filename)[-1]
+
+ repres.append({
+ "ext": ext,
+ "name": ext,
+ "stagingDir": staging_dir,
+ "files": filename
+ })
diff --git a/openpype/hosts/traypublisher/plugins/publish/validate_workfile.py b/openpype/hosts/traypublisher/plugins/publish/validate_workfile.py
new file mode 100644
index 0000000000..88339d2aac
--- /dev/null
+++ b/openpype/hosts/traypublisher/plugins/publish/validate_workfile.py
@@ -0,0 +1,24 @@
+import os
+import pyblish.api
+from openpype.pipeline import PublishValidationError
+
+
+class ValidateWorkfilePath(pyblish.api.InstancePlugin):
+ """Validate existence of workfile instance existence."""
+
+ label = "Collect Workfile"
+ order = pyblish.api.ValidatorOrder - 0.49
+ families = ["workfile"]
+ hosts = ["traypublisher"]
+
+ def process(self, instance):
+ filepath = instance.data["sourceFilepath"]
+ if not filepath:
+ raise PublishValidationError((
+ "Filepath of 'workfile' instance \"{}\" is not set"
+ ).format(instance.data["name"]))
+
+ if not os.path.exists(filepath):
+ raise PublishValidationError((
+ "Filepath of 'workfile' instance \"{}\" does not exist: {}"
+ ).format(instance.data["name"], filepath))
diff --git a/openpype/hosts/unreal/__init__.py b/openpype/hosts/unreal/__init__.py
index 1280442916..533f315df3 100644
--- a/openpype/hosts/unreal/__init__.py
+++ b/openpype/hosts/unreal/__init__.py
@@ -1,13 +1,15 @@
import os
+import openpype.hosts
def add_implementation_envs(env, _app):
"""Modify environments to contain all required for implementation."""
- # Set AVALON_UNREAL_PLUGIN required for Unreal implementation
+ # Set OPENPYPE_UNREAL_PLUGIN required for Unreal implementation
unreal_plugin_path = os.path.join(
- os.environ["OPENPYPE_REPOS_ROOT"], "repos", "avalon-unreal-integration"
+ os.path.dirname(os.path.abspath(openpype.hosts.__file__)),
+ "unreal", "integration"
)
- env["AVALON_UNREAL_PLUGIN"] = unreal_plugin_path
+ env["OPENPYPE_UNREAL_PLUGIN"] = unreal_plugin_path
# Set default environments if are not set via settings
defaults = {
diff --git a/openpype/hosts/unreal/api/__init__.py b/openpype/hosts/unreal/api/__init__.py
index 38469e0ddb..ede71aa218 100644
--- a/openpype/hosts/unreal/api/__init__.py
+++ b/openpype/hosts/unreal/api/__init__.py
@@ -1,45 +1,40 @@
-import os
-import logging
+# -*- coding: utf-8 -*-
+"""Unreal Editor OpenPype host API."""
-from avalon import api as avalon
-from pyblish import api as pyblish
-import openpype.hosts.unreal
+from .plugin import (
+ Loader,
+ Creator
+)
+from .pipeline import (
+ install,
+ uninstall,
+ ls,
+ publish,
+ containerise,
+ show_creator,
+ show_loader,
+ show_publisher,
+ show_manager,
+ show_experimental_tools,
+ show_tools_dialog,
+ show_tools_popup,
+ instantiate,
+)
-logger = logging.getLogger("openpype.hosts.unreal")
-
-HOST_DIR = os.path.dirname(os.path.abspath(openpype.hosts.unreal.__file__))
-PLUGINS_DIR = os.path.join(HOST_DIR, "plugins")
-PUBLISH_PATH = os.path.join(PLUGINS_DIR, "publish")
-LOAD_PATH = os.path.join(PLUGINS_DIR, "load")
-CREATE_PATH = os.path.join(PLUGINS_DIR, "create")
-INVENTORY_PATH = os.path.join(PLUGINS_DIR, "inventory")
-
-
-def install():
- """Install Unreal configuration for Avalon."""
- print("-=" * 40)
- logo = '''.
-.
- ____________
- / \\ __ \\
- \\ \\ \\/_\\ \\
- \\ \\ _____/ ______
- \\ \\ \\___// \\ \\
- \\ \\____\\ \\ \\_____\\
- \\/_____/ \\/______/ PYPE Club .
-.
-'''
- print(logo)
- print("installing OpenPype for Unreal ...")
- print("-=" * 40)
- logger.info("installing OpenPype for Unreal")
- pyblish.register_plugin_path(str(PUBLISH_PATH))
- avalon.register_plugin_path(avalon.Loader, str(LOAD_PATH))
- avalon.register_plugin_path(avalon.Creator, str(CREATE_PATH))
-
-
-def uninstall():
- """Uninstall Unreal configuration for Avalon."""
- pyblish.deregister_plugin_path(str(PUBLISH_PATH))
- avalon.deregister_plugin_path(avalon.Loader, str(LOAD_PATH))
- avalon.deregister_plugin_path(avalon.Creator, str(CREATE_PATH))
+__all__ = [
+ "install",
+ "uninstall",
+ "Creator",
+ "Loader",
+ "ls",
+ "publish",
+ "containerise",
+ "show_creator",
+ "show_loader",
+ "show_publisher",
+ "show_manager",
+ "show_experimental_tools",
+ "show_tools_dialog",
+ "show_tools_popup",
+ "instantiate"
+]
diff --git a/openpype/hosts/unreal/api/helpers.py b/openpype/hosts/unreal/api/helpers.py
new file mode 100644
index 0000000000..0b6f07f52f
--- /dev/null
+++ b/openpype/hosts/unreal/api/helpers.py
@@ -0,0 +1,44 @@
+# -*- coding: utf-8 -*-
+import unreal # noqa
+
+
+class OpenPypeUnrealException(Exception):
+ pass
+
+
+@unreal.uclass()
+class OpenPypeHelpers(unreal.OpenPypeLib):
+ """Class wrapping some useful functions for OpenPype.
+
+ This class is extending native BP class in OpenPype Integration Plugin.
+
+ """
+
+ @unreal.ufunction(params=[str, unreal.LinearColor, bool])
+ def set_folder_color(self, path: str, color: unreal.LinearColor) -> None:
+ """Set color on folder in Content Browser.
+
+ This method sets color on folder in Content Browser. Unfortunately
+ there is no way to refresh Content Browser so new color isn't applied
+ immediately. They are saved to config file and appears correctly
+ only after Editor is restarted.
+
+ Args:
+ path (str): Path to folder
+ color (:class:`unreal.LinearColor`): Color of the folder
+
+ Example:
+
+ OpenPypeHelpers().set_folder_color(
+ "/Game/Path", unreal.LinearColor(a=1.0, r=1.0, g=0.5, b=0)
+ )
+
+ Note:
+ This will take effect only after Editor is restarted. I couldn't
+ find a way to refresh it. Also this saves the color definition
+ into the project config, binding this path with color. So if you
+ delete this path and later re-create, it will set this color
+ again.
+
+ """
+ self.c_set_folder_color(path, color, False)
diff --git a/openpype/hosts/unreal/api/pipeline.py b/openpype/hosts/unreal/api/pipeline.py
new file mode 100644
index 0000000000..ad64d56e9e
--- /dev/null
+++ b/openpype/hosts/unreal/api/pipeline.py
@@ -0,0 +1,413 @@
+# -*- coding: utf-8 -*-
+import os
+import logging
+from typing import List
+
+import pyblish.api
+from avalon.pipeline import AVALON_CONTAINER_ID
+from avalon import api
+
+from openpype.tools.utils import host_tools
+import openpype.hosts.unreal
+
+import unreal # noqa
+
+
+logger = logging.getLogger("openpype.hosts.unreal")
+OPENPYPE_CONTAINERS = "OpenPypeContainers"
+
+HOST_DIR = os.path.dirname(os.path.abspath(openpype.hosts.unreal.__file__))
+PLUGINS_DIR = os.path.join(HOST_DIR, "plugins")
+PUBLISH_PATH = os.path.join(PLUGINS_DIR, "publish")
+LOAD_PATH = os.path.join(PLUGINS_DIR, "load")
+CREATE_PATH = os.path.join(PLUGINS_DIR, "create")
+INVENTORY_PATH = os.path.join(PLUGINS_DIR, "inventory")
+
+
+def install():
+ """Install Unreal configuration for OpenPype."""
+ print("-=" * 40)
+ logo = '''.
+.
+ ____________
+ / \\ __ \\
+ \\ \\ \\/_\\ \\
+ \\ \\ _____/ ______
+ \\ \\ \\___// \\ \\
+ \\ \\____\\ \\ \\_____\\
+ \\/_____/ \\/______/ PYPE Club .
+.
+'''
+ print(logo)
+ print("installing OpenPype for Unreal ...")
+ print("-=" * 40)
+ logger.info("installing OpenPype for Unreal")
+ pyblish.api.register_plugin_path(str(PUBLISH_PATH))
+ api.register_plugin_path(api.Loader, str(LOAD_PATH))
+ api.register_plugin_path(api.Creator, str(CREATE_PATH))
+ _register_callbacks()
+ _register_events()
+
+
+def uninstall():
+ """Uninstall Unreal configuration for Avalon."""
+ pyblish.api.deregister_plugin_path(str(PUBLISH_PATH))
+ api.deregister_plugin_path(api.Loader, str(LOAD_PATH))
+ api.deregister_plugin_path(api.Creator, str(CREATE_PATH))
+
+
+def _register_callbacks():
+ """
+ TODO: Implement callbacks if supported by UE4
+ """
+ pass
+
+
+def _register_events():
+ """
+ TODO: Implement callbacks if supported by UE4
+ """
+ pass
+
+
+class Creator(api.Creator):
+ hosts = ["unreal"]
+ asset_types = []
+
+ def process(self):
+ nodes = list()
+
+ with unreal.ScopedEditorTransaction("OpenPype Creating Instance"):
+ if (self.options or {}).get("useSelection"):
+ self.log.info("setting ...")
+ print("settings ...")
+ nodes = unreal.EditorUtilityLibrary.get_selected_assets()
+
+ asset_paths = [a.get_path_name() for a in nodes]
+ self.name = move_assets_to_path(
+ "/Game", self.name, asset_paths
+ )
+
+ instance = create_publish_instance("/Game", self.name)
+ imprint(instance, self.data)
+
+ return instance
+
+
+def ls():
+ """List all containers.
+
+ List all found in *Content Manager* of Unreal and return
+ metadata from them. Adding `objectName` to set.
+
+ """
+ ar = unreal.AssetRegistryHelpers.get_asset_registry()
+ openpype_containers = ar.get_assets_by_class("AssetContainer", True)
+
+ # get_asset_by_class returns AssetData. To get all metadata we need to
+ # load asset. get_tag_values() work only on metadata registered in
+ # Asset Registry Project settings (and there is no way to set it with
+ # python short of editing ini configuration file).
+ for asset_data in openpype_containers:
+ asset = asset_data.get_asset()
+ data = unreal.EditorAssetLibrary.get_metadata_tag_values(asset)
+ data["objectName"] = asset_data.asset_name
+ data = cast_map_to_str_dict(data)
+
+ yield data
+
+
+def parse_container(container):
+ """To get data from container, AssetContainer must be loaded.
+
+ Args:
+ container(str): path to container
+
+ Returns:
+ dict: metadata stored on container
+ """
+ asset = unreal.EditorAssetLibrary.load_asset(container)
+ data = unreal.EditorAssetLibrary.get_metadata_tag_values(asset)
+ data["objectName"] = asset.get_name()
+ data = cast_map_to_str_dict(data)
+
+ return data
+
+
+def publish():
+ """Shorthand to publish from within host."""
+ import pyblish.util
+
+ return pyblish.util.publish()
+
+
+def containerise(name, namespace, nodes, context, loader=None, suffix="_CON"):
+ """Bundles *nodes* (assets) into a *container* and add metadata to it.
+
+ Unreal doesn't support *groups* of assets that you can add metadata to.
+ But it does support folders that helps to organize asset. Unfortunately
+ those folders are just that - you cannot add any additional information
+ to them. OpenPype Integration Plugin is providing way out - Implementing
+ `AssetContainer` Blueprint class. This class when added to folder can
+ handle metadata on it using standard
+ :func:`unreal.EditorAssetLibrary.set_metadata_tag()` and
+ :func:`unreal.EditorAssetLibrary.get_metadata_tag_values()`. It also
+ stores and monitor all changes in assets in path where it resides. List of
+ those assets is available as `assets` property.
+
+ This is list of strings starting with asset type and ending with its path:
+ `Material /Game/OpenPype/Test/TestMaterial.TestMaterial`
+
+ """
+ # 1 - create directory for container
+ root = "/Game"
+ container_name = "{}{}".format(name, suffix)
+ new_name = move_assets_to_path(root, container_name, nodes)
+
+ # 2 - create Asset Container there
+ path = "{}/{}".format(root, new_name)
+ create_container(container=container_name, path=path)
+
+ namespace = path
+
+ data = {
+ "schema": "openpype:container-2.0",
+ "id": AVALON_CONTAINER_ID,
+ "name": new_name,
+ "namespace": namespace,
+ "loader": str(loader),
+ "representation": context["representation"]["_id"],
+ }
+ # 3 - imprint data
+ imprint("{}/{}".format(path, container_name), data)
+ return path
+
+
+def instantiate(root, name, data, assets=None, suffix="_INS"):
+ """Bundles *nodes* into *container*.
+
+ Marking it with metadata as publishable instance. If assets are provided,
+ they are moved to new path where `OpenPypePublishInstance` class asset is
+ created and imprinted with metadata.
+
+ This can then be collected for publishing by Pyblish for example.
+
+ Args:
+ root (str): root path where to create instance container
+ name (str): name of the container
+ data (dict): data to imprint on container
+ assets (list of str): list of asset paths to include in publish
+ instance
+ suffix (str): suffix string to append to instance name
+
+ """
+ container_name = "{}{}".format(name, suffix)
+
+ # if we specify assets, create new folder and move them there. If not,
+ # just create empty folder
+ if assets:
+ new_name = move_assets_to_path(root, container_name, assets)
+ else:
+ new_name = create_folder(root, name)
+
+ path = "{}/{}".format(root, new_name)
+ create_publish_instance(instance=container_name, path=path)
+
+ imprint("{}/{}".format(path, container_name), data)
+
+
+def imprint(node, data):
+ loaded_asset = unreal.EditorAssetLibrary.load_asset(node)
+ for key, value in data.items():
+ # Support values evaluated at imprint
+ if callable(value):
+ value = value()
+ # Unreal doesn't support NoneType in metadata values
+ if value is None:
+ value = ""
+ unreal.EditorAssetLibrary.set_metadata_tag(
+ loaded_asset, key, str(value)
+ )
+
+ with unreal.ScopedEditorTransaction("OpenPype containerising"):
+ unreal.EditorAssetLibrary.save_asset(node)
+
+
+def show_tools_popup():
+ """Show popup with tools.
+
+ Popup will disappear on click or loosing focus.
+ """
+ from openpype.hosts.unreal.api import tools_ui
+
+ tools_ui.show_tools_popup()
+
+
+def show_tools_dialog():
+ """Show dialog with tools.
+
+ Dialog will stay visible.
+ """
+ from openpype.hosts.unreal.api import tools_ui
+
+ tools_ui.show_tools_dialog()
+
+
+def show_creator():
+ host_tools.show_creator()
+
+
+def show_loader():
+ host_tools.show_loader(use_context=True)
+
+
+def show_publisher():
+ host_tools.show_publish()
+
+
+def show_manager():
+ host_tools.show_scene_inventory()
+
+
+def show_experimental_tools():
+ host_tools.show_experimental_tools_dialog()
+
+
+def create_folder(root: str, name: str) -> str:
+ """Create new folder.
+
+ If folder exists, append number at the end and try again, incrementing
+ if needed.
+
+ Args:
+ root (str): path root
+ name (str): folder name
+
+ Returns:
+ str: folder name
+
+ Example:
+ >>> create_folder("/Game/Foo")
+ /Game/Foo
+ >>> create_folder("/Game/Foo")
+ /Game/Foo1
+
+ """
+ eal = unreal.EditorAssetLibrary
+ index = 1
+ while True:
+ if eal.does_directory_exist("{}/{}".format(root, name)):
+ name = "{}{}".format(name, index)
+ index += 1
+ else:
+ eal.make_directory("{}/{}".format(root, name))
+ break
+
+ return name
+
+
+def move_assets_to_path(root: str, name: str, assets: List[str]) -> str:
+ """Moving (renaming) list of asset paths to new destination.
+
+ Args:
+ root (str): root of the path (eg. `/Game`)
+ name (str): name of destination directory (eg. `Foo` )
+ assets (list of str): list of asset paths
+
+ Returns:
+ str: folder name
+
+ Example:
+ This will get paths of all assets under `/Game/Test` and move them
+ to `/Game/NewTest`. If `/Game/NewTest` already exists, then resulting
+ path will be `/Game/NewTest1`
+
+ >>> assets = unreal.EditorAssetLibrary.list_assets("/Game/Test")
+ >>> move_assets_to_path("/Game", "NewTest", assets)
+ NewTest
+
+ """
+ eal = unreal.EditorAssetLibrary
+ name = create_folder(root, name)
+
+ unreal.log(assets)
+ for asset in assets:
+ loaded = eal.load_asset(asset)
+ eal.rename_asset(
+ asset, "{}/{}/{}".format(root, name, loaded.get_name())
+ )
+
+ return name
+
+
+def create_container(container: str, path: str) -> unreal.Object:
+ """Helper function to create Asset Container class on given path.
+
+ This Asset Class helps to mark given path as Container
+ and enable asset version control on it.
+
+ Args:
+ container (str): Asset Container name
+ path (str): Path where to create Asset Container. This path should
+ point into container folder
+
+ Returns:
+ :class:`unreal.Object`: instance of created asset
+
+ Example:
+
+ create_container(
+ "/Game/modelingFooCharacter_CON",
+ "modelingFooCharacter_CON"
+ )
+
+ """
+ factory = unreal.AssetContainerFactory()
+ tools = unreal.AssetToolsHelpers().get_asset_tools()
+
+ asset = tools.create_asset(container, path, None, factory)
+ return asset
+
+
+def create_publish_instance(instance: str, path: str) -> unreal.Object:
+ """Helper function to create OpenPype Publish Instance on given path.
+
+ This behaves similarly as :func:`create_openpype_container`.
+
+ Args:
+ path (str): Path where to create Publish Instance.
+ This path should point into container folder
+ instance (str): Publish Instance name
+
+ Returns:
+ :class:`unreal.Object`: instance of created asset
+
+ Example:
+
+ create_publish_instance(
+ "/Game/modelingFooCharacter_INST",
+ "modelingFooCharacter_INST"
+ )
+
+ """
+ factory = unreal.OpenPypePublishInstanceFactory()
+ tools = unreal.AssetToolsHelpers().get_asset_tools()
+ asset = tools.create_asset(instance, path, None, factory)
+ return asset
+
+
+def cast_map_to_str_dict(umap) -> dict:
+ """Cast Unreal Map to dict.
+
+ Helper function to cast Unreal Map object to plain old python
+ dict. This will also cast values and keys to str. Useful for
+ metadata dicts.
+
+ Args:
+ umap: Unreal Map object
+
+ Returns:
+ dict
+
+ """
+ return {str(key): str(value) for (key, value) in umap.items()}
diff --git a/openpype/hosts/unreal/api/plugin.py b/openpype/hosts/unreal/api/plugin.py
index 5a6b236730..2327fc09c8 100644
--- a/openpype/hosts/unreal/api/plugin.py
+++ b/openpype/hosts/unreal/api/plugin.py
@@ -1,5 +1,8 @@
-from avalon import api
+# -*- coding: utf-8 -*-
+from abc import ABC
+
import openpype.api
+import avalon.api
class Creator(openpype.api.Creator):
@@ -7,6 +10,6 @@ class Creator(openpype.api.Creator):
defaults = ['Main']
-class Loader(api.Loader):
+class Loader(avalon.api.Loader, ABC):
"""This serves as skeleton for future OpenPype specific functionality"""
pass
diff --git a/openpype/hosts/unreal/hooks/pre_workfile_preparation.py b/openpype/hosts/unreal/hooks/pre_workfile_preparation.py
index 880dba5cfb..f07e96551c 100644
--- a/openpype/hosts/unreal/hooks/pre_workfile_preparation.py
+++ b/openpype/hosts/unreal/hooks/pre_workfile_preparation.py
@@ -10,7 +10,7 @@ from openpype.lib import (
get_workdir_data,
get_workfile_template_key
)
-from openpype.hosts.unreal.api import lib as unreal_lib
+import openpype.hosts.unreal.lib as unreal_lib
class UnrealPrelaunchHook(PreLaunchHook):
@@ -136,9 +136,9 @@ class UnrealPrelaunchHook(PreLaunchHook):
f"{self.signature} creating unreal "
f"project [ {unreal_project_name} ]"
))
- # Set "AVALON_UNREAL_PLUGIN" to current process environment for
+ # Set "OPENPYPE_UNREAL_PLUGIN" to current process environment for
# execution of `create_unreal_project`
- env_key = "AVALON_UNREAL_PLUGIN"
+ env_key = "OPENPYPE_UNREAL_PLUGIN"
if self.launch_context.env.get(env_key):
os.environ[env_key] = self.launch_context.env[env_key]
diff --git a/openpype/hosts/unreal/integration/.gitignore b/openpype/hosts/unreal/integration/.gitignore
new file mode 100644
index 0000000000..b32a6f55e5
--- /dev/null
+++ b/openpype/hosts/unreal/integration/.gitignore
@@ -0,0 +1,35 @@
+# Prerequisites
+*.d
+
+# Compiled Object files
+*.slo
+*.lo
+*.o
+*.obj
+
+# Precompiled Headers
+*.gch
+*.pch
+
+# Compiled Dynamic libraries
+*.so
+*.dylib
+*.dll
+
+# Fortran module files
+*.mod
+*.smod
+
+# Compiled Static libraries
+*.lai
+*.la
+*.a
+*.lib
+
+# Executables
+*.exe
+*.out
+*.app
+
+/Binaries
+/Intermediate
diff --git a/openpype/hosts/unreal/integration/Content/Python/init_unreal.py b/openpype/hosts/unreal/integration/Content/Python/init_unreal.py
new file mode 100644
index 0000000000..2ecd301c25
--- /dev/null
+++ b/openpype/hosts/unreal/integration/Content/Python/init_unreal.py
@@ -0,0 +1,34 @@
+import unreal
+
+openpype_detected = True
+try:
+ from avalon import api
+except ImportError as exc:
+ api = None
+ openpype_detected = False
+ unreal.log_error("Avalon: cannot load Avalon [ {} ]".format(exc))
+
+try:
+ from openpype.hosts.unreal import api as openpype_host
+except ImportError as exc:
+ openpype_host = None
+ openpype_detected = False
+ unreal.log_error("OpenPype: cannot load OpenPype [ {} ]".format(exc))
+
+if openpype_detected:
+ api.install(openpype_host)
+
+
+@unreal.uclass()
+class OpenPypeIntegration(unreal.OpenPypePythonBridge):
+ @unreal.ufunction(override=True)
+ def RunInPython_Popup(self):
+ unreal.log_warning("OpenPype: showing tools popup")
+ if openpype_detected:
+ openpype_host.show_tools_popup()
+
+ @unreal.ufunction(override=True)
+ def RunInPython_Dialog(self):
+ unreal.log_warning("OpenPype: showing tools dialog")
+ if openpype_detected:
+ openpype_host.show_tools_dialog()
diff --git a/openpype/hosts/unreal/integration/OpenPype.uplugin b/openpype/hosts/unreal/integration/OpenPype.uplugin
new file mode 100644
index 0000000000..4c7a74403c
--- /dev/null
+++ b/openpype/hosts/unreal/integration/OpenPype.uplugin
@@ -0,0 +1,24 @@
+{
+ "FileVersion": 3,
+ "Version": 1,
+ "VersionName": "1.0",
+ "FriendlyName": "OpenPype",
+ "Description": "OpenPype Integration",
+ "Category": "OpenPype.Integration",
+ "CreatedBy": "Ondrej Samohel",
+ "CreatedByURL": "https://openpype.io",
+ "DocsURL": "https://openpype.io/docs/artist_hosts_unreal",
+ "MarketplaceURL": "",
+ "SupportURL": "https://pype.club/",
+ "CanContainContent": true,
+ "IsBetaVersion": true,
+ "IsExperimentalVersion": false,
+ "Installed": false,
+ "Modules": [
+ {
+ "Name": "OpenPype",
+ "Type": "Editor",
+ "LoadingPhase": "Default"
+ }
+ ]
+}
\ No newline at end of file
diff --git a/openpype/hosts/unreal/integration/README.md b/openpype/hosts/unreal/integration/README.md
new file mode 100644
index 0000000000..a32d89aab8
--- /dev/null
+++ b/openpype/hosts/unreal/integration/README.md
@@ -0,0 +1,11 @@
+# OpenPype Unreal Integration plugin
+
+This is plugin for Unreal Editor, creating menu for [OpenPype](https://github.com/getavalon) tools to run.
+
+## How does this work
+
+Plugin is creating basic menu items in **Window/OpenPype** section of Unreal Editor main menu and a button
+on the main toolbar with associated menu. Clicking on those menu items is calling callbacks that are
+declared in c++ but needs to be implemented during Unreal Editor
+startup in `Plugins/OpenPype/Content/Python/init_unreal.py` - this should be executed by Unreal Editor
+automatically.
diff --git a/openpype/hosts/unreal/integration/Resources/openpype128.png b/openpype/hosts/unreal/integration/Resources/openpype128.png
new file mode 100644
index 0000000000..abe8a807ef
Binary files /dev/null and b/openpype/hosts/unreal/integration/Resources/openpype128.png differ
diff --git a/openpype/hosts/unreal/integration/Resources/openpype40.png b/openpype/hosts/unreal/integration/Resources/openpype40.png
new file mode 100644
index 0000000000..f983e7a1f2
Binary files /dev/null and b/openpype/hosts/unreal/integration/Resources/openpype40.png differ
diff --git a/openpype/hosts/unreal/integration/Resources/openpype512.png b/openpype/hosts/unreal/integration/Resources/openpype512.png
new file mode 100644
index 0000000000..97c4d4326b
Binary files /dev/null and b/openpype/hosts/unreal/integration/Resources/openpype512.png differ
diff --git a/openpype/hosts/unreal/integration/Source/OpenPype/OpenPype.Build.cs b/openpype/hosts/unreal/integration/Source/OpenPype/OpenPype.Build.cs
new file mode 100644
index 0000000000..c30835b63d
--- /dev/null
+++ b/openpype/hosts/unreal/integration/Source/OpenPype/OpenPype.Build.cs
@@ -0,0 +1,57 @@
+// Copyright 1998-2019 Epic Games, Inc. All Rights Reserved.
+
+using UnrealBuildTool;
+
+public class OpenPype : ModuleRules
+{
+ public OpenPype(ReadOnlyTargetRules Target) : base(Target)
+ {
+ PCHUsage = ModuleRules.PCHUsageMode.UseExplicitOrSharedPCHs;
+
+ PublicIncludePaths.AddRange(
+ new string[] {
+ // ... add public include paths required here ...
+ }
+ );
+
+
+ PrivateIncludePaths.AddRange(
+ new string[] {
+ // ... add other private include paths required here ...
+ }
+ );
+
+
+ PublicDependencyModuleNames.AddRange(
+ new string[]
+ {
+ "Core",
+ // ... add other public dependencies that you statically link with here ...
+ }
+ );
+
+
+ PrivateDependencyModuleNames.AddRange(
+ new string[]
+ {
+ "Projects",
+ "InputCore",
+ "UnrealEd",
+ "LevelEditor",
+ "CoreUObject",
+ "Engine",
+ "Slate",
+ "SlateCore",
+ // ... add private dependencies that you statically link with here ...
+ }
+ );
+
+
+ DynamicallyLoadedModuleNames.AddRange(
+ new string[]
+ {
+ // ... add any modules that your module loads dynamically here ...
+ }
+ );
+ }
+}
diff --git a/openpype/hosts/unreal/integration/Source/OpenPype/Private/AssetContainer.cpp b/openpype/hosts/unreal/integration/Source/OpenPype/Private/AssetContainer.cpp
new file mode 100644
index 0000000000..c766f87a8e
--- /dev/null
+++ b/openpype/hosts/unreal/integration/Source/OpenPype/Private/AssetContainer.cpp
@@ -0,0 +1,115 @@
+// Fill out your copyright notice in the Description page of Project Settings.
+
+#include "AssetContainer.h"
+#include "AssetRegistryModule.h"
+#include "Misc/PackageName.h"
+#include "Engine.h"
+#include "Containers/UnrealString.h"
+
+UAssetContainer::UAssetContainer(const FObjectInitializer& ObjectInitializer)
+: UAssetUserData(ObjectInitializer)
+{
+ FAssetRegistryModule& AssetRegistryModule = FModuleManager::LoadModuleChecked("AssetRegistry");
+ FString path = UAssetContainer::GetPathName();
+ UE_LOG(LogTemp, Warning, TEXT("UAssetContainer %s"), *path);
+ FARFilter Filter;
+ Filter.PackagePaths.Add(FName(*path));
+
+ AssetRegistryModule.Get().OnAssetAdded().AddUObject(this, &UAssetContainer::OnAssetAdded);
+ AssetRegistryModule.Get().OnAssetRemoved().AddUObject(this, &UAssetContainer::OnAssetRemoved);
+ AssetRegistryModule.Get().OnAssetRenamed().AddUObject(this, &UAssetContainer::OnAssetRenamed);
+}
+
+void UAssetContainer::OnAssetAdded(const FAssetData& AssetData)
+{
+ TArray split;
+
+ // get directory of current container
+ FString selfFullPath = UAssetContainer::GetPathName();
+ FString selfDir = FPackageName::GetLongPackagePath(*selfFullPath);
+
+ // get asset path and class
+ FString assetPath = AssetData.GetFullName();
+ FString assetFName = AssetData.AssetClass.ToString();
+
+ // split path
+ assetPath.ParseIntoArray(split, TEXT(" "), true);
+
+ FString assetDir = FPackageName::GetLongPackagePath(*split[1]);
+
+ // take interest only in paths starting with path of current container
+ if (assetDir.StartsWith(*selfDir))
+ {
+ // exclude self
+ if (assetFName != "AssetContainer")
+ {
+ assets.Add(assetPath);
+ assetsData.Add(AssetData);
+ UE_LOG(LogTemp, Log, TEXT("%s: asset added to %s"), *selfFullPath, *selfDir);
+ }
+ }
+}
+
+void UAssetContainer::OnAssetRemoved(const FAssetData& AssetData)
+{
+ TArray split;
+
+ // get directory of current container
+ FString selfFullPath = UAssetContainer::GetPathName();
+ FString selfDir = FPackageName::GetLongPackagePath(*selfFullPath);
+
+ // get asset path and class
+ FString assetPath = AssetData.GetFullName();
+ FString assetFName = AssetData.AssetClass.ToString();
+
+ // split path
+ assetPath.ParseIntoArray(split, TEXT(" "), true);
+
+ FString assetDir = FPackageName::GetLongPackagePath(*split[1]);
+
+ // take interest only in paths starting with path of current container
+ FString path = UAssetContainer::GetPathName();
+ FString lpp = FPackageName::GetLongPackagePath(*path);
+
+ if (assetDir.StartsWith(*selfDir))
+ {
+ // exclude self
+ if (assetFName != "AssetContainer")
+ {
+ // UE_LOG(LogTemp, Warning, TEXT("%s: asset removed"), *lpp);
+ assets.Remove(assetPath);
+ assetsData.Remove(AssetData);
+ }
+ }
+}
+
+void UAssetContainer::OnAssetRenamed(const FAssetData& AssetData, const FString& str)
+{
+ TArray split;
+
+ // get directory of current container
+ FString selfFullPath = UAssetContainer::GetPathName();
+ FString selfDir = FPackageName::GetLongPackagePath(*selfFullPath);
+
+ // get asset path and class
+ FString assetPath = AssetData.GetFullName();
+ FString assetFName = AssetData.AssetClass.ToString();
+
+ // split path
+ assetPath.ParseIntoArray(split, TEXT(" "), true);
+
+ FString assetDir = FPackageName::GetLongPackagePath(*split[1]);
+ if (assetDir.StartsWith(*selfDir))
+ {
+ // exclude self
+ if (assetFName != "AssetContainer")
+ {
+
+ assets.Remove(str);
+ assets.Add(assetPath);
+ assetsData.Remove(AssetData);
+ // UE_LOG(LogTemp, Warning, TEXT("%s: asset renamed %s"), *lpp, *str);
+ }
+ }
+}
+
diff --git a/openpype/hosts/unreal/integration/Source/OpenPype/Private/AssetContainerFactory.cpp b/openpype/hosts/unreal/integration/Source/OpenPype/Private/AssetContainerFactory.cpp
new file mode 100644
index 0000000000..b943150bdd
--- /dev/null
+++ b/openpype/hosts/unreal/integration/Source/OpenPype/Private/AssetContainerFactory.cpp
@@ -0,0 +1,20 @@
+#include "AssetContainerFactory.h"
+#include "AssetContainer.h"
+
+UAssetContainerFactory::UAssetContainerFactory(const FObjectInitializer& ObjectInitializer)
+ : UFactory(ObjectInitializer)
+{
+ SupportedClass = UAssetContainer::StaticClass();
+ bCreateNew = false;
+ bEditorImport = true;
+}
+
+UObject* UAssetContainerFactory::FactoryCreateNew(UClass* Class, UObject* InParent, FName Name, EObjectFlags Flags, UObject* Context, FFeedbackContext* Warn)
+{
+ UAssetContainer* AssetContainer = NewObject(InParent, Class, Name, Flags);
+ return AssetContainer;
+}
+
+bool UAssetContainerFactory::ShouldShowInNewMenu() const {
+ return false;
+}
diff --git a/openpype/hosts/unreal/integration/Source/OpenPype/Private/OpenPype.cpp b/openpype/hosts/unreal/integration/Source/OpenPype/Private/OpenPype.cpp
new file mode 100644
index 0000000000..15c46b3862
--- /dev/null
+++ b/openpype/hosts/unreal/integration/Source/OpenPype/Private/OpenPype.cpp
@@ -0,0 +1,103 @@
+#include "OpenPype.h"
+#include "LevelEditor.h"
+#include "OpenPypePythonBridge.h"
+#include "OpenPypeStyle.h"
+
+
+static const FName OpenPypeTabName("OpenPype");
+
+#define LOCTEXT_NAMESPACE "FOpenPypeModule"
+
+// This function is triggered when the plugin is staring up
+void FOpenPypeModule::StartupModule()
+{
+
+ FOpenPypeStyle::Initialize();
+ FOpenPypeStyle::SetIcon("Logo", "openpype40");
+
+ // Create the Extender that will add content to the menu
+ FLevelEditorModule& LevelEditorModule = FModuleManager::LoadModuleChecked("LevelEditor");
+
+ TSharedPtr MenuExtender = MakeShareable(new FExtender());
+ TSharedPtr ToolbarExtender = MakeShareable(new FExtender());
+
+ MenuExtender->AddMenuExtension(
+ "LevelEditor",
+ EExtensionHook::After,
+ NULL,
+ FMenuExtensionDelegate::CreateRaw(this, &FOpenPypeModule::AddMenuEntry)
+ );
+ ToolbarExtender->AddToolBarExtension(
+ "Settings",
+ EExtensionHook::After,
+ NULL,
+ FToolBarExtensionDelegate::CreateRaw(this, &FOpenPypeModule::AddToobarEntry));
+
+
+ LevelEditorModule.GetMenuExtensibilityManager()->AddExtender(MenuExtender);
+ LevelEditorModule.GetToolBarExtensibilityManager()->AddExtender(ToolbarExtender);
+
+}
+
+void FOpenPypeModule::ShutdownModule()
+{
+ FOpenPypeStyle::Shutdown();
+}
+
+
+void FOpenPypeModule::AddMenuEntry(FMenuBuilder& MenuBuilder)
+{
+ // Create Section
+ MenuBuilder.BeginSection("OpenPype", TAttribute(FText::FromString("OpenPype")));
+ {
+ // Create a Submenu inside of the Section
+ MenuBuilder.AddMenuEntry(
+ FText::FromString("Tools..."),
+ FText::FromString("Pipeline tools"),
+ FSlateIcon(FOpenPypeStyle::GetStyleSetName(), "OpenPype.Logo"),
+ FUIAction(FExecuteAction::CreateRaw(this, &FOpenPypeModule::MenuPopup))
+ );
+
+ MenuBuilder.AddMenuEntry(
+ FText::FromString("Tools dialog..."),
+ FText::FromString("Pipeline tools dialog"),
+ FSlateIcon(FOpenPypeStyle::GetStyleSetName(), "OpenPype.Logo"),
+ FUIAction(FExecuteAction::CreateRaw(this, &FOpenPypeModule::MenuDialog))
+ );
+
+ }
+ MenuBuilder.EndSection();
+}
+
+void FOpenPypeModule::AddToobarEntry(FToolBarBuilder& ToolbarBuilder)
+{
+ ToolbarBuilder.BeginSection(TEXT("OpenPype"));
+ {
+ ToolbarBuilder.AddToolBarButton(
+ FUIAction(
+ FExecuteAction::CreateRaw(this, &FOpenPypeModule::MenuPopup),
+ NULL,
+ FIsActionChecked()
+
+ ),
+ NAME_None,
+ LOCTEXT("OpenPype_label", "OpenPype"),
+ LOCTEXT("OpenPype_tooltip", "OpenPype Tools"),
+ FSlateIcon(FOpenPypeStyle::GetStyleSetName(), "OpenPype.Logo")
+ );
+ }
+ ToolbarBuilder.EndSection();
+}
+
+
+void FOpenPypeModule::MenuPopup() {
+ UOpenPypePythonBridge* bridge = UOpenPypePythonBridge::Get();
+ bridge->RunInPython_Popup();
+}
+
+void FOpenPypeModule::MenuDialog() {
+ UOpenPypePythonBridge* bridge = UOpenPypePythonBridge::Get();
+ bridge->RunInPython_Dialog();
+}
+
+IMPLEMENT_MODULE(FOpenPypeModule, OpenPype)
diff --git a/openpype/hosts/unreal/integration/Source/OpenPype/Private/OpenPypeLib.cpp b/openpype/hosts/unreal/integration/Source/OpenPype/Private/OpenPypeLib.cpp
new file mode 100644
index 0000000000..5facab7b8b
--- /dev/null
+++ b/openpype/hosts/unreal/integration/Source/OpenPype/Private/OpenPypeLib.cpp
@@ -0,0 +1,48 @@
+#include "OpenPypeLib.h"
+#include "Misc/Paths.h"
+#include "Misc/ConfigCacheIni.h"
+#include "UObject/UnrealType.h"
+
+/**
+ * Sets color on folder icon on given path
+ * @param InPath - path to folder
+ * @param InFolderColor - color of the folder
+ * @warning This color will appear only after Editor restart. Is there a better way?
+ */
+
+void UOpenPypeLib::CSetFolderColor(FString FolderPath, FLinearColor FolderColor, bool bForceAdd)
+{
+ auto SaveColorInternal = [](FString InPath, FLinearColor InFolderColor)
+ {
+ // Saves the color of the folder to the config
+ if (FPaths::FileExists(GEditorPerProjectIni))
+ {
+ GConfig->SetString(TEXT("PathColor"), *InPath, *InFolderColor.ToString(), GEditorPerProjectIni);
+ }
+
+ };
+
+ SaveColorInternal(FolderPath, FolderColor);
+
+}
+/**
+ * Returns all poperties on given object
+ * @param cls - class
+ * @return TArray of properties
+ */
+TArray UOpenPypeLib::GetAllProperties(UClass* cls)
+{
+ TArray Ret;
+ if (cls != nullptr)
+ {
+ for (TFieldIterator It(cls); It; ++It)
+ {
+ FProperty* Property = *It;
+ if (Property->HasAnyPropertyFlags(EPropertyFlags::CPF_Edit))
+ {
+ Ret.Add(Property->GetName());
+ }
+ }
+ }
+ return Ret;
+}
diff --git a/openpype/hosts/unreal/integration/Source/OpenPype/Private/OpenPypePublishInstance.cpp b/openpype/hosts/unreal/integration/Source/OpenPype/Private/OpenPypePublishInstance.cpp
new file mode 100644
index 0000000000..4f1e846c0b
--- /dev/null
+++ b/openpype/hosts/unreal/integration/Source/OpenPype/Private/OpenPypePublishInstance.cpp
@@ -0,0 +1,108 @@
+#pragma once
+
+#include "OpenPypePublishInstance.h"
+#include "AssetRegistryModule.h"
+
+
+UOpenPypePublishInstance::UOpenPypePublishInstance(const FObjectInitializer& ObjectInitializer)
+ : UObject(ObjectInitializer)
+{
+ FAssetRegistryModule& AssetRegistryModule = FModuleManager::LoadModuleChecked("AssetRegistry");
+ FString path = UOpenPypePublishInstance::GetPathName();
+ FARFilter Filter;
+ Filter.PackagePaths.Add(FName(*path));
+
+ AssetRegistryModule.Get().OnAssetAdded().AddUObject(this, &UOpenPypePublishInstance::OnAssetAdded);
+ AssetRegistryModule.Get().OnAssetRemoved().AddUObject(this, &UOpenPypePublishInstance::OnAssetRemoved);
+ AssetRegistryModule.Get().OnAssetRenamed().AddUObject(this, &UOpenPypePublishInstance::OnAssetRenamed);
+}
+
+void UOpenPypePublishInstance::OnAssetAdded(const FAssetData& AssetData)
+{
+ TArray split;
+
+ // get directory of current container
+ FString selfFullPath = UOpenPypePublishInstance::GetPathName();
+ FString selfDir = FPackageName::GetLongPackagePath(*selfFullPath);
+
+ // get asset path and class
+ FString assetPath = AssetData.GetFullName();
+ FString assetFName = AssetData.AssetClass.ToString();
+
+ // split path
+ assetPath.ParseIntoArray(split, TEXT(" "), true);
+
+ FString assetDir = FPackageName::GetLongPackagePath(*split[1]);
+
+ // take interest only in paths starting with path of current container
+ if (assetDir.StartsWith(*selfDir))
+ {
+ // exclude self
+ if (assetFName != "OpenPypePublishInstance")
+ {
+ assets.Add(assetPath);
+ UE_LOG(LogTemp, Log, TEXT("%s: asset added to %s"), *selfFullPath, *selfDir);
+ }
+ }
+}
+
+void UOpenPypePublishInstance::OnAssetRemoved(const FAssetData& AssetData)
+{
+ TArray split;
+
+ // get directory of current container
+ FString selfFullPath = UOpenPypePublishInstance::GetPathName();
+ FString selfDir = FPackageName::GetLongPackagePath(*selfFullPath);
+
+ // get asset path and class
+ FString assetPath = AssetData.GetFullName();
+ FString assetFName = AssetData.AssetClass.ToString();
+
+ // split path
+ assetPath.ParseIntoArray(split, TEXT(" "), true);
+
+ FString assetDir = FPackageName::GetLongPackagePath(*split[1]);
+
+ // take interest only in paths starting with path of current container
+ FString path = UOpenPypePublishInstance::GetPathName();
+ FString lpp = FPackageName::GetLongPackagePath(*path);
+
+ if (assetDir.StartsWith(*selfDir))
+ {
+ // exclude self
+ if (assetFName != "OpenPypePublishInstance")
+ {
+ // UE_LOG(LogTemp, Warning, TEXT("%s: asset removed"), *lpp);
+ assets.Remove(assetPath);
+ }
+ }
+}
+
+void UOpenPypePublishInstance::OnAssetRenamed(const FAssetData& AssetData, const FString& str)
+{
+ TArray split;
+
+ // get directory of current container
+ FString selfFullPath = UOpenPypePublishInstance::GetPathName();
+ FString selfDir = FPackageName::GetLongPackagePath(*selfFullPath);
+
+ // get asset path and class
+ FString assetPath = AssetData.GetFullName();
+ FString assetFName = AssetData.AssetClass.ToString();
+
+ // split path
+ assetPath.ParseIntoArray(split, TEXT(" "), true);
+
+ FString assetDir = FPackageName::GetLongPackagePath(*split[1]);
+ if (assetDir.StartsWith(*selfDir))
+ {
+ // exclude self
+ if (assetFName != "AssetContainer")
+ {
+
+ assets.Remove(str);
+ assets.Add(assetPath);
+ // UE_LOG(LogTemp, Warning, TEXT("%s: asset renamed %s"), *lpp, *str);
+ }
+ }
+}
diff --git a/openpype/hosts/unreal/integration/Source/OpenPype/Private/OpenPypePublishInstanceFactory.cpp b/openpype/hosts/unreal/integration/Source/OpenPype/Private/OpenPypePublishInstanceFactory.cpp
new file mode 100644
index 0000000000..e61964c689
--- /dev/null
+++ b/openpype/hosts/unreal/integration/Source/OpenPype/Private/OpenPypePublishInstanceFactory.cpp
@@ -0,0 +1,20 @@
+#include "OpenPypePublishInstanceFactory.h"
+#include "OpenPypePublishInstance.h"
+
+UOpenPypePublishInstanceFactory::UOpenPypePublishInstanceFactory(const FObjectInitializer& ObjectInitializer)
+ : UFactory(ObjectInitializer)
+{
+ SupportedClass = UOpenPypePublishInstance::StaticClass();
+ bCreateNew = false;
+ bEditorImport = true;
+}
+
+UObject* UOpenPypePublishInstanceFactory::FactoryCreateNew(UClass* Class, UObject* InParent, FName Name, EObjectFlags Flags, UObject* Context, FFeedbackContext* Warn)
+{
+ UOpenPypePublishInstance* OpenPypePublishInstance = NewObject(InParent, Class, Name, Flags);
+ return OpenPypePublishInstance;
+}
+
+bool UOpenPypePublishInstanceFactory::ShouldShowInNewMenu() const {
+ return false;
+}
diff --git a/openpype/hosts/unreal/integration/Source/OpenPype/Private/OpenPypePythonBridge.cpp b/openpype/hosts/unreal/integration/Source/OpenPype/Private/OpenPypePythonBridge.cpp
new file mode 100644
index 0000000000..8113231503
--- /dev/null
+++ b/openpype/hosts/unreal/integration/Source/OpenPype/Private/OpenPypePythonBridge.cpp
@@ -0,0 +1,13 @@
+#include "OpenPypePythonBridge.h"
+
+UOpenPypePythonBridge* UOpenPypePythonBridge::Get()
+{
+ TArray OpenPypePythonBridgeClasses;
+ GetDerivedClasses(UOpenPypePythonBridge::StaticClass(), OpenPypePythonBridgeClasses);
+ int32 NumClasses = OpenPypePythonBridgeClasses.Num();
+ if (NumClasses > 0)
+ {
+ return Cast(OpenPypePythonBridgeClasses[NumClasses - 1]->GetDefaultObject());
+ }
+ return nullptr;
+};
\ No newline at end of file
diff --git a/openpype/hosts/unreal/integration/Source/OpenPype/Private/OpenPypeStyle.cpp b/openpype/hosts/unreal/integration/Source/OpenPype/Private/OpenPypeStyle.cpp
new file mode 100644
index 0000000000..a51c2d6aa5
--- /dev/null
+++ b/openpype/hosts/unreal/integration/Source/OpenPype/Private/OpenPypeStyle.cpp
@@ -0,0 +1,70 @@
+#include "OpenPypeStyle.h"
+#include "Framework/Application/SlateApplication.h"
+#include "Styling/SlateStyle.h"
+#include "Styling/SlateStyleRegistry.h"
+
+
+TUniquePtr< FSlateStyleSet > FOpenPypeStyle::OpenPypeStyleInstance = nullptr;
+
+void FOpenPypeStyle::Initialize()
+{
+ if (!OpenPypeStyleInstance.IsValid())
+ {
+ OpenPypeStyleInstance = Create();
+ FSlateStyleRegistry::RegisterSlateStyle(*OpenPypeStyleInstance);
+ }
+}
+
+void FOpenPypeStyle::Shutdown()
+{
+ if (OpenPypeStyleInstance.IsValid())
+ {
+ FSlateStyleRegistry::UnRegisterSlateStyle(*OpenPypeStyleInstance);
+ OpenPypeStyleInstance.Reset();
+ }
+}
+
+FName FOpenPypeStyle::GetStyleSetName()
+{
+ static FName StyleSetName(TEXT("OpenPypeStyle"));
+ return StyleSetName;
+}
+
+FName FOpenPypeStyle::GetContextName()
+{
+ static FName ContextName(TEXT("OpenPype"));
+ return ContextName;
+}
+
+#define IMAGE_BRUSH(RelativePath, ...) FSlateImageBrush( Style->RootToContentDir( RelativePath, TEXT(".png") ), __VA_ARGS__ )
+
+const FVector2D Icon40x40(40.0f, 40.0f);
+
+TUniquePtr< FSlateStyleSet > FOpenPypeStyle::Create()
+{
+ TUniquePtr< FSlateStyleSet > Style = MakeUnique(GetStyleSetName());
+ Style->SetContentRoot(FPaths::ProjectPluginsDir() / TEXT("OpenPype/Resources"));
+
+ return Style;
+}
+
+void FOpenPypeStyle::SetIcon(const FString& StyleName, const FString& ResourcePath)
+{
+ FSlateStyleSet* Style = OpenPypeStyleInstance.Get();
+
+ FString Name(GetContextName().ToString());
+ Name = Name + "." + StyleName;
+ Style->Set(*Name, new FSlateImageBrush(Style->RootToContentDir(ResourcePath, TEXT(".png")), Icon40x40));
+
+
+ FSlateApplication::Get().GetRenderer()->ReloadTextureResources();
+}
+
+#undef IMAGE_BRUSH
+
+const ISlateStyle& FOpenPypeStyle::Get()
+{
+ check(OpenPypeStyleInstance);
+ return *OpenPypeStyleInstance;
+ return *OpenPypeStyleInstance;
+}
diff --git a/openpype/hosts/unreal/integration/Source/OpenPype/Public/AssetContainer.h b/openpype/hosts/unreal/integration/Source/OpenPype/Public/AssetContainer.h
new file mode 100644
index 0000000000..3c2a360c78
--- /dev/null
+++ b/openpype/hosts/unreal/integration/Source/OpenPype/Public/AssetContainer.h
@@ -0,0 +1,39 @@
+// Fill out your copyright notice in the Description page of Project Settings.
+
+#pragma once
+
+#include "CoreMinimal.h"
+#include "UObject/NoExportTypes.h"
+#include "Engine/AssetUserData.h"
+#include "AssetData.h"
+#include "AssetContainer.generated.h"
+
+/**
+ *
+ */
+UCLASS(Blueprintable)
+class OPENPYPE_API UAssetContainer : public UAssetUserData
+{
+ GENERATED_BODY()
+
+public:
+
+ UAssetContainer(const FObjectInitializer& ObjectInitalizer);
+ // ~UAssetContainer();
+
+ UPROPERTY(EditAnywhere, BlueprintReadOnly)
+ TArray assets;
+
+ // There seems to be no reflection option to expose array of FAssetData
+ /*
+ UPROPERTY(Transient, BlueprintReadOnly, Category = "Python", meta=(DisplayName="Assets Data"))
+ TArray assetsData;
+ */
+private:
+ TArray assetsData;
+ void OnAssetAdded(const FAssetData& AssetData);
+ void OnAssetRemoved(const FAssetData& AssetData);
+ void OnAssetRenamed(const FAssetData& AssetData, const FString& str);
+};
+
+
diff --git a/openpype/hosts/unreal/integration/Source/OpenPype/Public/AssetContainerFactory.h b/openpype/hosts/unreal/integration/Source/OpenPype/Public/AssetContainerFactory.h
new file mode 100644
index 0000000000..331ce6bb50
--- /dev/null
+++ b/openpype/hosts/unreal/integration/Source/OpenPype/Public/AssetContainerFactory.h
@@ -0,0 +1,21 @@
+// Fill out your copyright notice in the Description page of Project Settings.
+
+#pragma once
+
+#include "CoreMinimal.h"
+#include "Factories/Factory.h"
+#include "AssetContainerFactory.generated.h"
+
+/**
+ *
+ */
+UCLASS()
+class OPENPYPE_API UAssetContainerFactory : public UFactory
+{
+ GENERATED_BODY()
+
+public:
+ UAssetContainerFactory(const FObjectInitializer& ObjectInitializer);
+ virtual UObject* FactoryCreateNew(UClass* Class, UObject* InParent, FName Name, EObjectFlags Flags, UObject* Context, FFeedbackContext* Warn) override;
+ virtual bool ShouldShowInNewMenu() const override;
+};
\ No newline at end of file
diff --git a/openpype/hosts/unreal/integration/Source/OpenPype/Public/OpenPype.h b/openpype/hosts/unreal/integration/Source/OpenPype/Public/OpenPype.h
new file mode 100644
index 0000000000..db3f299354
--- /dev/null
+++ b/openpype/hosts/unreal/integration/Source/OpenPype/Public/OpenPype.h
@@ -0,0 +1,21 @@
+// Copyright 1998-2019 Epic Games, Inc. All Rights Reserved.
+
+#pragma once
+
+#include "Engine.h"
+
+
+class FOpenPypeModule : public IModuleInterface
+{
+public:
+ virtual void StartupModule() override;
+ virtual void ShutdownModule() override;
+
+private:
+
+ void AddMenuEntry(FMenuBuilder& MenuBuilder);
+ void AddToobarEntry(FToolBarBuilder& ToolbarBuilder);
+ void MenuPopup();
+ void MenuDialog();
+
+};
diff --git a/openpype/hosts/unreal/integration/Source/OpenPype/Public/OpenPypeLib.h b/openpype/hosts/unreal/integration/Source/OpenPype/Public/OpenPypeLib.h
new file mode 100644
index 0000000000..59e9c8bd76
--- /dev/null
+++ b/openpype/hosts/unreal/integration/Source/OpenPype/Public/OpenPypeLib.h
@@ -0,0 +1,19 @@
+#pragma once
+
+#include "Engine.h"
+#include "OpenPypeLib.generated.h"
+
+
+UCLASS(Blueprintable)
+class OPENPYPE_API UOpenPypeLib : public UObject
+{
+
+ GENERATED_BODY()
+
+public:
+ UFUNCTION(BlueprintCallable, Category = Python)
+ static void CSetFolderColor(FString FolderPath, FLinearColor FolderColor, bool bForceAdd);
+
+ UFUNCTION(BlueprintCallable, Category = Python)
+ static TArray GetAllProperties(UClass* cls);
+};
\ No newline at end of file
diff --git a/openpype/hosts/unreal/integration/Source/OpenPype/Public/OpenPypePublishInstance.h b/openpype/hosts/unreal/integration/Source/OpenPype/Public/OpenPypePublishInstance.h
new file mode 100644
index 0000000000..0a27a078d7
--- /dev/null
+++ b/openpype/hosts/unreal/integration/Source/OpenPype/Public/OpenPypePublishInstance.h
@@ -0,0 +1,21 @@
+#pragma once
+
+#include "Engine.h"
+#include "OpenPypePublishInstance.generated.h"
+
+
+UCLASS(Blueprintable)
+class OPENPYPE_API UOpenPypePublishInstance : public UObject
+{
+ GENERATED_BODY()
+
+public:
+ UOpenPypePublishInstance(const FObjectInitializer& ObjectInitalizer);
+
+ UPROPERTY(EditAnywhere, BlueprintReadOnly)
+ TArray assets;
+private:
+ void OnAssetAdded(const FAssetData& AssetData);
+ void OnAssetRemoved(const FAssetData& AssetData);
+ void OnAssetRenamed(const FAssetData& AssetData, const FString& str);
+};
\ No newline at end of file
diff --git a/openpype/hosts/unreal/integration/Source/OpenPype/Public/OpenPypePublishInstanceFactory.h b/openpype/hosts/unreal/integration/Source/OpenPype/Public/OpenPypePublishInstanceFactory.h
new file mode 100644
index 0000000000..a2b3abe13e
--- /dev/null
+++ b/openpype/hosts/unreal/integration/Source/OpenPype/Public/OpenPypePublishInstanceFactory.h
@@ -0,0 +1,19 @@
+#pragma once
+
+#include "CoreMinimal.h"
+#include "Factories/Factory.h"
+#include "OpenPypePublishInstanceFactory.generated.h"
+
+/**
+ *
+ */
+UCLASS()
+class OPENPYPE_API UOpenPypePublishInstanceFactory : public UFactory
+{
+ GENERATED_BODY()
+
+public:
+ UOpenPypePublishInstanceFactory(const FObjectInitializer& ObjectInitializer);
+ virtual UObject* FactoryCreateNew(UClass* Class, UObject* InParent, FName Name, EObjectFlags Flags, UObject* Context, FFeedbackContext* Warn) override;
+ virtual bool ShouldShowInNewMenu() const override;
+};
\ No newline at end of file
diff --git a/openpype/hosts/unreal/integration/Source/OpenPype/Public/OpenPypePythonBridge.h b/openpype/hosts/unreal/integration/Source/OpenPype/Public/OpenPypePythonBridge.h
new file mode 100644
index 0000000000..692aab2e5e
--- /dev/null
+++ b/openpype/hosts/unreal/integration/Source/OpenPype/Public/OpenPypePythonBridge.h
@@ -0,0 +1,20 @@
+#pragma once
+#include "Engine.h"
+#include "OpenPypePythonBridge.generated.h"
+
+UCLASS(Blueprintable)
+class UOpenPypePythonBridge : public UObject
+{
+ GENERATED_BODY()
+
+public:
+ UFUNCTION(BlueprintCallable, Category = Python)
+ static UOpenPypePythonBridge* Get();
+
+ UFUNCTION(BlueprintImplementableEvent, Category = Python)
+ void RunInPython_Popup() const;
+
+ UFUNCTION(BlueprintImplementableEvent, Category = Python)
+ void RunInPython_Dialog() const;
+
+};
diff --git a/openpype/hosts/unreal/integration/Source/OpenPype/Public/OpenPypeStyle.h b/openpype/hosts/unreal/integration/Source/OpenPype/Public/OpenPypeStyle.h
new file mode 100644
index 0000000000..fbc8bcdd5b
--- /dev/null
+++ b/openpype/hosts/unreal/integration/Source/OpenPype/Public/OpenPypeStyle.h
@@ -0,0 +1,22 @@
+#pragma once
+#include "CoreMinimal.h"
+
+class FSlateStyleSet;
+class ISlateStyle;
+
+
+class FOpenPypeStyle
+{
+public:
+ static void Initialize();
+ static void Shutdown();
+ static const ISlateStyle& Get();
+ static FName GetStyleSetName();
+ static FName GetContextName();
+
+ static void SetIcon(const FString& StyleName, const FString& ResourcePath);
+
+private:
+ static TUniquePtr< FSlateStyleSet > Create();
+ static TUniquePtr< FSlateStyleSet > OpenPypeStyleInstance;
+};
\ No newline at end of file
diff --git a/openpype/hosts/unreal/api/lib.py b/openpype/hosts/unreal/lib.py
similarity index 92%
rename from openpype/hosts/unreal/api/lib.py
rename to openpype/hosts/unreal/lib.py
index 61dac46fac..d4a776e892 100644
--- a/openpype/hosts/unreal/api/lib.py
+++ b/openpype/hosts/unreal/lib.py
@@ -169,11 +169,11 @@ def create_unreal_project(project_name: str,
env: dict = None) -> None:
"""This will create `.uproject` file at specified location.
- As there is no way I know to create project via command line, this is
- easiest option. Unreal project file is basically JSON file. If we find
- `AVALON_UNREAL_PLUGIN` environment variable we assume this is location
- of Avalon Integration Plugin and we copy its content to project folder
- and enable this plugin.
+ As there is no way I know to create a project via command line, this is
+ easiest option. Unreal project file is basically a JSON file. If we find
+ the `OPENPYPE_UNREAL_PLUGIN` environment variable we assume this is the
+ location of the Integration Plugin and we copy its content to the project
+ folder and enable this plugin.
Args:
project_name (str): Name of the project.
@@ -230,18 +230,18 @@ def create_unreal_project(project_name: str,
ue_id = "{" + loaded_modules.get("BuildId") + "}"
plugins_path = None
- if os.path.isdir(env.get("AVALON_UNREAL_PLUGIN", "")):
+ if os.path.isdir(env.get("OPENPYPE_UNREAL_PLUGIN", "")):
# copy plugin to correct path under project
plugins_path = pr_dir / "Plugins"
- avalon_plugin_path = plugins_path / "Avalon"
- if not avalon_plugin_path.is_dir():
- avalon_plugin_path.mkdir(parents=True, exist_ok=True)
+ openpype_plugin_path = plugins_path / "OpenPype"
+ if not openpype_plugin_path.is_dir():
+ openpype_plugin_path.mkdir(parents=True, exist_ok=True)
dir_util._path_created = {}
- dir_util.copy_tree(os.environ.get("AVALON_UNREAL_PLUGIN"),
- avalon_plugin_path.as_posix())
+ dir_util.copy_tree(os.environ.get("OPENPYPE_UNREAL_PLUGIN"),
+ openpype_plugin_path.as_posix())
- if not (avalon_plugin_path / "Binaries").is_dir() \
- or not (avalon_plugin_path / "Intermediate").is_dir():
+ if not (openpype_plugin_path / "Binaries").is_dir() \
+ or not (openpype_plugin_path / "Intermediate").is_dir():
dev_mode = True
# data for project file
@@ -254,14 +254,14 @@ def create_unreal_project(project_name: str,
{"Name": "PythonScriptPlugin", "Enabled": True},
{"Name": "EditorScriptingUtilities", "Enabled": True},
{"Name": "SequencerScripting", "Enabled": True},
- {"Name": "Avalon", "Enabled": True}
+ {"Name": "OpenPype", "Enabled": True}
]
}
if dev_mode or preset["dev_mode"]:
- # this will add project module and necessary source file to make it
- # C++ project and to (hopefully) make Unreal Editor to compile all
- # sources at start
+ # this will add the project module and necessary source file to
+ # make it a C++ project and to (hopefully) make Unreal Editor to
+ # compile all # sources at start
data["Modules"] = [{
"Name": project_name,
@@ -304,7 +304,7 @@ def _prepare_cpp_project(project_file: Path, engine_path: Path) -> None:
"""Prepare CPP Unreal Project.
This function will add source files needed for project to be
- rebuild along with the avalon integration plugin.
+ rebuild along with the OpenPype integration plugin.
There seems not to be automated way to do it from command line.
But there might be way to create at least those target and build files
diff --git a/openpype/hosts/unreal/plugins/create/create_camera.py b/openpype/hosts/unreal/plugins/create/create_camera.py
index eda2b52be3..c2905fb6dd 100644
--- a/openpype/hosts/unreal/plugins/create/create_camera.py
+++ b/openpype/hosts/unreal/plugins/create/create_camera.py
@@ -16,7 +16,7 @@ class CreateCamera(Creator):
family = "camera"
icon = "cubes"
- root = "/Game/Avalon/Instances"
+ root = "/Game/OpenPype/Instances"
suffix = "_INS"
def __init__(self, *args, **kwargs):
diff --git a/openpype/hosts/unreal/plugins/create/create_layout.py b/openpype/hosts/unreal/plugins/create/create_layout.py
index 239b72787b..00e83cf433 100644
--- a/openpype/hosts/unreal/plugins/create/create_layout.py
+++ b/openpype/hosts/unreal/plugins/create/create_layout.py
@@ -1,3 +1,4 @@
+# -*- coding: utf-8 -*-
from unreal import EditorLevelLibrary as ell
from openpype.hosts.unreal.api.plugin import Creator
from avalon.unreal import (
@@ -6,7 +7,7 @@ from avalon.unreal import (
class CreateLayout(Creator):
- """Layout output for character rigs"""
+ """Layout output for character rigs."""
name = "layoutMain"
label = "Layout"
diff --git a/openpype/hosts/unreal/plugins/create/create_look.py b/openpype/hosts/unreal/plugins/create/create_look.py
index 7d3913b883..59c40d3e74 100644
--- a/openpype/hosts/unreal/plugins/create/create_look.py
+++ b/openpype/hosts/unreal/plugins/create/create_look.py
@@ -1,10 +1,12 @@
-import unreal
+# -*- coding: utf-8 -*-
+"""Create look in Unreal."""
+import unreal # noqa
from openpype.hosts.unreal.api.plugin import Creator
-from avalon.unreal import pipeline
+from openpype.hosts.unreal.api import pipeline
class CreateLook(Creator):
- """Shader connections defining shape look"""
+ """Shader connections defining shape look."""
name = "unrealLook"
label = "Unreal - Look"
@@ -49,14 +51,14 @@ class CreateLook(Creator):
for material in materials:
name = material.get_editor_property('material_slot_name')
object_path = f"{full_path}/{name}.{name}"
- object = unreal.EditorAssetLibrary.duplicate_loaded_asset(
+ unreal_object = unreal.EditorAssetLibrary.duplicate_loaded_asset(
cube.get_asset(), object_path
)
# Remove the default material of the cube object
- object.get_editor_property('static_materials').pop()
+ unreal_object.get_editor_property('static_materials').pop()
- object.add_material(
+ unreal_object.add_material(
material.get_editor_property('material_interface'))
self.data["members"].append(object_path)
diff --git a/openpype/hosts/unreal/plugins/create/create_staticmeshfbx.py b/openpype/hosts/unreal/plugins/create/create_staticmeshfbx.py
index 4cc67e0f1f..700eac7366 100644
--- a/openpype/hosts/unreal/plugins/create/create_staticmeshfbx.py
+++ b/openpype/hosts/unreal/plugins/create/create_staticmeshfbx.py
@@ -1,12 +1,14 @@
-import unreal
+# -*- coding: utf-8 -*-
+"""Create Static Meshes as FBX geometry."""
+import unreal # noqa
from openpype.hosts.unreal.api.plugin import Creator
-from avalon.unreal import (
+from openpype.hosts.unreal.api.pipeline import (
instantiate,
)
class CreateStaticMeshFBX(Creator):
- """Static FBX geometry"""
+ """Static FBX geometry."""
name = "unrealStaticMeshMain"
label = "Unreal - Static Mesh"
diff --git a/openpype/hosts/unreal/plugins/load/load_alembic_geometrycache.py b/openpype/hosts/unreal/plugins/load/load_alembic_geometrycache.py
index e2023e8b47..027e9f4cd3 100644
--- a/openpype/hosts/unreal/plugins/load/load_alembic_geometrycache.py
+++ b/openpype/hosts/unreal/plugins/load/load_alembic_geometrycache.py
@@ -1,12 +1,15 @@
+# -*- coding: utf-8 -*-
+"""Loader for published alembics."""
import os
from avalon import api, pipeline
-from avalon.unreal import lib
-from avalon.unreal import pipeline as unreal_pipeline
-import unreal
+from openpype.hosts.unreal.api import plugin
+from openpype.hosts.unreal.api import pipeline as unreal_pipeline
+
+import unreal # noqa
-class PointCacheAlembicLoader(api.Loader):
+class PointCacheAlembicLoader(plugin.Loader):
"""Load Point Cache from Alembic"""
families = ["model", "pointcache"]
@@ -56,8 +59,7 @@ class PointCacheAlembicLoader(api.Loader):
return task
def load(self, context, name, namespace, data):
- """
- Load and containerise representation into Content Browser.
+ """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
@@ -76,10 +78,10 @@ class PointCacheAlembicLoader(api.Loader):
Returns:
list(str): list of container content
- """
- # Create directory for asset and avalon container
- root = "/Game/Avalon/Assets"
+ """
+ # Create directory for asset and OpenPype container
+ root = "/Game/OpenPype/Assets"
asset = context.get('asset').get('name')
suffix = "_CON"
if asset:
@@ -109,7 +111,7 @@ class PointCacheAlembicLoader(api.Loader):
unreal.AssetToolsHelpers.get_asset_tools().import_asset_tasks([task]) # noqa: E501
# Create Asset Container
- lib.create_avalon_container(
+ unreal_pipeline.create_container(
container=container_name, path=asset_dir)
data = {
diff --git a/openpype/hosts/unreal/plugins/load/load_alembic_skeletalmesh.py b/openpype/hosts/unreal/plugins/load/load_alembic_skeletalmesh.py
index b652af0b89..0236bab138 100644
--- a/openpype/hosts/unreal/plugins/load/load_alembic_skeletalmesh.py
+++ b/openpype/hosts/unreal/plugins/load/load_alembic_skeletalmesh.py
@@ -1,12 +1,14 @@
+# -*- coding: utf-8 -*-
+"""Load Skeletal Mesh alembics."""
import os
from avalon import api, pipeline
-from avalon.unreal import lib
-from avalon.unreal import pipeline as unreal_pipeline
-import unreal
+from openpype.hosts.unreal.api import plugin
+from openpype.hosts.unreal.api import pipeline as unreal_pipeline
+import unreal # noqa
-class SkeletalMeshAlembicLoader(api.Loader):
+class SkeletalMeshAlembicLoader(plugin.Loader):
"""Load Unreal SkeletalMesh from Alembic"""
families = ["pointcache"]
@@ -16,8 +18,7 @@ class SkeletalMeshAlembicLoader(api.Loader):
color = "orange"
def load(self, context, name, namespace, data):
- """
- Load and containerise representation into Content Browser.
+ """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
@@ -38,8 +39,8 @@ class SkeletalMeshAlembicLoader(api.Loader):
list(str): list of container content
"""
- # Create directory for asset and avalon container
- root = "/Game/Avalon/Assets"
+ # Create directory for asset and openpype container
+ root = "/Game/OpenPype/Assets"
asset = context.get('asset').get('name')
suffix = "_CON"
if asset:
@@ -74,7 +75,7 @@ class SkeletalMeshAlembicLoader(api.Loader):
unreal.AssetToolsHelpers.get_asset_tools().import_asset_tasks([task]) # noqa: E501
# Create Asset Container
- lib.create_avalon_container(
+ unreal_pipeline.create_container(
container=container_name, path=asset_dir)
data = {
diff --git a/openpype/hosts/unreal/plugins/load/load_alembic_staticmesh.py b/openpype/hosts/unreal/plugins/load/load_alembic_staticmesh.py
index ccec31b832..3bcc8b476f 100644
--- a/openpype/hosts/unreal/plugins/load/load_alembic_staticmesh.py
+++ b/openpype/hosts/unreal/plugins/load/load_alembic_staticmesh.py
@@ -1,12 +1,14 @@
+# -*- coding: utf-8 -*-
+"""Loader for Static Mesh alembics."""
import os
from avalon import api, pipeline
-from avalon.unreal import lib
-from avalon.unreal import pipeline as unreal_pipeline
-import unreal
+from openpype.hosts.unreal.api import plugin
+from openpype.hosts.unreal.api import pipeline as unreal_pipeline
+import unreal # noqa
-class StaticMeshAlembicLoader(api.Loader):
+class StaticMeshAlembicLoader(plugin.Loader):
"""Load Unreal StaticMesh from Alembic"""
families = ["model"]
@@ -49,8 +51,7 @@ class StaticMeshAlembicLoader(api.Loader):
return task
def load(self, context, name, namespace, data):
- """
- Load and containerise representation into Content Browser.
+ """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
@@ -69,10 +70,10 @@ class StaticMeshAlembicLoader(api.Loader):
Returns:
list(str): list of container content
- """
- # Create directory for asset and avalon container
- root = "/Game/Avalon/Assets"
+ """
+ # Create directory for asset and OpenPype container
+ root = "/Game/OpenPype/Assets"
asset = context.get('asset').get('name')
suffix = "_CON"
if asset:
@@ -93,7 +94,7 @@ class StaticMeshAlembicLoader(api.Loader):
unreal.AssetToolsHelpers.get_asset_tools().import_asset_tasks([task]) # noqa: E501
# Create Asset Container
- lib.create_avalon_container(
+ unreal_pipeline.create_container(
container=container_name, path=asset_dir)
data = {
diff --git a/openpype/hosts/unreal/plugins/load/load_animation.py b/openpype/hosts/unreal/plugins/load/load_animation.py
index 20baa30847..63c734b969 100644
--- a/openpype/hosts/unreal/plugins/load/load_animation.py
+++ b/openpype/hosts/unreal/plugins/load/load_animation.py
@@ -1,14 +1,16 @@
+# -*- coding: utf-8 -*-
+"""Load FBX with animations."""
import os
import json
from avalon import api, pipeline
-from avalon.unreal import lib
-from avalon.unreal import pipeline as unreal_pipeline
-import unreal
+from openpype.hosts.unreal.api import plugin
+from openpype.hosts.unreal.api import pipeline as unreal_pipeline
+import unreal # noqa
-class AnimationFBXLoader(api.Loader):
- """Load Unreal SkeletalMesh from FBX"""
+class AnimationFBXLoader(plugin.Loader):
+ """Load Unreal SkeletalMesh from FBX."""
families = ["animation"]
label = "Import FBX Animation"
@@ -37,10 +39,10 @@ class AnimationFBXLoader(api.Loader):
Returns:
list(str): list of container content
- """
- # Create directory for asset and avalon container
- root = "/Game/Avalon/Assets"
+ """
+ # Create directory for asset and OpenPype container
+ root = "/Game/OpenPype/Assets"
asset = context.get('asset').get('name')
suffix = "_CON"
if asset:
@@ -62,9 +64,9 @@ class AnimationFBXLoader(api.Loader):
task = unreal.AssetImportTask()
task.options = unreal.FbxImportUI()
- libpath = self.fname.replace("fbx", "json")
+ lib_path = self.fname.replace("fbx", "json")
- with open(libpath, "r") as fp:
+ with open(lib_path, "r") as fp:
data = json.load(fp)
instance_name = data.get("instance_name")
@@ -127,7 +129,7 @@ class AnimationFBXLoader(api.Loader):
unreal.AssetToolsHelpers.get_asset_tools().import_asset_tasks([task])
# Create Asset Container
- lib.create_avalon_container(
+ unreal_pipeline.create_container(
container=container_name, path=asset_dir)
data = {
diff --git a/openpype/hosts/unreal/plugins/load/load_camera.py b/openpype/hosts/unreal/plugins/load/load_camera.py
index b2b25eec73..0de9470ef9 100644
--- a/openpype/hosts/unreal/plugins/load/load_camera.py
+++ b/openpype/hosts/unreal/plugins/load/load_camera.py
@@ -1,12 +1,14 @@
+# -*- coding: utf-8 -*-
+"""Load camera from FBX."""
import os
-from avalon import api, io, pipeline
-from avalon.unreal import lib
-from avalon.unreal import pipeline as unreal_pipeline
-import unreal
+from avalon import io, pipeline
+from openpype.hosts.unreal.api import plugin
+from openpype.hosts.unreal.api import pipeline as unreal_pipeline
+import unreal # noqa
-class CameraLoader(api.Loader):
+class CameraLoader(plugin.Loader):
"""Load Unreal StaticMesh from FBX"""
families = ["camera"]
@@ -38,8 +40,8 @@ class CameraLoader(api.Loader):
list(str): list of container content
"""
- # Create directory for asset and avalon container
- root = "/Game/Avalon/Assets"
+ # Create directory for asset and OpenPype container
+ root = "/Game/OpenPype/Assets"
asset = context.get('asset').get('name')
suffix = "_CON"
if asset:
@@ -109,7 +111,8 @@ class CameraLoader(api.Loader):
)
# Create Asset Container
- lib.create_avalon_container(container=container_name, path=asset_dir)
+ unreal_pipeline.create_container(
+ container=container_name, path=asset_dir)
data = {
"schema": "openpype:container-2.0",
diff --git a/openpype/hosts/unreal/plugins/load/load_layout.py b/openpype/hosts/unreal/plugins/load/load_layout.py
index 19d0b74e3e..b802f5940a 100644
--- a/openpype/hosts/unreal/plugins/load/load_layout.py
+++ b/openpype/hosts/unreal/plugins/load/load_layout.py
@@ -1,3 +1,5 @@
+# -*- coding: utf-8 -*-
+"""Loader for layouts."""
import os
import json
from pathlib import Path
@@ -10,11 +12,11 @@ from unreal import FBXImportType
from unreal import MathLibrary as umath
from avalon import api, pipeline
-from avalon.unreal import lib
-from avalon.unreal import pipeline as unreal_pipeline
+from openpype.hosts.unreal.api import plugin
+from openpype.hosts.unreal.api import pipeline as unreal_pipeline
-class LayoutLoader(api.Loader):
+class LayoutLoader(plugin.Loader):
"""Load Layout from a JSON file"""
families = ["layout"]
@@ -23,6 +25,7 @@ class LayoutLoader(api.Loader):
label = "Load Layout"
icon = "code-fork"
color = "orange"
+ ASSET_ROOT = "/Game/OpenPype/Assets"
def _get_asset_containers(self, path):
ar = unreal.AssetRegistryHelpers.get_asset_registry()
@@ -40,7 +43,8 @@ class LayoutLoader(api.Loader):
return asset_containers
- def _get_fbx_loader(self, loaders, family):
+ @staticmethod
+ def _get_fbx_loader(loaders, family):
name = ""
if family == 'rig':
name = "SkeletalMeshFBXLoader"
@@ -58,7 +62,8 @@ class LayoutLoader(api.Loader):
return None
- def _get_abc_loader(self, loaders, family):
+ @staticmethod
+ def _get_abc_loader(loaders, family):
name = ""
if family == 'rig':
name = "SkeletalMeshAlembicLoader"
@@ -74,14 +79,15 @@ class LayoutLoader(api.Loader):
return None
- def _process_family(self, assets, classname, transform, inst_name=None):
+ @staticmethod
+ def _process_family(assets, class_name, transform, inst_name=None):
ar = unreal.AssetRegistryHelpers.get_asset_registry()
actors = []
for asset in assets:
obj = ar.get_asset_by_object_path(asset).get_asset()
- if obj.get_class().get_name() == classname:
+ if obj.get_class().get_name() == class_name:
actor = EditorLevelLibrary.spawn_actor_from_object(
obj,
transform.get('translation')
@@ -111,8 +117,9 @@ class LayoutLoader(api.Loader):
return actors
+ @staticmethod
def _import_animation(
- self, asset_dir, path, instance_name, skeleton, actors_dict,
+ asset_dir, path, instance_name, skeleton, actors_dict,
animation_file):
anim_file = Path(animation_file)
anim_file_name = anim_file.with_suffix('')
@@ -192,10 +199,10 @@ class LayoutLoader(api.Loader):
actor.skeletal_mesh_component.animation_data.set_editor_property(
'anim_to_play', animation)
- def _process(self, libpath, asset_dir, loaded=None):
+ def _process(self, lib_path, asset_dir, loaded=None):
ar = unreal.AssetRegistryHelpers.get_asset_registry()
- with open(libpath, "r") as fp:
+ with open(lib_path, "r") as fp:
data = json.load(fp)
all_loaders = api.discover(api.Loader)
@@ -203,7 +210,7 @@ class LayoutLoader(api.Loader):
if not loaded:
loaded = []
- path = Path(libpath)
+ path = Path(lib_path)
skeleton_dict = {}
actors_dict = {}
@@ -292,17 +299,18 @@ class LayoutLoader(api.Loader):
asset_dir, path, instance_name, skeleton,
actors_dict, animation_file)
- def _remove_family(self, assets, components, classname, propname):
+ @staticmethod
+ def _remove_family(assets, components, class_name, prop_name):
ar = unreal.AssetRegistryHelpers.get_asset_registry()
objects = []
for a in assets:
obj = ar.get_asset_by_object_path(a)
- if obj.get_asset().get_class().get_name() == classname:
+ if obj.get_asset().get_class().get_name() == class_name:
objects.append(obj)
for obj in objects:
for comp in components:
- if comp.get_editor_property(propname) == obj.get_asset():
+ if comp.get_editor_property(prop_name) == obj.get_asset():
comp.get_owner().destroy_actor()
def _remove_actors(self, path):
@@ -334,8 +342,7 @@ class LayoutLoader(api.Loader):
assets, skel_meshes_comp, 'SkeletalMesh', 'skeletal_mesh')
def load(self, context, name, namespace, options):
- """
- Load and containerise representation into Content Browser.
+ """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
@@ -349,14 +356,14 @@ class LayoutLoader(api.Loader):
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()`.
+ options (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"
+ root = self.ASSET_ROOT
asset = context.get('asset').get('name')
suffix = "_CON"
if asset:
@@ -375,7 +382,7 @@ class LayoutLoader(api.Loader):
self._process(self.fname, asset_dir)
# Create Asset Container
- lib.create_avalon_container(
+ unreal_pipeline.create_container(
container=container_name, path=asset_dir)
data = {
@@ -406,7 +413,7 @@ class LayoutLoader(api.Loader):
source_path = api.get_representation_path(representation)
destination_path = container["namespace"]
- libpath = Path(api.get_representation_path(representation))
+ lib_path = Path(api.get_representation_path(representation))
self._remove_actors(destination_path)
@@ -502,7 +509,7 @@ class LayoutLoader(api.Loader):
if animation_file and skeleton:
self._import_animation(
- destination_path, libpath,
+ destination_path, lib_path,
instance_name, skeleton,
actors_dict, animation_file)
diff --git a/openpype/hosts/unreal/plugins/load/load_rig.py b/openpype/hosts/unreal/plugins/load/load_rig.py
index c7d095aa21..a7ecb0ef7d 100644
--- a/openpype/hosts/unreal/plugins/load/load_rig.py
+++ b/openpype/hosts/unreal/plugins/load/load_rig.py
@@ -1,13 +1,15 @@
+# -*- coding: utf-8 -*-
+"""Load Skeletal Meshes form FBX."""
import os
from avalon import api, pipeline
-from avalon.unreal import lib
-from avalon.unreal import pipeline as unreal_pipeline
-import unreal
+from openpype.hosts.unreal.api import plugin
+from openpype.hosts.unreal.api import pipeline as unreal_pipeline
+import unreal # noqa
-class SkeletalMeshFBXLoader(api.Loader):
- """Load Unreal SkeletalMesh from FBX"""
+class SkeletalMeshFBXLoader(plugin.Loader):
+ """Load Unreal SkeletalMesh from FBX."""
families = ["rig"]
label = "Import FBX Skeletal Mesh"
@@ -16,8 +18,7 @@ class SkeletalMeshFBXLoader(api.Loader):
color = "orange"
def load(self, context, name, namespace, options):
- """
- Load and containerise representation into Content Browser.
+ """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
@@ -31,15 +32,15 @@ class SkeletalMeshFBXLoader(api.Loader):
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()`.
+ options (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"
+ """
+ # Create directory for asset and OpenPype container
+ root = "/Game/OpenPype/Assets"
if options and options.get("asset_dir"):
root = options["asset_dir"]
asset = context.get('asset').get('name')
@@ -94,7 +95,7 @@ class SkeletalMeshFBXLoader(api.Loader):
unreal.AssetToolsHelpers.get_asset_tools().import_asset_tasks([task]) # noqa: E501
# Create Asset Container
- lib.create_avalon_container(
+ unreal_pipeline.create_container(
container=container_name, path=asset_dir)
data = {
diff --git a/openpype/hosts/unreal/plugins/load/load_staticmeshfbx.py b/openpype/hosts/unreal/plugins/load/load_staticmeshfbx.py
index 510c4331ad..c8a6964ffb 100644
--- a/openpype/hosts/unreal/plugins/load/load_staticmeshfbx.py
+++ b/openpype/hosts/unreal/plugins/load/load_staticmeshfbx.py
@@ -1,13 +1,15 @@
+# -*- coding: utf-8 -*-
+"""Load Static meshes form FBX."""
import os
from avalon import api, pipeline
-from avalon.unreal import lib
-from avalon.unreal import pipeline as unreal_pipeline
-import unreal
+from openpype.hosts.unreal.api import plugin
+from openpype.hosts.unreal.api import pipeline as unreal_pipeline
+import unreal # noqa
-class StaticMeshFBXLoader(api.Loader):
- """Load Unreal StaticMesh from FBX"""
+class StaticMeshFBXLoader(plugin.Loader):
+ """Load Unreal StaticMesh from FBX."""
families = ["model", "unrealStaticMesh"]
label = "Import FBX Static Mesh"
@@ -15,7 +17,8 @@ class StaticMeshFBXLoader(api.Loader):
icon = "cube"
color = "orange"
- def get_task(self, filename, asset_dir, asset_name, replace):
+ @staticmethod
+ def get_task(filename, asset_dir, asset_name, replace):
task = unreal.AssetImportTask()
options = unreal.FbxImportUI()
import_data = unreal.FbxStaticMeshImportData()
@@ -41,8 +44,7 @@ class StaticMeshFBXLoader(api.Loader):
return task
def load(self, context, name, namespace, options):
- """
- Load and containerise representation into Content Browser.
+ """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
@@ -56,15 +58,15 @@ class StaticMeshFBXLoader(api.Loader):
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()`.
+ options (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"
+ # Create directory for asset and OpenPype container
+ root = "/Game/OpenPype/Assets"
if options and options.get("asset_dir"):
root = options["asset_dir"]
asset = context.get('asset').get('name')
@@ -87,7 +89,7 @@ class StaticMeshFBXLoader(api.Loader):
unreal.AssetToolsHelpers.get_asset_tools().import_asset_tasks([task]) # noqa: E501
# Create Asset Container
- lib.create_avalon_container(
+ unreal_pipeline.create_container(
container=container_name, path=asset_dir)
data = {
diff --git a/openpype/hosts/unreal/plugins/publish/collect_current_file.py b/openpype/hosts/unreal/plugins/publish/collect_current_file.py
index 4e828933bb..acd4c5c8d2 100644
--- a/openpype/hosts/unreal/plugins/publish/collect_current_file.py
+++ b/openpype/hosts/unreal/plugins/publish/collect_current_file.py
@@ -1,17 +1,18 @@
-import unreal
-
+# -*- coding: utf-8 -*-
+"""Collect current project path."""
+import unreal # noqa
import pyblish.api
class CollectUnrealCurrentFile(pyblish.api.ContextPlugin):
- """Inject the current working file into context"""
+ """Inject the current working file into context."""
order = pyblish.api.CollectorOrder - 0.5
label = "Unreal Current File"
hosts = ['unreal']
def process(self, context):
- """Inject the current working file"""
+ """Inject the current working file."""
current_file = unreal.Paths.get_project_file_path()
context.data['currentFile'] = current_file
diff --git a/openpype/hosts/unreal/plugins/publish/collect_instances.py b/openpype/hosts/unreal/plugins/publish/collect_instances.py
index 62676f9938..94e732d728 100644
--- a/openpype/hosts/unreal/plugins/publish/collect_instances.py
+++ b/openpype/hosts/unreal/plugins/publish/collect_instances.py
@@ -1,12 +1,14 @@
+# -*- coding: utf-8 -*-
+"""Collect publishable instances in Unreal."""
import ast
-import unreal
+import unreal # noqa
import pyblish.api
class CollectInstances(pyblish.api.ContextPlugin):
- """Gather instances by AvalonPublishInstance class
+ """Gather instances by OpenPypePublishInstance class
- This collector finds all paths containing `AvalonPublishInstance` class
+ This collector finds all paths containing `OpenPypePublishInstance` class
asset
Identifier:
@@ -22,7 +24,7 @@ class CollectInstances(pyblish.api.ContextPlugin):
ar = unreal.AssetRegistryHelpers.get_asset_registry()
instance_containers = ar.get_assets_by_class(
- "AvalonPublishInstance", True)
+ "OpenPypePublishInstance", True)
for container_data in instance_containers:
asset = container_data.get_asset()
diff --git a/openpype/hosts/unreal/plugins/publish/extract_camera.py b/openpype/hosts/unreal/plugins/publish/extract_camera.py
index 10862fc0ef..ce53824563 100644
--- a/openpype/hosts/unreal/plugins/publish/extract_camera.py
+++ b/openpype/hosts/unreal/plugins/publish/extract_camera.py
@@ -1,3 +1,5 @@
+# -*- coding: utf-8 -*-
+"""Extract camera from Unreal."""
import os
import unreal
@@ -17,7 +19,7 @@ class ExtractCamera(openpype.api.Extractor):
def process(self, instance):
# Define extract output file path
- stagingdir = self.staging_dir(instance)
+ staging_dir = self.staging_dir(instance)
fbx_filename = "{}.fbx".format(instance.name)
# Perform extraction
@@ -38,7 +40,7 @@ class ExtractCamera(openpype.api.Extractor):
sequence,
sequence.get_bindings(),
unreal.FbxExportOption(),
- os.path.join(stagingdir, fbx_filename)
+ os.path.join(staging_dir, fbx_filename)
)
break
@@ -49,6 +51,6 @@ class ExtractCamera(openpype.api.Extractor):
'name': 'fbx',
'ext': 'fbx',
'files': fbx_filename,
- "stagingDir": stagingdir,
+ "stagingDir": staging_dir,
}
instance.data["representations"].append(fbx_representation)
diff --git a/openpype/hosts/unreal/plugins/publish/extract_layout.py b/openpype/hosts/unreal/plugins/publish/extract_layout.py
index a47187cf47..2d09b0e7bd 100644
--- a/openpype/hosts/unreal/plugins/publish/extract_layout.py
+++ b/openpype/hosts/unreal/plugins/publish/extract_layout.py
@@ -1,3 +1,4 @@
+# -*- coding: utf-8 -*-
import os
import json
import math
@@ -20,7 +21,7 @@ class ExtractLayout(openpype.api.Extractor):
def process(self, instance):
# Define extract output file path
- stagingdir = self.staging_dir(instance)
+ staging_dir = self.staging_dir(instance)
# Perform extraction
self.log.info("Performing extraction..")
@@ -96,7 +97,7 @@ class ExtractLayout(openpype.api.Extractor):
json_data.append(json_element)
json_filename = "{}.json".format(instance.name)
- json_path = os.path.join(stagingdir, json_filename)
+ json_path = os.path.join(staging_dir, json_filename)
with open(json_path, "w+") as file:
json.dump(json_data, fp=file, indent=2)
@@ -108,6 +109,6 @@ class ExtractLayout(openpype.api.Extractor):
'name': 'json',
'ext': 'json',
'files': json_filename,
- "stagingDir": stagingdir,
+ "stagingDir": staging_dir,
}
instance.data["representations"].append(json_representation)
diff --git a/openpype/hosts/unreal/plugins/publish/extract_look.py b/openpype/hosts/unreal/plugins/publish/extract_look.py
index 0f1539a7d5..ea39949417 100644
--- a/openpype/hosts/unreal/plugins/publish/extract_look.py
+++ b/openpype/hosts/unreal/plugins/publish/extract_look.py
@@ -1,3 +1,4 @@
+# -*- coding: utf-8 -*-
import json
import os
@@ -17,7 +18,7 @@ class ExtractLook(openpype.api.Extractor):
def process(self, instance):
# Define extract output file path
- stagingdir = self.staging_dir(instance)
+ staging_dir = self.staging_dir(instance)
resources_dir = instance.data["resourcesDir"]
ar = unreal.AssetRegistryHelpers.get_asset_registry()
@@ -57,7 +58,7 @@ class ExtractLook(openpype.api.Extractor):
tga_export_task.set_editor_property('automated', True)
tga_export_task.set_editor_property('object', texture)
tga_export_task.set_editor_property(
- 'filename', f"{stagingdir}/{tga_filename}")
+ 'filename', f"{staging_dir}/{tga_filename}")
tga_export_task.set_editor_property('prompt', False)
tga_export_task.set_editor_property('selected', False)
@@ -66,7 +67,7 @@ class ExtractLook(openpype.api.Extractor):
json_element['tga_filename'] = tga_filename
transfers.append((
- f"{stagingdir}/{tga_filename}",
+ f"{staging_dir}/{tga_filename}",
f"{resources_dir}/{tga_filename}"))
fbx_filename = f"{instance.name}_{name}.fbx"
@@ -84,7 +85,7 @@ class ExtractLook(openpype.api.Extractor):
task.set_editor_property('automated', True)
task.set_editor_property('object', object)
task.set_editor_property(
- 'filename', f"{stagingdir}/{fbx_filename}")
+ 'filename', f"{staging_dir}/{fbx_filename}")
task.set_editor_property('prompt', False)
task.set_editor_property('selected', False)
@@ -93,13 +94,13 @@ class ExtractLook(openpype.api.Extractor):
json_element['fbx_filename'] = fbx_filename
transfers.append((
- f"{stagingdir}/{fbx_filename}",
+ f"{staging_dir}/{fbx_filename}",
f"{resources_dir}/{fbx_filename}"))
json_data.append(json_element)
json_filename = f"{instance.name}.json"
- json_path = os.path.join(stagingdir, json_filename)
+ json_path = os.path.join(staging_dir, json_filename)
with open(json_path, "w+") as file:
json.dump(json_data, fp=file, indent=2)
@@ -113,7 +114,7 @@ class ExtractLook(openpype.api.Extractor):
'name': 'json',
'ext': 'json',
'files': json_filename,
- "stagingDir": stagingdir,
+ "stagingDir": staging_dir,
}
instance.data["representations"].append(json_representation)
diff --git a/openpype/lib/__init__.py b/openpype/lib/__init__.py
index ebe7648ad7..6a24f30455 100644
--- a/openpype/lib/__init__.py
+++ b/openpype/lib/__init__.py
@@ -29,19 +29,29 @@ from .execute import (
get_linux_launcher_args,
execute,
run_subprocess,
+ run_detached_process,
run_openpype_process,
clean_envs_for_openpype_process,
path_to_subprocess_arg,
CREATE_NO_WINDOW
)
from .log import PypeLogger, timeit
+
+from .path_templates import (
+ merge_dict,
+ TemplateMissingKey,
+ TemplateUnsolved,
+ StringTemplate,
+ TemplatesDict,
+ FormatObject,
+)
+
from .mongo import (
get_default_components,
validate_mongo_connection,
OpenPypeMongoConnection
)
from .anatomy import (
- merge_dict,
Anatomy
)
@@ -130,7 +140,7 @@ from .applications import (
PostLaunchHook,
EnvironmentPrepData,
- prepare_host_environments,
+ prepare_app_environments,
prepare_context_environments,
get_app_environments_for_context,
apply_project_environments_value
@@ -188,6 +198,7 @@ __all__ = [
"get_linux_launcher_args",
"execute",
"run_subprocess",
+ "run_detached_process",
"run_openpype_process",
"clean_envs_for_openpype_process",
"path_to_subprocess_arg",
@@ -261,7 +272,7 @@ __all__ = [
"PreLaunchHook",
"PostLaunchHook",
"EnvironmentPrepData",
- "prepare_host_environments",
+ "prepare_app_environments",
"prepare_context_environments",
"get_app_environments_for_context",
"apply_project_environments_value",
@@ -283,9 +294,15 @@ __all__ = [
"get_version_from_path",
"get_last_version_from_path",
+ "merge_dict",
+ "TemplateMissingKey",
+ "TemplateUnsolved",
+ "StringTemplate",
+ "TemplatesDict",
+ "FormatObject",
+
"terminal",
- "merge_dict",
"Anatomy",
"get_datetime_data",
diff --git a/openpype/lib/anatomy.py b/openpype/lib/anatomy.py
index fa81a18ff7..3fbc05ee88 100644
--- a/openpype/lib/anatomy.py
+++ b/openpype/lib/anatomy.py
@@ -9,6 +9,12 @@ from openpype.settings.lib import (
get_default_anatomy_settings,
get_anatomy_settings
)
+from .path_templates import (
+ TemplateUnsolved,
+ TemplateResult,
+ TemplatesDict,
+ FormatObject,
+)
from .log import PypeLogger
log = PypeLogger().get_logger(__name__)
@@ -19,32 +25,6 @@ except NameError:
StringType = str
-def merge_dict(main_dict, enhance_dict):
- """Merges dictionaries by keys.
-
- Function call itself if value on key is again dictionary.
-
- Args:
- main_dict (dict): First dict to merge second one into.
- enhance_dict (dict): Second dict to be merged.
-
- Returns:
- dict: Merged result.
-
- .. note:: does not overrides whole value on first found key
- but only values differences from enhance_dict
-
- """
- for key, value in enhance_dict.items():
- if key not in main_dict:
- main_dict[key] = value
- elif isinstance(value, dict) and isinstance(main_dict[key], dict):
- main_dict[key] = merge_dict(main_dict[key], value)
- else:
- main_dict[key] = value
- return main_dict
-
-
class ProjectNotSet(Exception):
"""Exception raised when is created Anatomy without project name."""
@@ -59,7 +39,7 @@ class RootCombinationError(Exception):
# TODO better error message
msg = (
"Combination of root with and"
- " without root name in Templates. {}"
+ " without root name in AnatomyTemplates. {}"
).format(joined_roots)
super(RootCombinationError, self).__init__(msg)
@@ -68,7 +48,7 @@ class RootCombinationError(Exception):
class Anatomy:
"""Anatomy module helps to keep project settings.
- Wraps key project specifications, Templates and Roots.
+ Wraps key project specifications, AnatomyTemplates and Roots.
Args:
project_name (str): Project name to look on overrides.
@@ -93,7 +73,7 @@ class Anatomy:
get_anatomy_settings(project_name, site_name)
)
self._site_name = site_name
- self._templates_obj = Templates(self)
+ self._templates_obj = AnatomyTemplates(self)
self._roots_obj = Roots(self)
# Anatomy used as dictionary
@@ -158,12 +138,12 @@ class Anatomy:
@property
def templates(self):
- """Wrap property `templates` of Anatomy's Templates instance."""
+ """Wrap property `templates` of Anatomy's AnatomyTemplates instance."""
return self._templates_obj.templates
@property
def templates_obj(self):
- """Return `Templates` object of current Anatomy instance."""
+ """Return `AnatomyTemplates` object of current Anatomy instance."""
return self._templates_obj
def format(self, *args, **kwargs):
@@ -375,203 +355,45 @@ class Anatomy:
return rootless_path.format(**data)
-class TemplateMissingKey(Exception):
- """Exception for cases when key does not exist in Anatomy."""
-
- msg = "Anatomy key does not exist: `anatomy{0}`."
-
- def __init__(self, parents):
- parent_join = "".join(["[\"{0}\"]".format(key) for key in parents])
- super(TemplateMissingKey, self).__init__(
- self.msg.format(parent_join)
- )
-
-
-class TemplateUnsolved(Exception):
+class AnatomyTemplateUnsolved(TemplateUnsolved):
"""Exception for unsolved template when strict is set to True."""
msg = "Anatomy template \"{0}\" is unsolved.{1}{2}"
- invalid_types_msg = " Keys with invalid DataType: `{0}`."
- missing_keys_msg = " Missing keys: \"{0}\"."
- def __init__(self, template, missing_keys, invalid_types):
- invalid_type_items = []
- for _key, _type in invalid_types.items():
- invalid_type_items.append(
- "\"{0}\" {1}".format(_key, str(_type))
- )
- invalid_types_msg = ""
- if invalid_type_items:
- invalid_types_msg = self.invalid_types_msg.format(
- ", ".join(invalid_type_items)
- )
+class AnatomyTemplateResult(TemplateResult):
+ rootless = None
- missing_keys_msg = ""
- if missing_keys:
- missing_keys_msg = self.missing_keys_msg.format(
- ", ".join(missing_keys)
- )
- super(TemplateUnsolved, self).__init__(
- self.msg.format(template, missing_keys_msg, invalid_types_msg)
+ def __new__(cls, result, rootless_path):
+ new_obj = super(AnatomyTemplateResult, cls).__new__(
+ cls,
+ str(result),
+ result.template,
+ result.solved,
+ result.used_values,
+ result.missing_keys,
+ result.invalid_types
)
-
-
-class TemplateResult(str):
- """Result (formatted template) of anatomy with most of information in.
-
- Args:
- used_values (dict): Dictionary of template filling data with
- only used keys.
- solved (bool): For check if all required keys were filled.
- template (str): Original template.
- missing_keys (list): Missing keys that were not in the data. Include
- missing optional keys.
- invalid_types (dict): When key was found in data, but value had not
- allowed DataType. Allowed data types are `numbers`,
- `str`(`basestring`) and `dict`. Dictionary may cause invalid type
- when value of key in data is dictionary but template expect string
- of number.
- """
-
- def __new__(
- cls, filled_template, template, solved, rootless_path,
- used_values, missing_keys, invalid_types
- ):
- new_obj = super(TemplateResult, cls).__new__(cls, filled_template)
- new_obj.used_values = used_values
- new_obj.solved = solved
- new_obj.template = template
new_obj.rootless = rootless_path
- new_obj.missing_keys = list(set(missing_keys))
- _invalid_types = {}
- for invalid_type in invalid_types:
- for key, val in invalid_type.items():
- if key in _invalid_types:
- continue
- _invalid_types[key] = val
- new_obj.invalid_types = _invalid_types
return new_obj
-
-class TemplatesDict(dict):
- """Holds and wrap TemplateResults for easy bug report."""
-
- def __init__(self, in_data, key=None, parent=None, strict=None):
- super(TemplatesDict, self).__init__()
- for _key, _value in in_data.items():
- if isinstance(_value, dict):
- _value = self.__class__(_value, _key, self)
- self[_key] = _value
-
- self.key = key
- self.parent = parent
- self.strict = strict
- if self.parent is None and strict is None:
- self.strict = True
-
- def __getitem__(self, key):
- # Raise error about missing key in anatomy.yaml
- if key not in self.keys():
- hier = self.hierarchy()
- hier.append(key)
- raise TemplateMissingKey(hier)
-
- value = super(TemplatesDict, self).__getitem__(key)
- if isinstance(value, self.__class__):
- return value
-
- # Raise exception when expected solved templates and it is not.
- if (
- self.raise_on_unsolved
- and (hasattr(value, "solved") and not value.solved)
- ):
- raise TemplateUnsolved(
- value.template, value.missing_keys, value.invalid_types
+ def validate(self):
+ if not self.solved:
+ raise AnatomyTemplateUnsolved(
+ self.template,
+ self.missing_keys,
+ self.invalid_types
)
- return value
-
- @property
- def raise_on_unsolved(self):
- """To affect this change `strict` attribute."""
- if self.strict is not None:
- return self.strict
- return self.parent.raise_on_unsolved
-
- def hierarchy(self):
- """Return dictionary keys one by one to root parent."""
- if self.parent is None:
- return []
-
- hier_keys = []
- par_hier = self.parent.hierarchy()
- if par_hier:
- hier_keys.extend(par_hier)
- hier_keys.append(self.key)
-
- return hier_keys
-
- @property
- def missing_keys(self):
- """Return missing keys of all children templates."""
- missing_keys = []
- for value in self.values():
- missing_keys.extend(value.missing_keys)
- return list(set(missing_keys))
-
- @property
- def invalid_types(self):
- """Return invalid types of all children templates."""
- invalid_types = {}
- for value in self.values():
- for invalid_type in value.invalid_types:
- _invalid_types = {}
- for key, val in invalid_type.items():
- if key in invalid_types:
- continue
- _invalid_types[key] = val
- invalid_types = merge_dict(invalid_types, _invalid_types)
- return invalid_types
-
- @property
- def used_values(self):
- """Return used values for all children templates."""
- used_values = {}
- for value in self.values():
- used_values = merge_dict(used_values, value.used_values)
- return used_values
-
- def get_solved(self):
- """Get only solved key from templates."""
- result = {}
- for key, value in self.items():
- if isinstance(value, self.__class__):
- value = value.get_solved()
- if not value:
- continue
- result[key] = value
-
- elif (
- not hasattr(value, "solved") or
- value.solved
- ):
- result[key] = value
- return self.__class__(result, key=self.key, parent=self.parent)
-class Templates:
- key_pattern = re.compile(r"(\{.*?[^{0]*\})")
- key_padding_pattern = re.compile(r"([^:]+)\S+[><]\S+")
- sub_dict_pattern = re.compile(r"([^\[\]]+)")
- optional_pattern = re.compile(r"(<.*?[^{0]*>)[^0-9]*?")
-
+class AnatomyTemplates(TemplatesDict):
inner_key_pattern = re.compile(r"(\{@.*?[^{}0]*\})")
inner_key_name_pattern = re.compile(r"\{@(.*?[^{}0]*)\}")
def __init__(self, anatomy):
+ super(AnatomyTemplates, self).__init__()
self.anatomy = anatomy
self.loaded_project = None
- self._templates = None
def __getitem__(self, key):
return self.templates[key]
@@ -580,7 +402,9 @@ class Templates:
return self.templates.get(key, default)
def reset(self):
+ self._raw_templates = None
self._templates = None
+ self._objected_templates = None
@property
def project_name(self):
@@ -592,17 +416,66 @@ class Templates:
@property
def templates(self):
+ self._validate_discovery()
+ return self._templates
+
+ @property
+ def objected_templates(self):
+ self._validate_discovery()
+ return self._objected_templates
+
+ def _validate_discovery(self):
if self.project_name != self.loaded_project:
- self._templates = None
+ self.reset()
if self._templates is None:
- self._templates = self._discover()
+ self._discover()
self.loaded_project = self.project_name
- return self._templates
+
+ def _format_value(self, value, data):
+ if isinstance(value, RootItem):
+ return self._solve_dict(value, data)
+
+ result = super(AnatomyTemplates, self)._format_value(value, data)
+ if isinstance(result, TemplateResult):
+ rootless_path = self._rootless_path(result, data)
+ result = AnatomyTemplateResult(result, rootless_path)
+ return result
+
+ def set_templates(self, templates):
+ if not templates:
+ self.reset()
+ return
+
+ self._raw_templates = copy.deepcopy(templates)
+ templates = copy.deepcopy(templates)
+ v_queue = collections.deque()
+ v_queue.append(templates)
+ while v_queue:
+ item = v_queue.popleft()
+ if not isinstance(item, dict):
+ continue
+
+ for key in tuple(item.keys()):
+ value = item[key]
+ if isinstance(value, dict):
+ v_queue.append(value)
+
+ elif (
+ isinstance(value, StringType)
+ and "{task}" in value
+ ):
+ item[key] = value.replace("{task}", "{task[name]}")
+
+ solved_templates = self.solve_template_inner_links(templates)
+ self._templates = solved_templates
+ self._objected_templates = self.create_ojected_templates(
+ solved_templates
+ )
def default_templates(self):
"""Return default templates data with solved inner keys."""
- return Templates.solve_template_inner_links(
+ return self.solve_template_inner_links(
self.anatomy["templates"]
)
@@ -613,7 +486,7 @@ class Templates:
TODO: create templates if not exist.
Returns:
- TemplatesDict: Contain templates data for current project of
+ TemplatesResultDict: Contain templates data for current project of
default templates.
"""
@@ -624,7 +497,7 @@ class Templates:
" Trying to use default."
).format(self.project_name))
- return Templates.solve_template_inner_links(self.anatomy["templates"])
+ self.set_templates(self.anatomy["templates"])
@classmethod
def replace_inner_keys(cls, matches, value, key_values, key):
@@ -791,149 +664,6 @@ class Templates:
return keys_by_subkey
- def _filter_optional(self, template, data):
- """Filter invalid optional keys.
-
- Invalid keys may be missing keys of with invalid value DataType.
-
- Args:
- template (str): Anatomy template which will be formatted.
- data (dict): Containing keys to be filled into template.
-
- Result:
- tuple: Contain origin template without missing optional keys and
- without optional keys identificator ("<" and ">"), information
- about missing optional keys and invalid types of optional keys.
-
- """
-
- # Remove optional missing keys
- missing_keys = []
- invalid_types = []
- for optional_group in self.optional_pattern.findall(template):
- _missing_keys = []
- _invalid_types = []
- for optional_key in self.key_pattern.findall(optional_group):
- key = str(optional_key[1:-1])
- key_padding = list(
- self.key_padding_pattern.findall(key)
- )
- if key_padding:
- key = key_padding[0]
-
- validation_result = self._validate_data_key(
- key, data
- )
- missing_key = validation_result["missing_key"]
- invalid_type = validation_result["invalid_type"]
-
- valid = True
- if missing_key is not None:
- _missing_keys.append(missing_key)
- valid = False
-
- if invalid_type is not None:
- _invalid_types.append(invalid_type)
- valid = False
-
- if valid:
- try:
- optional_key.format(**data)
- except KeyError:
- _missing_keys.append(key)
- valid = False
-
- valid = len(_invalid_types) == 0 and len(_missing_keys) == 0
- missing_keys.extend(_missing_keys)
- invalid_types.extend(_invalid_types)
- replacement = ""
- if valid:
- replacement = optional_group[1:-1]
-
- template = template.replace(optional_group, replacement)
- return (template, missing_keys, invalid_types)
-
- def _validate_data_key(self, key, data):
- """Check and prepare missing keys and invalid types of template."""
- result = {
- "missing_key": None,
- "invalid_type": None
- }
-
- # check if key expects subdictionary keys (e.g. project[name])
- key_subdict = list(self.sub_dict_pattern.findall(key))
- used_keys = []
- if len(key_subdict) <= 1:
- if key not in data:
- result["missing_key"] = key
- return result
-
- used_keys.append(key)
- value = data[key]
-
- else:
- value = data
- missing_key = False
- invalid_type = False
- for sub_key in key_subdict:
- if (
- value is None
- or (hasattr(value, "items") and sub_key not in value)
- ):
- missing_key = True
- used_keys.append(sub_key)
- break
-
- elif not hasattr(value, "items"):
- invalid_type = True
- break
-
- used_keys.append(sub_key)
- value = value.get(sub_key)
-
- if missing_key or invalid_type:
- if len(used_keys) == 0:
- invalid_key = key_subdict[0]
- else:
- invalid_key = used_keys[0]
- for idx, sub_key in enumerate(used_keys):
- if idx == 0:
- continue
- invalid_key += "[{0}]".format(sub_key)
-
- if missing_key:
- result["missing_key"] = invalid_key
-
- elif invalid_type:
- result["invalid_type"] = {invalid_key: type(value)}
-
- return result
-
- if isinstance(value, (numbers.Number, Roots, RootItem)):
- return result
-
- for inh_class in type(value).mro():
- if inh_class == StringType:
- return result
-
- result["missing_key"] = key
- result["invalid_type"] = {key: type(value)}
- return result
-
- def _merge_used_values(self, current_used, keys, value):
- key = keys[0]
- _keys = keys[1:]
- if len(_keys) == 0:
- current_used[key] = value
- else:
- next_dict = {}
- if key in current_used:
- next_dict = current_used[key]
- current_used[key] = self._merge_used_values(
- next_dict, _keys, value
- )
- return current_used
-
def _dict_to_subkeys_list(self, subdict, pre_keys=None):
if pre_keys is None:
pre_keys = []
@@ -956,9 +686,11 @@ class Templates:
return {key_list[0]: value}
return {key_list[0]: self._keys_to_dicts(key_list[1:], value)}
- def _rootless_path(
- self, template, used_values, final_data, missing_keys, invalid_types
- ):
+ def _rootless_path(self, result, final_data):
+ used_values = result.used_values
+ missing_keys = result.missing_keys
+ template = result.template
+ invalid_types = result.invalid_types
if (
"root" not in used_values
or "root" in missing_keys
@@ -974,210 +706,49 @@ class Templates:
if not root_keys:
return
- roots_dict = {}
+ output = str(result)
for used_root_keys in root_keys:
if not used_root_keys:
continue
+ used_value = used_values
root_key = None
for key in used_root_keys:
+ used_value = used_value[key]
if root_key is None:
root_key = key
else:
root_key += "[{}]".format(key)
root_key = "{" + root_key + "}"
-
- roots_dict = merge_dict(
- roots_dict,
- self._keys_to_dicts(used_root_keys, root_key)
- )
-
- final_data["root"] = roots_dict["root"]
- return template.format(**final_data)
-
- def _format(self, orig_template, data):
- """ Figure out with whole formatting.
-
- Separate advanced keys (*Like '{project[name]}') from string which must
- be formatted separatelly in case of missing or incomplete keys in data.
-
- Args:
- template (str): Anatomy template which will be formatted.
- data (dict): Containing keys to be filled into template.
-
- Returns:
- TemplateResult: Filled or partially filled template containing all
- data needed or missing for filling template.
- """
- task_data = data.get("task")
- if (
- isinstance(task_data, StringType)
- and "{task[name]}" in orig_template
- ):
- # Change task to dictionary if template expect dictionary
- data["task"] = {"name": task_data}
-
- template, missing_optional, invalid_optional = (
- self._filter_optional(orig_template, data)
- )
- # Remove optional missing keys
- used_values = {}
- invalid_required = []
- missing_required = []
- replace_keys = []
-
- for group in self.key_pattern.findall(template):
- orig_key = group[1:-1]
- key = str(orig_key)
- key_padding = list(self.key_padding_pattern.findall(key))
- if key_padding:
- key = key_padding[0]
-
- validation_result = self._validate_data_key(key, data)
- missing_key = validation_result["missing_key"]
- invalid_type = validation_result["invalid_type"]
-
- if invalid_type is not None:
- invalid_required.append(invalid_type)
- replace_keys.append(key)
- continue
-
- if missing_key is not None:
- missing_required.append(missing_key)
- replace_keys.append(key)
- continue
-
- try:
- value = group.format(**data)
- key_subdict = list(self.sub_dict_pattern.findall(key))
- if len(key_subdict) <= 1:
- used_values[key] = value
-
- else:
- used_values = self._merge_used_values(
- used_values, key_subdict, value
- )
-
- except (TypeError, KeyError):
- missing_required.append(key)
- replace_keys.append(key)
-
- final_data = copy.deepcopy(data)
- for key in replace_keys:
- key_subdict = list(self.sub_dict_pattern.findall(key))
- if len(key_subdict) <= 1:
- final_data[key] = "{" + key + "}"
- continue
-
- replace_key_dst = "---".join(key_subdict)
- replace_key_dst_curly = "{" + replace_key_dst + "}"
- replace_key_src_curly = "{" + key + "}"
- template = template.replace(
- replace_key_src_curly, replace_key_dst_curly
- )
- final_data[replace_key_dst] = replace_key_src_curly
-
- solved = len(missing_required) == 0 and len(invalid_required) == 0
-
- missing_keys = missing_required + missing_optional
- invalid_types = invalid_required + invalid_optional
-
- filled_template = template.format(**final_data)
- # WARNING `_rootless_path` change values in `final_data` please keep
- # in midn when changing order
- rootless_path = self._rootless_path(
- template, used_values, final_data, missing_keys, invalid_types
- )
- if rootless_path is None:
- rootless_path = filled_template
-
- result = TemplateResult(
- filled_template, orig_template, solved, rootless_path,
- used_values, missing_keys, invalid_types
- )
- return result
-
- def solve_dict(self, templates, data):
- """ Solves templates with entered data.
-
- Args:
- templates (dict): All Anatomy templates which will be formatted.
- data (dict): Containing keys to be filled into template.
-
- Returns:
- dict: With `TemplateResult` in values containing filled or
- partially filled templates.
- """
- output = collections.defaultdict(dict)
- for key, orig_value in templates.items():
- if isinstance(orig_value, StringType):
- # Replace {task} by '{task[name]}' for backward compatibility
- if '{task}' in orig_value:
- orig_value = orig_value.replace('{task}', '{task[name]}')
-
- output[key] = self._format(orig_value, data)
- continue
-
- # Check if orig_value has items attribute (any dict inheritance)
- if not hasattr(orig_value, "items"):
- # TODO we should handle this case
- output[key] = orig_value
- continue
-
- for s_key, s_value in self.solve_dict(orig_value, data).items():
- output[key][s_key] = s_value
+ output = output.replace(str(used_value), root_key)
return output
+ def format(self, data, strict=True):
+ copy_data = copy.deepcopy(data)
+ roots = self.roots
+ if roots:
+ copy_data["root"] = roots
+ result = super(AnatomyTemplates, self).format(copy_data)
+ result.strict = strict
+ return result
+
def format_all(self, in_data, only_keys=True):
""" Solves templates based on entered data.
Args:
data (dict): Containing keys to be filled into template.
- only_keys (bool, optional): Decides if environ will be used to
- fill templates or only keys in data.
Returns:
- TemplatesDict: Output `TemplateResult` have `strict` attribute
- set to False so accessing unfilled keys in templates won't
- raise any exceptions.
+ TemplatesResultDict: Output `TemplateResult` have `strict`
+ attribute set to False so accessing unfilled keys in templates
+ won't raise any exceptions.
"""
- output = self.format(in_data, only_keys)
- output.strict = False
- return output
-
- def format(self, in_data, only_keys=True):
- """ Solves templates based on entered data.
-
- Args:
- data (dict): Containing keys to be filled into template.
- only_keys (bool, optional): Decides if environ will be used to
- fill templates or only keys in data.
-
- Returns:
- TemplatesDict: Output `TemplateResult` have `strict` attribute
- set to True so accessing unfilled keys in templates will
- raise exceptions with explaned error.
- """
- # Create a copy of inserted data
- data = copy.deepcopy(in_data)
-
- # Add environment variable to data
- if only_keys is False:
- for key, val in os.environ.items():
- data["$" + key] = val
-
- # override root value
- roots = self.roots
- if roots:
- data["root"] = roots
- solved = self.solve_dict(self.templates, data)
-
- return TemplatesDict(solved)
+ return self.format(in_data, strict=False)
-class RootItem:
+class RootItem(FormatObject):
"""Represents one item or roots.
Holds raw data of root item specification. Raw data contain value
diff --git a/openpype/lib/applications.py b/openpype/lib/applications.py
index 393c83e9be..5b32df066f 100644
--- a/openpype/lib/applications.py
+++ b/openpype/lib/applications.py
@@ -28,7 +28,8 @@ from .profiles_filtering import filter_profiles
from .local_settings import get_openpype_username
from .avalon_context import (
get_workdir_data,
- get_workdir_with_workdir_data
+ get_workdir_with_workdir_data,
+ get_workfile_template_key
)
from .python_module_tools import (
@@ -1295,7 +1296,7 @@ def get_app_environments_for_context(
"env": env
})
- prepare_host_environments(data, env_group)
+ prepare_app_environments(data, env_group)
prepare_context_environments(data, env_group)
# Discard avalon connection
@@ -1316,7 +1317,7 @@ def _merge_env(env, current_env):
return result
-def prepare_host_environments(data, env_group=None, implementation_envs=True):
+def prepare_app_environments(data, env_group=None, implementation_envs=True):
"""Modify launch environments based on launched app and context.
Args:
@@ -1474,6 +1475,22 @@ def prepare_context_environments(data, env_group=None):
)
app = data["app"]
+ context_env = {
+ "AVALON_PROJECT": project_doc["name"],
+ "AVALON_ASSET": asset_doc["name"],
+ "AVALON_TASK": task_name,
+ "AVALON_APP_NAME": app.full_name
+ }
+
+ log.debug(
+ "Context environments set:\n{}".format(
+ json.dumps(context_env, indent=4)
+ )
+ )
+ data["env"].update(context_env)
+ if not app.is_host:
+ return
+
workdir_data = get_workdir_data(
project_doc, asset_doc, task_name, app.host_name
)
@@ -1504,20 +1521,8 @@ def prepare_context_environments(data, env_group=None):
"Couldn't create workdir because: {}".format(str(exc))
)
- context_env = {
- "AVALON_PROJECT": project_doc["name"],
- "AVALON_ASSET": asset_doc["name"],
- "AVALON_TASK": task_name,
- "AVALON_APP": app.host_name,
- "AVALON_APP_NAME": app.full_name,
- "AVALON_WORKDIR": workdir
- }
- log.debug(
- "Context environments set:\n{}".format(
- json.dumps(context_env, indent=4)
- )
- )
- data["env"].update(context_env)
+ data["env"]["AVALON_APP"] = app.host_name
+ data["env"]["AVALON_WORKDIR"] = workdir
_prepare_last_workfile(data, workdir)
@@ -1587,14 +1592,15 @@ def _prepare_last_workfile(data, workdir):
last_workfile_path = data.get("last_workfile_path") or ""
if not last_workfile_path:
extensions = avalon.api.HOST_WORKFILE_EXTENSIONS.get(app.host_name)
-
if extensions:
anatomy = data["anatomy"]
+ project_settings = data["project_settings"]
+ task_type = workdir_data["task"]["type"]
+ template_key = get_workfile_template_key(
+ task_type, app.host_name, project_settings=project_settings
+ )
# Find last workfile
- file_template = anatomy.templates["work"]["file"]
- # Replace {task} by '{task[name]}' for backward compatibility
- if '{task}' in file_template:
- file_template = file_template.replace('{task}', '{task[name]}')
+ file_template = str(anatomy.templates[template_key]["file"])
workdir_data.update({
"version": 1,
diff --git a/openpype/lib/avalon_context.py b/openpype/lib/avalon_context.py
index 3ce205c499..1e8d21852b 100644
--- a/openpype/lib/avalon_context.py
+++ b/openpype/lib/avalon_context.py
@@ -952,7 +952,7 @@ class BuildWorkfile:
Returns:
(dict): preset per entered task name
"""
- host_name = avalon.api.registered_host().__name__.rsplit(".", 1)[-1]
+ host_name = os.environ["AVALON_APP"]
project_settings = get_project_settings(
avalon.io.Session["AVALON_PROJECT"]
)
diff --git a/openpype/lib/delivery.py b/openpype/lib/delivery.py
index 01fcc907ed..a61603fa05 100644
--- a/openpype/lib/delivery.py
+++ b/openpype/lib/delivery.py
@@ -17,7 +17,7 @@ def collect_frames(files):
Returns:
(dict): {'/asset/subset_v001.0001.png': '0001', ....}
"""
- collections, remainder = clique.assemble(files)
+ collections, remainder = clique.assemble(files, minimum_items=1)
sources_and_frames = {}
if collections:
diff --git a/openpype/lib/execute.py b/openpype/lib/execute.py
index afde844f2d..f2eb97c5f5 100644
--- a/openpype/lib/execute.py
+++ b/openpype/lib/execute.py
@@ -1,5 +1,9 @@
import os
+import sys
import subprocess
+import platform
+import json
+import tempfile
import distutils.spawn
from .log import PypeLogger as Logger
@@ -181,6 +185,80 @@ def run_openpype_process(*args, **kwargs):
return run_subprocess(args, env=env, **kwargs)
+def run_detached_process(args, **kwargs):
+ """Execute process with passed arguments as separated process.
+
+ Values from 'os.environ' are used for environments if are not passed.
+ They are cleaned using 'clean_envs_for_openpype_process' function.
+
+ Example:
+ ```
+ run_detached_openpype_process("run", "")
+ ```
+
+ Args:
+ *args (tuple): OpenPype cli arguments.
+ **kwargs (dict): Keyword arguments for for subprocess.Popen.
+
+ Returns:
+ subprocess.Popen: Pointer to launched process but it is possible that
+ launched process is already killed (on linux).
+ """
+ env = kwargs.pop("env", None)
+ # Keep env untouched if are passed and not empty
+ if not env:
+ env = os.environ
+
+ # Create copy of passed env
+ kwargs["env"] = {k: v for k, v in env.items()}
+
+ low_platform = platform.system().lower()
+ if low_platform == "darwin":
+ new_args = ["open", "-na", args.pop(0), "--args"]
+ new_args.extend(args)
+ args = new_args
+
+ elif low_platform == "windows":
+ flags = (
+ subprocess.CREATE_NEW_PROCESS_GROUP
+ | subprocess.DETACHED_PROCESS
+ )
+ kwargs["creationflags"] = flags
+
+ if not sys.stdout:
+ kwargs["stdout"] = subprocess.DEVNULL
+ kwargs["stderr"] = subprocess.DEVNULL
+
+ elif low_platform == "linux" and get_linux_launcher_args() is not None:
+ json_data = {
+ "args": args,
+ "env": kwargs.pop("env")
+ }
+ json_temp = tempfile.NamedTemporaryFile(
+ mode="w", prefix="op_app_args", suffix=".json", delete=False
+ )
+ json_temp.close()
+ json_temp_filpath = json_temp.name
+ with open(json_temp_filpath, "w") as stream:
+ json.dump(json_data, stream)
+
+ new_args = get_linux_launcher_args()
+ new_args.append(json_temp_filpath)
+
+ # Create mid-process which will launch application
+ process = subprocess.Popen(new_args, **kwargs)
+ # Wait until the process finishes
+ # - This is important! The process would stay in "open" state.
+ process.wait()
+ # Remove the temp file
+ os.remove(json_temp_filpath)
+ # Return process which is already terminated
+ return process
+
+ process = subprocess.Popen(args, **kwargs)
+ return process
+
+
def path_to_subprocess_arg(path):
"""Prepare path for subprocess arguments.
diff --git a/openpype/lib/path_templates.py b/openpype/lib/path_templates.py
new file mode 100644
index 0000000000..62bfdf774a
--- /dev/null
+++ b/openpype/lib/path_templates.py
@@ -0,0 +1,778 @@
+import os
+import re
+import copy
+import numbers
+import collections
+
+import six
+
+from .log import PypeLogger
+
+log = PypeLogger.get_logger(__name__)
+
+
+KEY_PATTERN = re.compile(r"(\{.*?[^{0]*\})")
+KEY_PADDING_PATTERN = re.compile(r"([^:]+)\S+[><]\S+")
+SUB_DICT_PATTERN = re.compile(r"([^\[\]]+)")
+OPTIONAL_PATTERN = re.compile(r"(<.*?[^{0]*>)[^0-9]*?")
+
+
+def merge_dict(main_dict, enhance_dict):
+ """Merges dictionaries by keys.
+
+ Function call itself if value on key is again dictionary.
+
+ Args:
+ main_dict (dict): First dict to merge second one into.
+ enhance_dict (dict): Second dict to be merged.
+
+ Returns:
+ dict: Merged result.
+
+ .. note:: does not overrides whole value on first found key
+ but only values differences from enhance_dict
+
+ """
+ for key, value in enhance_dict.items():
+ if key not in main_dict:
+ main_dict[key] = value
+ elif isinstance(value, dict) and isinstance(main_dict[key], dict):
+ main_dict[key] = merge_dict(main_dict[key], value)
+ else:
+ main_dict[key] = value
+ return main_dict
+
+
+class TemplateMissingKey(Exception):
+ """Exception for cases when key does not exist in template."""
+
+ msg = "Template key does not exist: `{}`."
+
+ def __init__(self, parents):
+ parent_join = "".join(["[\"{0}\"]".format(key) for key in parents])
+ super(TemplateMissingKey, self).__init__(
+ self.msg.format(parent_join)
+ )
+
+
+class TemplateUnsolved(Exception):
+ """Exception for unsolved template when strict is set to True."""
+
+ msg = "Template \"{0}\" is unsolved.{1}{2}"
+ invalid_types_msg = " Keys with invalid DataType: `{0}`."
+ missing_keys_msg = " Missing keys: \"{0}\"."
+
+ def __init__(self, template, missing_keys, invalid_types):
+ invalid_type_items = []
+ for _key, _type in invalid_types.items():
+ invalid_type_items.append(
+ "\"{0}\" {1}".format(_key, str(_type))
+ )
+
+ invalid_types_msg = ""
+ if invalid_type_items:
+ invalid_types_msg = self.invalid_types_msg.format(
+ ", ".join(invalid_type_items)
+ )
+
+ missing_keys_msg = ""
+ if missing_keys:
+ missing_keys_msg = self.missing_keys_msg.format(
+ ", ".join(missing_keys)
+ )
+ super(TemplateUnsolved, self).__init__(
+ self.msg.format(template, missing_keys_msg, invalid_types_msg)
+ )
+
+
+class StringTemplate(object):
+ """String that can be formatted."""
+ def __init__(self, template):
+ if not isinstance(template, six.string_types):
+ raise TypeError("<{}> argument must be a string, not {}.".format(
+ self.__class__.__name__, str(type(template))
+ ))
+
+ self._template = template
+ parts = []
+ last_end_idx = 0
+ for item in KEY_PATTERN.finditer(template):
+ start, end = item.span()
+ if start > last_end_idx:
+ parts.append(template[last_end_idx:start])
+ parts.append(FormattingPart(template[start:end]))
+ last_end_idx = end
+
+ if last_end_idx < len(template):
+ parts.append(template[last_end_idx:len(template)])
+
+ new_parts = []
+ for part in parts:
+ if not isinstance(part, six.string_types):
+ new_parts.append(part)
+ continue
+
+ substr = ""
+ for char in part:
+ if char not in ("<", ">"):
+ substr += char
+ else:
+ if substr:
+ new_parts.append(substr)
+ new_parts.append(char)
+ substr = ""
+ if substr:
+ new_parts.append(substr)
+
+ self._parts = self.find_optional_parts(new_parts)
+
+ def __str__(self):
+ return self.template
+
+ def __repr__(self):
+ return "<{}> {}".format(self.__class__.__name__, self.template)
+
+ def __contains__(self, other):
+ return other in self.template
+
+ def replace(self, *args, **kwargs):
+ self._template = self.template.replace(*args, **kwargs)
+ return self
+
+ @property
+ def template(self):
+ return self._template
+
+ def format(self, data):
+ """ Figure out with whole formatting.
+
+ Separate advanced keys (*Like '{project[name]}') from string which must
+ be formatted separatelly in case of missing or incomplete keys in data.
+
+ Args:
+ data (dict): Containing keys to be filled into template.
+
+ Returns:
+ TemplateResult: Filled or partially filled template containing all
+ data needed or missing for filling template.
+ """
+ result = TemplatePartResult()
+ for part in self._parts:
+ if isinstance(part, six.string_types):
+ result.add_output(part)
+ else:
+ part.format(data, result)
+
+ invalid_types = result.invalid_types
+ invalid_types.update(result.invalid_optional_types)
+ invalid_types = result.split_keys_to_subdicts(invalid_types)
+
+ missing_keys = result.missing_keys
+ missing_keys |= result.missing_optional_keys
+
+ solved = result.solved
+ used_values = result.get_clean_used_values()
+
+ return TemplateResult(
+ result.output,
+ self.template,
+ solved,
+ used_values,
+ missing_keys,
+ invalid_types
+ )
+
+ def format_strict(self, *args, **kwargs):
+ result = self.format(*args, **kwargs)
+ result.validate()
+ return result
+
+ @staticmethod
+ def find_optional_parts(parts):
+ new_parts = []
+ tmp_parts = {}
+ counted_symb = -1
+ for part in parts:
+ if part == "<":
+ counted_symb += 1
+ tmp_parts[counted_symb] = []
+
+ elif part == ">":
+ if counted_symb > -1:
+ parts = tmp_parts.pop(counted_symb)
+ counted_symb -= 1
+ if parts:
+ # Remove optional start char
+ parts.pop(0)
+ if counted_symb < 0:
+ out_parts = new_parts
+ else:
+ out_parts = tmp_parts[counted_symb]
+ # Store temp parts
+ out_parts.append(OptionalPart(parts))
+ continue
+
+ if counted_symb < 0:
+ new_parts.append(part)
+ else:
+ tmp_parts[counted_symb].append(part)
+
+ if tmp_parts:
+ for idx in sorted(tmp_parts.keys()):
+ new_parts.extend(tmp_parts[idx])
+ return new_parts
+
+
+class TemplatesDict(object):
+ def __init__(self, templates=None):
+ self._raw_templates = None
+ self._templates = None
+ self._objected_templates = None
+ self.set_templates(templates)
+
+ def set_templates(self, templates):
+ if templates is None:
+ self._raw_templates = None
+ self._templates = None
+ self._objected_templates = None
+ elif isinstance(templates, dict):
+ self._raw_templates = copy.deepcopy(templates)
+ self._templates = templates
+ self._objected_templates = self.create_ojected_templates(templates)
+ else:
+ raise TypeError("<{}> argument must be a dict, not {}.".format(
+ self.__class__.__name__, str(type(templates))
+ ))
+
+ def __getitem__(self, key):
+ return self.templates[key]
+
+ def get(self, key, *args, **kwargs):
+ return self.templates.get(key, *args, **kwargs)
+
+ @property
+ def raw_templates(self):
+ return self._raw_templates
+
+ @property
+ def templates(self):
+ return self._templates
+
+ @property
+ def objected_templates(self):
+ return self._objected_templates
+
+ @classmethod
+ def create_ojected_templates(cls, templates):
+ if not isinstance(templates, dict):
+ raise TypeError("Expected dict object, got {}".format(
+ str(type(templates))
+ ))
+
+ objected_templates = copy.deepcopy(templates)
+ inner_queue = collections.deque()
+ inner_queue.append(objected_templates)
+ while inner_queue:
+ item = inner_queue.popleft()
+ if not isinstance(item, dict):
+ continue
+ for key in tuple(item.keys()):
+ value = item[key]
+ if isinstance(value, six.string_types):
+ item[key] = StringTemplate(value)
+ elif isinstance(value, dict):
+ inner_queue.append(value)
+ return objected_templates
+
+ def _format_value(self, value, data):
+ if isinstance(value, StringTemplate):
+ return value.format(data)
+
+ if isinstance(value, dict):
+ return self._solve_dict(value, data)
+ return value
+
+ def _solve_dict(self, templates, data):
+ """ Solves templates with entered data.
+
+ Args:
+ templates (dict): All templates which will be formatted.
+ data (dict): Containing keys to be filled into template.
+
+ Returns:
+ dict: With `TemplateResult` in values containing filled or
+ partially filled templates.
+ """
+ output = collections.defaultdict(dict)
+ for key, value in templates.items():
+ output[key] = self._format_value(value, data)
+
+ return output
+
+ def format(self, in_data, only_keys=True, strict=True):
+ """ Solves templates based on entered data.
+
+ Args:
+ data (dict): Containing keys to be filled into template.
+ only_keys (bool, optional): Decides if environ will be used to
+ fill templates or only keys in data.
+
+ Returns:
+ TemplatesResultDict: Output `TemplateResult` have `strict`
+ attribute set to True so accessing unfilled keys in templates
+ will raise exceptions with explaned error.
+ """
+ # Create a copy of inserted data
+ data = copy.deepcopy(in_data)
+
+ # Add environment variable to data
+ if only_keys is False:
+ for key, val in os.environ.items():
+ env_key = "$" + key
+ if env_key not in data:
+ data[env_key] = val
+
+ solved = self._solve_dict(self.objected_templates, data)
+
+ output = TemplatesResultDict(solved)
+ output.strict = strict
+ return output
+
+
+class TemplateResult(str):
+ """Result of template format with most of information in.
+
+ Args:
+ used_values (dict): Dictionary of template filling data with
+ only used keys.
+ solved (bool): For check if all required keys were filled.
+ template (str): Original template.
+ missing_keys (list): Missing keys that were not in the data. Include
+ missing optional keys.
+ invalid_types (dict): When key was found in data, but value had not
+ allowed DataType. Allowed data types are `numbers`,
+ `str`(`basestring`) and `dict`. Dictionary may cause invalid type
+ when value of key in data is dictionary but template expect string
+ of number.
+ """
+ used_values = None
+ solved = None
+ template = None
+ missing_keys = None
+ invalid_types = None
+
+ def __new__(
+ cls, filled_template, template, solved,
+ used_values, missing_keys, invalid_types
+ ):
+ new_obj = super(TemplateResult, cls).__new__(cls, filled_template)
+ new_obj.used_values = used_values
+ new_obj.solved = solved
+ new_obj.template = template
+ new_obj.missing_keys = list(set(missing_keys))
+ new_obj.invalid_types = invalid_types
+ return new_obj
+
+ def validate(self):
+ if not self.solved:
+ raise TemplateUnsolved(
+ self.template,
+ self.missing_keys,
+ self.invalid_types
+ )
+
+
+class TemplatesResultDict(dict):
+ """Holds and wrap TemplateResults for easy bug report."""
+
+ def __init__(self, in_data, key=None, parent=None, strict=None):
+ super(TemplatesResultDict, self).__init__()
+ for _key, _value in in_data.items():
+ if isinstance(_value, dict):
+ _value = self.__class__(_value, _key, self)
+ self[_key] = _value
+
+ self.key = key
+ self.parent = parent
+ self.strict = strict
+ if self.parent is None and strict is None:
+ self.strict = True
+
+ def __getitem__(self, key):
+ if key not in self.keys():
+ hier = self.hierarchy()
+ hier.append(key)
+ raise TemplateMissingKey(hier)
+
+ value = super(TemplatesResultDict, self).__getitem__(key)
+ if isinstance(value, self.__class__):
+ return value
+
+ # Raise exception when expected solved templates and it is not.
+ if self.raise_on_unsolved and hasattr(value, "validate"):
+ value.validate()
+ return value
+
+ @property
+ def raise_on_unsolved(self):
+ """To affect this change `strict` attribute."""
+ if self.strict is not None:
+ return self.strict
+ return self.parent.raise_on_unsolved
+
+ def hierarchy(self):
+ """Return dictionary keys one by one to root parent."""
+ if self.parent is None:
+ return []
+
+ hier_keys = []
+ par_hier = self.parent.hierarchy()
+ if par_hier:
+ hier_keys.extend(par_hier)
+ hier_keys.append(self.key)
+
+ return hier_keys
+
+ @property
+ def missing_keys(self):
+ """Return missing keys of all children templates."""
+ missing_keys = set()
+ for value in self.values():
+ missing_keys |= value.missing_keys
+ return missing_keys
+
+ @property
+ def invalid_types(self):
+ """Return invalid types of all children templates."""
+ invalid_types = {}
+ for value in self.values():
+ invalid_types = merge_dict(invalid_types, value.invalid_types)
+ return invalid_types
+
+ @property
+ def used_values(self):
+ """Return used values for all children templates."""
+ used_values = {}
+ for value in self.values():
+ used_values = merge_dict(used_values, value.used_values)
+ return used_values
+
+ def get_solved(self):
+ """Get only solved key from templates."""
+ result = {}
+ for key, value in self.items():
+ if isinstance(value, self.__class__):
+ value = value.get_solved()
+ if not value:
+ continue
+ result[key] = value
+
+ elif (
+ not hasattr(value, "solved") or
+ value.solved
+ ):
+ result[key] = value
+ return self.__class__(result, key=self.key, parent=self.parent)
+
+
+class TemplatePartResult:
+ """Result to store result of template parts."""
+ def __init__(self, optional=False):
+ # Missing keys or invalid value types of required keys
+ self._missing_keys = set()
+ self._invalid_types = {}
+ # Missing keys or invalid value types of optional keys
+ self._missing_optional_keys = set()
+ self._invalid_optional_types = {}
+
+ # Used values stored by key with origin type
+ # - key without any padding or key modifiers
+ # - value from filling data
+ # Example: {"version": 1}
+ self._used_values = {}
+ # Used values stored by key with all modifirs
+ # - value is already formatted string
+ # Example: {"version:0>3": "001"}
+ self._realy_used_values = {}
+ # Concatenated string output after formatting
+ self._output = ""
+ # Is this result from optional part
+ self._optional = True
+
+ def add_output(self, other):
+ if isinstance(other, six.string_types):
+ self._output += other
+
+ elif isinstance(other, TemplatePartResult):
+ self._output += other.output
+
+ self._missing_keys |= other.missing_keys
+ self._missing_optional_keys |= other.missing_optional_keys
+
+ self._invalid_types.update(other.invalid_types)
+ self._invalid_optional_types.update(other.invalid_optional_types)
+
+ if other.optional and not other.solved:
+ return
+ self._used_values.update(other.used_values)
+ self._realy_used_values.update(other.realy_used_values)
+
+ else:
+ raise TypeError("Cannot add data from \"{}\" to \"{}\"".format(
+ str(type(other)), self.__class__.__name__)
+ )
+
+ @property
+ def solved(self):
+ if self.optional:
+ if (
+ len(self.missing_optional_keys) > 0
+ or len(self.invalid_optional_types) > 0
+ ):
+ return False
+ return (
+ len(self.missing_keys) == 0
+ and len(self.invalid_types) == 0
+ )
+
+ @property
+ def optional(self):
+ return self._optional
+
+ @property
+ def output(self):
+ return self._output
+
+ @property
+ def missing_keys(self):
+ return self._missing_keys
+
+ @property
+ def missing_optional_keys(self):
+ return self._missing_optional_keys
+
+ @property
+ def invalid_types(self):
+ return self._invalid_types
+
+ @property
+ def invalid_optional_types(self):
+ return self._invalid_optional_types
+
+ @property
+ def realy_used_values(self):
+ return self._realy_used_values
+
+ @property
+ def used_values(self):
+ return self._used_values
+
+ @staticmethod
+ def split_keys_to_subdicts(values):
+ output = {}
+ for key, value in values.items():
+ key_padding = list(KEY_PADDING_PATTERN.findall(key))
+ if key_padding:
+ key = key_padding[0]
+ key_subdict = list(SUB_DICT_PATTERN.findall(key))
+ data = output
+ last_key = key_subdict.pop(-1)
+ for subkey in key_subdict:
+ if subkey not in data:
+ data[subkey] = {}
+ data = data[subkey]
+ data[last_key] = value
+ return output
+
+ def get_clean_used_values(self):
+ new_used_values = {}
+ for key, value in self.used_values.items():
+ if isinstance(value, FormatObject):
+ value = str(value)
+ new_used_values[key] = value
+
+ return self.split_keys_to_subdicts(new_used_values)
+
+ def add_realy_used_value(self, key, value):
+ self._realy_used_values[key] = value
+
+ def add_used_value(self, key, value):
+ self._used_values[key] = value
+
+ def add_missing_key(self, key):
+ if self._optional:
+ self._missing_optional_keys.add(key)
+ else:
+ self._missing_keys.add(key)
+
+ def add_invalid_type(self, key, value):
+ if self._optional:
+ self._invalid_optional_types[key] = type(value)
+ else:
+ self._invalid_types[key] = type(value)
+
+
+class FormatObject(object):
+ """Object that can be used for formatting.
+
+ This is base that is valid for to be used in 'StringTemplate' value.
+ """
+ def __init__(self):
+ self.value = ""
+
+ def __format__(self, *args, **kwargs):
+ return self.value.__format__(*args, **kwargs)
+
+ def __str__(self):
+ return str(self.value)
+
+ def __repr__(self):
+ return self.__str__()
+
+
+class FormattingPart:
+ """String with formatting template.
+
+ Containt only single key to format e.g. "{project[name]}".
+
+ Args:
+ template(str): String containing the formatting key.
+ """
+ def __init__(self, template):
+ self._template = template
+
+ @property
+ def template(self):
+ return self._template
+
+ def __repr__(self):
+ return "".format(self._template)
+
+ def __str__(self):
+ return self._template
+
+ @staticmethod
+ def validate_value_type(value):
+ """Check if value can be used for formatting of single key."""
+ if isinstance(value, (numbers.Number, FormatObject)):
+ return True
+
+ for inh_class in type(value).mro():
+ if inh_class in six.string_types:
+ return True
+ return False
+
+ def format(self, data, result):
+ """Format the formattings string.
+
+ Args:
+ data(dict): Data that should be used for formatting.
+ result(TemplatePartResult): Object where result is stored.
+ """
+ key = self.template[1:-1]
+ if key in result.realy_used_values:
+ result.add_output(result.realy_used_values[key])
+ return result
+
+ # check if key expects subdictionary keys (e.g. project[name])
+ existence_check = key
+ key_padding = list(KEY_PADDING_PATTERN.findall(existence_check))
+ if key_padding:
+ existence_check = key_padding[0]
+ key_subdict = list(SUB_DICT_PATTERN.findall(existence_check))
+
+ value = data
+ missing_key = False
+ invalid_type = False
+ used_keys = []
+ for sub_key in key_subdict:
+ if (
+ value is None
+ or (hasattr(value, "items") and sub_key not in value)
+ ):
+ missing_key = True
+ used_keys.append(sub_key)
+ break
+
+ if not hasattr(value, "items"):
+ invalid_type = True
+ break
+
+ used_keys.append(sub_key)
+ value = value.get(sub_key)
+
+ if missing_key or invalid_type:
+ if len(used_keys) == 0:
+ invalid_key = key_subdict[0]
+ else:
+ invalid_key = used_keys[0]
+ for idx, sub_key in enumerate(used_keys):
+ if idx == 0:
+ continue
+ invalid_key += "[{0}]".format(sub_key)
+
+ if missing_key:
+ result.add_missing_key(invalid_key)
+
+ elif invalid_type:
+ result.add_invalid_type(invalid_key, value)
+
+ result.add_output(self.template)
+ return result
+
+ if self.validate_value_type(value):
+ fill_data = {}
+ first_value = True
+ for used_key in reversed(used_keys):
+ if first_value:
+ first_value = False
+ fill_data[used_key] = value
+ else:
+ _fill_data = {used_key: fill_data}
+ fill_data = _fill_data
+
+ formatted_value = self.template.format(**fill_data)
+ result.add_realy_used_value(key, formatted_value)
+ result.add_used_value(existence_check, formatted_value)
+ result.add_output(formatted_value)
+ return result
+
+ result.add_invalid_type(key, value)
+ result.add_output(self.template)
+
+ return result
+
+
+class OptionalPart:
+ """Template part which contains optional formatting strings.
+
+ If this part can't be filled the result is empty string.
+
+ Args:
+ parts(list): Parts of template. Can contain 'str', 'OptionalPart' or
+ 'FormattingPart'.
+ """
+ def __init__(self, parts):
+ self._parts = parts
+
+ @property
+ def parts(self):
+ return self._parts
+
+ def __str__(self):
+ return "<{}>".format("".join([str(p) for p in self._parts]))
+
+ def __repr__(self):
+ return "".format("".join([str(p) for p in self._parts]))
+
+ def format(self, data, result):
+ new_result = TemplatePartResult(True)
+ for part in self._parts:
+ if isinstance(part, six.string_types):
+ new_result.add_output(part)
+ else:
+ part.format(data, new_result)
+
+ if new_result.solved:
+ result.add_output(new_result)
+ return result
diff --git a/openpype/modules/base.py b/openpype/modules/base.py
index 213a7681f5..c7078475df 100644
--- a/openpype/modules/base.py
+++ b/openpype/modules/base.py
@@ -44,6 +44,7 @@ DEFAULT_OPENPYPE_MODULES = (
"project_manager_action",
"settings_action",
"standalonepublish_action",
+ "traypublish_action",
"job_queue",
"timers_manager",
"sync_server",
@@ -846,6 +847,7 @@ class TrayModulesManager(ModulesManager):
"avalon",
"clockify",
"standalonepublish_tool",
+ "traypublish_tool",
"log_viewer",
"local_settings",
"settings"
diff --git a/openpype/modules/deadline/plugins/publish/submit_houdini_render_deadline.py b/openpype/modules/deadline/plugins/publish/submit_houdini_render_deadline.py
index 2cd6b0e6b0..59aeb68b79 100644
--- a/openpype/modules/deadline/plugins/publish/submit_houdini_render_deadline.py
+++ b/openpype/modules/deadline/plugins/publish/submit_houdini_render_deadline.py
@@ -50,8 +50,8 @@ class HoudiniSubmitRenderDeadline(pyblish.api.InstancePlugin):
# StartFrame to EndFrame by byFrameStep
frames = "{start}-{end}x{step}".format(
- start=int(instance.data["startFrame"]),
- end=int(instance.data["endFrame"]),
+ start=int(instance.data["frameStart"]),
+ end=int(instance.data["frameEnd"]),
step=int(instance.data["byFrameStep"]),
)
diff --git a/openpype/modules/deadline/plugins/publish/submit_publish_job.py b/openpype/modules/deadline/plugins/publish/submit_publish_job.py
index a77a968815..c7a14791e4 100644
--- a/openpype/modules/deadline/plugins/publish/submit_publish_job.py
+++ b/openpype/modules/deadline/plugins/publish/submit_publish_job.py
@@ -316,8 +316,8 @@ class ProcessSubmittedJobOnFarm(pyblish.api.InstancePlugin):
import speedcopy
self.log.info("Preparing to copy ...")
- start = instance.data.get("startFrame")
- end = instance.data.get("endFrame")
+ start = instance.data.get("frameStart")
+ end = instance.data.get("frameEnd")
# get latest version of subset
# this will stop if subset wasn't published yet
diff --git a/openpype/modules/deadline/plugins/publish/validate_expected_and_rendered_files.py b/openpype/modules/deadline/plugins/publish/validate_expected_and_rendered_files.py
index 615ba53c1a..d49e314179 100644
--- a/openpype/modules/deadline/plugins/publish/validate_expected_and_rendered_files.py
+++ b/openpype/modules/deadline/plugins/publish/validate_expected_and_rendered_files.py
@@ -1,5 +1,4 @@
import os
-import json
import requests
import pyblish.api
@@ -30,47 +29,58 @@ class ValidateExpectedFiles(pyblish.api.InstancePlugin):
staging_dir = repre["stagingDir"]
existing_files = self._get_existing_files(staging_dir)
- expected_non_existent = expected_files.difference(
- existing_files)
- if len(expected_non_existent) != 0:
- self.log.info("Some expected files missing {}".format(
- expected_non_existent))
+ if self.allow_user_override:
+ # We always check for user override because the user might have
+ # also overridden the Job frame list to be longer than the
+ # originally submitted frame range
+ # todo: We should first check if Job frame range was overridden
+ # at all so we don't unnecessarily override anything
+ file_name_template, frame_placeholder = \
+ self._get_file_name_template_and_placeholder(
+ expected_files)
- if self.allow_user_override:
- file_name_template, frame_placeholder = \
- self._get_file_name_template_and_placeholder(
- expected_files)
+ if not file_name_template:
+ raise RuntimeError("Unable to retrieve file_name template"
+ "from files: {}".format(expected_files))
- if not file_name_template:
- return
+ job_expected_files = self._get_job_expected_files(
+ file_name_template,
+ frame_placeholder,
+ frame_list)
- real_expected_rendered = self._get_real_render_expected(
- file_name_template,
- frame_placeholder,
- frame_list)
+ job_files_diff = job_expected_files.difference(expected_files)
+ if job_files_diff:
+ self.log.debug(
+ "Detected difference in expected output files from "
+ "Deadline job. Assuming an updated frame list by the "
+ "user. Difference: {}".format(sorted(job_files_diff))
+ )
- real_expected_non_existent = \
- real_expected_rendered.difference(existing_files)
- if len(real_expected_non_existent) != 0:
- raise RuntimeError("Still missing some files {}".
- format(real_expected_non_existent))
- self.log.info("Update range from actual job range")
- repre["files"] = sorted(list(real_expected_rendered))
- else:
- raise RuntimeError("Some expected files missing {}".format(
- expected_non_existent))
+ # Update the representation expected files
+ self.log.info("Update range from actual job range "
+ "to frame list: {}".format(frame_list))
+ repre["files"] = sorted(job_expected_files)
+
+ # Update the expected files
+ expected_files = job_expected_files
+
+ # We don't use set.difference because we do allow other existing
+ # files to be in the folder that we might not want to use.
+ missing = expected_files - existing_files
+ if missing:
+ raise RuntimeError("Missing expected files: {}".format(
+ sorted(missing)))
def _get_frame_list(self, original_job_id):
- """
- Returns list of frame ranges from all render job.
+ """Returns list of frame ranges from all render job.
- Render job might be requeried so job_id in metadata.json is invalid
- GlobalJobPreload injects current ids to RENDER_JOB_IDS.
+ Render job might be re-submitted so job_id in metadata.json could be
+ invalid. GlobalJobPreload injects current job id to RENDER_JOB_IDS.
- Args:
- original_job_id (str)
- Returns:
- (list)
+ Args:
+ original_job_id (str)
+ Returns:
+ (list)
"""
all_frame_lists = []
render_job_ids = os.environ.get("RENDER_JOB_IDS")
@@ -87,13 +97,15 @@ class ValidateExpectedFiles(pyblish.api.InstancePlugin):
return all_frame_lists
- def _get_real_render_expected(self, file_name_template, frame_placeholder,
- frame_list):
- """
- Calculates list of names of expected rendered files.
+ def _get_job_expected_files(self,
+ file_name_template,
+ frame_placeholder,
+ frame_list):
+ """Calculates list of names of expected rendered files.
+
+ Might be different from expected files from submission if user
+ explicitly and manually changed the frame list on the Deadline job.
- Might be different from job expected files if user explicitly and
- manually change frame list on Deadline job.
"""
real_expected_rendered = set()
src_padding_exp = "%0{}d".format(len(frame_placeholder))
@@ -115,6 +127,14 @@ class ValidateExpectedFiles(pyblish.api.InstancePlugin):
file_name_template = frame_placeholder = None
for file_name, frame in sources_and_frames.items():
+
+ # There might be cases where clique was unable to collect
+ # collections in `collect_frames` - thus we capture that case
+ if frame is None:
+ self.log.warning("Unable to detect frame from filename: "
+ "{}".format(file_name))
+ continue
+
frame_placeholder = "#" * len(frame)
file_name_template = os.path.basename(
file_name.replace(frame, frame_placeholder))
@@ -123,11 +143,11 @@ class ValidateExpectedFiles(pyblish.api.InstancePlugin):
return file_name_template, frame_placeholder
def _get_job_info(self, job_id):
- """
- Calls DL for actual job info for 'job_id'
+ """Calls DL for actual job info for 'job_id'
+
+ Might be different than job info saved in metadata.json if user
+ manually changes job pre/during rendering.
- Might be different than job info saved in metadata.json if user
- manually changes job pre/during rendering.
"""
# get default deadline webservice url from deadline module
deadline_url = self.instance.context.data["defaultDeadline"]
@@ -140,8 +160,8 @@ class ValidateExpectedFiles(pyblish.api.InstancePlugin):
try:
response = requests_get(url)
except requests.exceptions.ConnectionError:
- print("Deadline is not accessible at {}".format(deadline_url))
- # self.log("Deadline is not accessible at {}".format(deadline_url))
+ self.log.error("Deadline is not accessible at "
+ "{}".format(deadline_url))
return {}
if not response.ok:
@@ -155,29 +175,26 @@ class ValidateExpectedFiles(pyblish.api.InstancePlugin):
return json_content.pop()
return {}
- def _parse_metadata_json(self, json_path):
- if not os.path.exists(json_path):
- msg = "Metadata file {} doesn't exist".format(json_path)
- raise RuntimeError(msg)
-
- with open(json_path) as fp:
- try:
- return json.load(fp)
- except Exception as exc:
- self.log.error(
- "Error loading json: "
- "{} - Exception: {}".format(json_path, exc)
- )
-
- def _get_existing_files(self, out_dir):
- """Returns set of existing file names from 'out_dir'"""
+ def _get_existing_files(self, staging_dir):
+ """Returns set of existing file names from 'staging_dir'"""
existing_files = set()
- for file_name in os.listdir(out_dir):
+ for file_name in os.listdir(staging_dir):
existing_files.add(file_name)
return existing_files
def _get_expected_files(self, repre):
- """Returns set of file names from metadata.json"""
+ """Returns set of file names in representation['files']
+
+ The representations are collected from `CollectRenderedFiles` using
+ the metadata.json file submitted along with the render job.
+
+ Args:
+ repre (dict): The representation containing 'files'
+
+ Returns:
+ set: Set of expected file_names in the staging directory.
+
+ """
expected_files = set()
files = repre["files"]
diff --git a/openpype/modules/deadline/repository/custom/plugins/GlobalJobPreLoad.py b/openpype/modules/deadline/repository/custom/plugins/GlobalJobPreLoad.py
index ee137a2ee3..82c2494e7a 100644
--- a/openpype/modules/deadline/repository/custom/plugins/GlobalJobPreLoad.py
+++ b/openpype/modules/deadline/repository/custom/plugins/GlobalJobPreLoad.py
@@ -1,10 +1,11 @@
# -*- coding: utf-8 -*-
import os
import tempfile
-import time
+from datetime import datetime
import subprocess
import json
import platform
+import uuid
from Deadline.Scripting import RepositoryUtils, FileUtils
@@ -36,9 +37,11 @@ def inject_openpype_environment(deadlinePlugin):
print("--- OpenPype executable: {}".format(openpype_app))
# tempfile.TemporaryFile cannot be used because of locking
- export_url = os.path.join(tempfile.gettempdir(),
- time.strftime('%Y%m%d%H%M%S'),
- 'env.json') # add HHMMSS + delete later
+ temp_file_name = "{}_{}.json".format(
+ datetime.utcnow().strftime('%Y%m%d%H%M%S%f'),
+ str(uuid.uuid1())
+ )
+ export_url = os.path.join(tempfile.gettempdir(), temp_file_name)
print(">>> Temporary path: {}".format(export_url))
args = [
diff --git a/openpype/modules/ftrack/event_handlers_server/event_sync_to_avalon.py b/openpype/modules/ftrack/event_handlers_server/event_sync_to_avalon.py
index 9f85000dbb..eea6436b53 100644
--- a/openpype/modules/ftrack/event_handlers_server/event_sync_to_avalon.py
+++ b/openpype/modules/ftrack/event_handlers_server/event_sync_to_avalon.py
@@ -20,11 +20,16 @@ from openpype_modules.ftrack.lib import (
query_custom_attributes,
CUST_ATTR_ID_KEY,
CUST_ATTR_AUTO_SYNC,
+ FPS_KEYS,
avalon_sync,
BaseEvent
)
+from openpype_modules.ftrack.lib.avalon_sync import (
+ convert_to_fps,
+ InvalidFpsValue
+)
from openpype.lib import CURRENT_DOC_SCHEMAS
@@ -1149,12 +1154,31 @@ class SyncToAvalonEvent(BaseEvent):
"description": ftrack_ent["description"]
}
}
+ invalid_fps_items = []
cust_attrs = self.get_cust_attr_values(ftrack_ent)
for key, val in cust_attrs.items():
if key.startswith("avalon_"):
continue
+
+ if key in FPS_KEYS:
+ try:
+ val = convert_to_fps(val)
+ except InvalidFpsValue:
+ invalid_fps_items.append((ftrack_ent["id"], val))
+ continue
+
final_entity["data"][key] = val
+ if invalid_fps_items:
+ fps_msg = (
+ "These entities have invalid fps value in custom attributes"
+ )
+ items = []
+ for entity_id, value in invalid_fps_items:
+ ent_path = self.get_ent_path(entity_id)
+ items.append("{} - \"{}\"".format(ent_path, value))
+ self.report_items["error"][fps_msg] = items
+
_mongo_id_str = cust_attrs.get(CUST_ATTR_ID_KEY)
if _mongo_id_str:
try:
@@ -2155,11 +2179,19 @@ class SyncToAvalonEvent(BaseEvent):
)
convert_types_by_id[attr_id] = convert_type
+ default_value = attr["default"]
+ if key in FPS_KEYS:
+ try:
+ default_value = convert_to_fps(default_value)
+ except InvalidFpsValue:
+ pass
+
entities_dict[ftrack_project_id]["hier_attrs"][key] = (
attr["default"]
)
# PREPARE DATA BEFORE THIS
+ invalid_fps_items = []
avalon_hier = []
for item in values:
value = item["value"]
@@ -2173,8 +2205,25 @@ class SyncToAvalonEvent(BaseEvent):
if convert_type:
value = convert_type(value)
+
+ if key in FPS_KEYS:
+ try:
+ value = convert_to_fps(value)
+ except InvalidFpsValue:
+ invalid_fps_items.append((entity_id, value))
+ continue
entities_dict[entity_id]["hier_attrs"][key] = value
+ if invalid_fps_items:
+ fps_msg = (
+ "These entities have invalid fps value in custom attributes"
+ )
+ items = []
+ for entity_id, value in invalid_fps_items:
+ ent_path = self.get_ent_path(entity_id)
+ items.append("{} - \"{}\"".format(ent_path, value))
+ self.report_items["error"][fps_msg] = items
+
# Get dictionary with not None hierarchical values to pull to childs
project_values = {}
for key, value in (
diff --git a/openpype/modules/ftrack/event_handlers_user/action_create_cust_attrs.py b/openpype/modules/ftrack/event_handlers_user/action_create_cust_attrs.py
index cb5b88ad50..88dc8213bd 100644
--- a/openpype/modules/ftrack/event_handlers_user/action_create_cust_attrs.py
+++ b/openpype/modules/ftrack/event_handlers_user/action_create_cust_attrs.py
@@ -11,6 +11,7 @@ from openpype_modules.ftrack.lib import (
CUST_ATTR_TOOLS,
CUST_ATTR_APPLICATIONS,
CUST_ATTR_INTENT,
+ FPS_KEYS,
default_custom_attributes_definition,
app_definitions_from_app_manager,
@@ -519,20 +520,28 @@ class CustomAttributes(BaseAction):
self.show_message(event, msg)
def process_attribute(self, data):
- existing_attrs = self.session.query(
- "CustomAttributeConfiguration"
- ).all()
+ existing_attrs = self.session.query((
+ "select is_hierarchical, key, type, entity_type, object_type_id"
+ " from CustomAttributeConfiguration"
+ )).all()
matching = []
+ is_hierarchical = data.get("is_hierarchical", False)
for attr in existing_attrs:
if (
- attr["key"] != data["key"] or
- attr["type"]["name"] != data["type"]["name"]
+ is_hierarchical != attr["is_hierarchical"]
+ or attr["key"] != data["key"]
):
continue
- if data.get("is_hierarchical") is True:
- if attr["is_hierarchical"] is True:
- matching.append(attr)
+ if attr["type"]["name"] != data["type"]["name"]:
+ if data["key"] in FPS_KEYS and attr["type"]["name"] == "text":
+ self.log.info("Kept 'fps' as text custom attribute.")
+ return
+ continue
+
+ if is_hierarchical:
+ matching.append(attr)
+
elif "object_type_id" in data:
if (
attr["entity_type"] == data["entity_type"] and
diff --git a/openpype/modules/ftrack/event_handlers_user/action_create_folders.py b/openpype/modules/ftrack/event_handlers_user/action_create_folders.py
index 8bbef9ad73..d15a865124 100644
--- a/openpype/modules/ftrack/event_handlers_user/action_create_folders.py
+++ b/openpype/modules/ftrack/event_handlers_user/action_create_folders.py
@@ -97,7 +97,6 @@ class CreateFolders(BaseAction):
all_entities = self.get_notask_children(entity)
anatomy = Anatomy(project_name)
- project_settings = get_project_settings(project_name)
work_keys = ["work", "folder"]
work_template = anatomy.templates
diff --git a/openpype/modules/ftrack/event_handlers_user/action_delete_asset.py b/openpype/modules/ftrack/event_handlers_user/action_delete_asset.py
index 676dd80e93..94385a36c5 100644
--- a/openpype/modules/ftrack/event_handlers_user/action_delete_asset.py
+++ b/openpype/modules/ftrack/event_handlers_user/action_delete_asset.py
@@ -3,8 +3,9 @@ import uuid
from datetime import datetime
from bson.objectid import ObjectId
-from openpype_modules.ftrack.lib import BaseAction, statics_icon
from avalon.api import AvalonMongoDB
+from openpype_modules.ftrack.lib import BaseAction, statics_icon
+from openpype_modules.ftrack.lib.avalon_sync import create_chunks
class DeleteAssetSubset(BaseAction):
@@ -554,8 +555,8 @@ class DeleteAssetSubset(BaseAction):
ftrack_proc_txt, ", ".join(ftrack_ids_to_delete)
))
- entities_by_link_len = (
- self._filter_entities_to_delete(ftrack_ids_to_delete, session)
+ entities_by_link_len = self._prepare_entities_before_delete(
+ ftrack_ids_to_delete, session
)
for link_len in sorted(entities_by_link_len.keys(), reverse=True):
for entity in entities_by_link_len[link_len]:
@@ -609,7 +610,7 @@ class DeleteAssetSubset(BaseAction):
return self.report_handle(report_messages, project_name, event)
- def _filter_entities_to_delete(self, ftrack_ids_to_delete, session):
+ def _prepare_entities_before_delete(self, ftrack_ids_to_delete, session):
"""Filter children entities to avoid CircularDependencyError."""
joined_ids_to_delete = ", ".join(
["\"{}\"".format(id) for id in ftrack_ids_to_delete]
@@ -638,6 +639,21 @@ class DeleteAssetSubset(BaseAction):
parent_ids_to_delete.append(entity["id"])
to_delete_entities.append(entity)
+ # Unset 'task_id' from AssetVersion entities
+ # - when task is deleted the asset version is not marked for deletion
+ task_ids = set(
+ entity["id"]
+ for entity in to_delete_entities
+ if entity.entity_type.lower() == "task"
+ )
+ for chunk in create_chunks(task_ids):
+ asset_versions = session.query((
+ "select id, task_id from AssetVersion where task_id in ({})"
+ ).format(self.join_query_keys(chunk))).all()
+ for asset_version in asset_versions:
+ asset_version["task_id"] = None
+ session.commit()
+
entities_by_link_len = collections.defaultdict(list)
for entity in to_delete_entities:
entities_by_link_len[len(entity["link"])].append(entity)
diff --git a/openpype/modules/ftrack/event_handlers_user/action_job_killer.py b/openpype/modules/ftrack/event_handlers_user/action_job_killer.py
index af24e0280d..f489c0c54c 100644
--- a/openpype/modules/ftrack/event_handlers_user/action_job_killer.py
+++ b/openpype/modules/ftrack/event_handlers_user/action_job_killer.py
@@ -3,111 +3,128 @@ from openpype_modules.ftrack.lib import BaseAction, statics_icon
class JobKiller(BaseAction):
- '''Edit meta data action.'''
+ """Kill jobs that are marked as running."""
- #: Action identifier.
- identifier = 'job.killer'
- #: Action label.
+ identifier = "job.killer"
label = "OpenPype Admin"
- variant = '- Job Killer'
- #: Action description.
- description = 'Killing selected running jobs'
- #: roles that are allowed to register this action
+ variant = "- Job Killer"
+ description = "Killing selected running jobs"
icon = statics_icon("ftrack", "action_icons", "OpenPypeAdmin.svg")
settings_key = "job_killer"
def discover(self, session, entities, event):
- ''' Validation '''
+ """Check if action is available for user role."""
return self.valid_roles(session, entities, event)
def interface(self, session, entities, event):
- if not event['data'].get('values', {}):
- title = 'Select jobs to kill'
-
- jobs = session.query(
- 'select id, status from Job'
- ' where status in ("queued", "running")'
- ).all()
-
- items = []
-
- item_splitter = {'type': 'label', 'value': '---'}
- for job in jobs:
- try:
- data = json.loads(job['data'])
- desctiption = data['description']
- except Exception:
- desctiption = '*No description*'
- user = job['user']['username']
- created = job['created_at'].strftime('%d.%m.%Y %H:%M:%S')
- label = '{} - {} - {}'.format(
- desctiption, created, user
- )
- item_label = {
- 'type': 'label',
- 'value': label
- }
- item = {
- 'name': job['id'],
- 'type': 'boolean',
- 'value': False
- }
- if len(items) > 0:
- items.append(item_splitter)
- items.append(item_label)
- items.append(item)
-
- if len(items) == 0:
- return {
- 'success': False,
- 'message': 'Didn\'t found any running jobs'
- }
- else:
- return {
- 'items': items,
- 'title': title
- }
-
- def launch(self, session, entities, event):
- """ GET JOB """
- if 'values' not in event['data']:
+ if event["data"].get("values"):
return
- values = event['data']['values']
- if len(values) <= 0:
+ title = "Select jobs to kill"
+
+ jobs = session.query(
+ "select id, user_id, status, created_at, data from Job"
+ " where status in (\"queued\", \"running\")"
+ ).all()
+ if not jobs:
return {
- 'success': True,
- 'message': 'No jobs to kill!'
+ "success": True,
+ "message": "Didn't found any running jobs"
}
- jobs = []
- job_ids = []
- for k, v in values.items():
- if v is True:
- job_ids.append(k)
+ # Collect user ids from jobs
+ user_ids = set()
+ for job in jobs:
+ user_id = job["user_id"]
+ if user_id:
+ user_ids.add(user_id)
+
+ # Store usernames by their ids
+ usernames_by_id = {}
+ if user_ids:
+ users = session.query(
+ "select id, username from User where id in ({})".format(
+ self.join_query_keys(user_ids)
+ )
+ ).all()
+ for user in users:
+ usernames_by_id[user["id"]] = user["username"]
+
+ items = []
+ for job in jobs:
+ try:
+ data = json.loads(job["data"])
+ desctiption = data["description"]
+ except Exception:
+ desctiption = "*No description*"
+ user_id = job["user_id"]
+ username = usernames_by_id.get(user_id) or "Unknown user"
+ created = job["created_at"].strftime('%d.%m.%Y %H:%M:%S')
+ label = "{} - {} - {}".format(
+ username, desctiption, created
+ )
+ item_label = {
+ "type": "label",
+ "value": label
+ }
+ item = {
+ "name": job["id"],
+ "type": "boolean",
+ "value": False
+ }
+ if len(items) > 0:
+ items.append({"type": "label", "value": "---"})
+ items.append(item_label)
+ items.append(item)
+
+ return {
+ "items": items,
+ "title": title
+ }
+
+ def launch(self, session, entities, event):
+ if "values" not in event["data"]:
+ return
+
+ values = event["data"]["values"]
+ if len(values) < 1:
+ return {
+ "success": True,
+ "message": "No jobs to kill!"
+ }
+
+ job_ids = set()
+ for job_id, kill_job in values.items():
+ if kill_job:
+ job_ids.add(job_id)
+
+ jobs = session.query(
+ "select id, status from Job where id in ({})".format(
+ self.join_query_keys(job_ids)
+ )
+ ).all()
- for id in job_ids:
- query = 'Job where id is "{}"'.format(id)
- jobs.append(session.query(query).one())
# Update all the queried jobs, setting the status to failed.
for job in jobs:
try:
origin_status = job["status"]
- job['status'] = 'failed'
- session.commit()
self.log.debug((
'Changing Job ({}) status: {} -> failed'
- ).format(job['id'], origin_status))
+ ).format(job["id"], origin_status))
+
+ job["status"] = "failed"
+ session.commit()
+
except Exception:
session.rollback()
self.log.warning((
- 'Changing Job ({}) has failed'
- ).format(job['id']))
+ "Changing Job ({}) has failed"
+ ).format(job["id"]))
- self.log.info('All running jobs were killed Successfully!')
+ self.log.info("All selected jobs were killed Successfully!")
return {
- 'success': True,
- 'message': 'All running jobs were killed Successfully!'
+ "success": True,
+ "message": "All selected jobs were killed Successfully!"
}
diff --git a/openpype/modules/ftrack/lib/__init__.py b/openpype/modules/ftrack/lib/__init__.py
index 80b4db9dd6..7fc2bc99eb 100644
--- a/openpype/modules/ftrack/lib/__init__.py
+++ b/openpype/modules/ftrack/lib/__init__.py
@@ -4,7 +4,8 @@ from .constants import (
CUST_ATTR_GROUP,
CUST_ATTR_TOOLS,
CUST_ATTR_APPLICATIONS,
- CUST_ATTR_INTENT
+ CUST_ATTR_INTENT,
+ FPS_KEYS
)
from .settings import (
get_ftrack_event_mongo_info
@@ -30,6 +31,8 @@ __all__ = (
"CUST_ATTR_GROUP",
"CUST_ATTR_TOOLS",
"CUST_ATTR_APPLICATIONS",
+ "CUST_ATTR_INTENT",
+ "FPS_KEYS",
"get_ftrack_event_mongo_info",
diff --git a/openpype/modules/ftrack/lib/avalon_sync.py b/openpype/modules/ftrack/lib/avalon_sync.py
index 06e8784287..5301ec568e 100644
--- a/openpype/modules/ftrack/lib/avalon_sync.py
+++ b/openpype/modules/ftrack/lib/avalon_sync.py
@@ -2,6 +2,9 @@ import re
import json
import collections
import copy
+import numbers
+
+import six
from avalon.api import AvalonMongoDB
@@ -14,7 +17,7 @@ from openpype.api import (
)
from openpype.lib import ApplicationManager
-from .constants import CUST_ATTR_ID_KEY
+from .constants import CUST_ATTR_ID_KEY, FPS_KEYS
from .custom_attributes import get_openpype_attr, query_custom_attributes
from bson.objectid import ObjectId
@@ -33,6 +36,130 @@ CURRENT_DOC_SCHEMAS = {
}
+class InvalidFpsValue(Exception):
+ pass
+
+
+def is_string_number(value):
+ """Can string value be converted to number (float)."""
+ if not isinstance(value, six.string_types):
+ raise TypeError("Expected {} got {}".format(
+ ", ".join(str(t) for t in six.string_types), str(type(value))
+ ))
+ if value == ".":
+ return False
+
+ if value.startswith("."):
+ value = "0" + value
+ elif value.endswith("."):
+ value = value + "0"
+
+ if re.match(r"^\d+(\.\d+)?$", value) is None:
+ return False
+ return True
+
+
+def convert_to_fps(source_value):
+ """Convert value into fps value.
+
+ Non string values are kept untouched. String is tried to convert.
+ Valid values:
+ "1000"
+ "1000.05"
+ "1000,05"
+ ",05"
+ ".05"
+ "1000,"
+ "1000."
+ "1000/1000"
+ "1000.05/1000"
+ "1000/1000.05"
+ "1000.05/1000.05"
+ "1000,05/1000"
+ "1000/1000,05"
+ "1000,05/1000,05"
+
+ Invalid values:
+ "/"
+ "/1000"
+ "1000/"
+ ","
+ "."
+ ...any other string
+
+ Returns:
+ float: Converted value.
+
+ Raises:
+ InvalidFpsValue: When value can't be converted to float.
+ """
+ if not isinstance(source_value, six.string_types):
+ if isinstance(source_value, numbers.Number):
+ return float(source_value)
+ return source_value
+
+ value = source_value.strip().replace(",", ".")
+ if not value:
+ raise InvalidFpsValue("Got empty value")
+
+ subs = value.split("/")
+ if len(subs) == 1:
+ str_value = subs[0]
+ if not is_string_number(str_value):
+ raise InvalidFpsValue(
+ "Value \"{}\" can't be converted to number.".format(value)
+ )
+ return float(str_value)
+
+ elif len(subs) == 2:
+ divident, divisor = subs
+ if not divident or not is_string_number(divident):
+ raise InvalidFpsValue(
+ "Divident value \"{}\" can't be converted to number".format(
+ divident
+ )
+ )
+
+ if not divisor or not is_string_number(divisor):
+ raise InvalidFpsValue(
+ "Divisor value \"{}\" can't be converted to number".format(
+ divident
+ )
+ )
+ divisor_float = float(divisor)
+ if divisor_float == 0.0:
+ raise InvalidFpsValue("Can't divide by zero")
+ return float(divident) / divisor_float
+
+ raise InvalidFpsValue(
+ "Value can't be converted to number \"{}\"".format(source_value)
+ )
+
+
+def create_chunks(iterable, chunk_size=None):
+ """Separate iterable into multiple chunks by size.
+
+ Args:
+ iterable(list|tuple|set): Object that will be separated into chunks.
+ chunk_size(int): Size of one chunk. Default value is 200.
+
+ Returns:
+ list: Chunked items.
+ """
+ chunks = []
+ if not iterable:
+ return chunks
+
+ tupled_iterable = tuple(iterable)
+ iterable_size = len(tupled_iterable)
+ if chunk_size is None:
+ chunk_size = 200
+
+ for idx in range(0, iterable_size, chunk_size):
+ chunks.append(tupled_iterable[idx:idx + chunk_size])
+ return chunks
+
+
def check_regex(name, entity_type, in_schema=None, schema_patterns=None):
schema_name = "asset-3.0"
if in_schema:
@@ -956,6 +1083,7 @@ class SyncEntitiesFactory:
sync_ids
)
+ invalid_fps_items = []
for item in items:
entity_id = item["entity_id"]
attr_id = item["configuration_id"]
@@ -968,8 +1096,24 @@ class SyncEntitiesFactory:
value = item["value"]
if convert_type:
value = convert_type(value)
+
+ if key in FPS_KEYS:
+ try:
+ value = convert_to_fps(value)
+ except InvalidFpsValue:
+ invalid_fps_items.append((entity_id, value))
self.entities_dict[entity_id][store_key][key] = value
+ if invalid_fps_items:
+ fps_msg = (
+ "These entities have invalid fps value in custom attributes"
+ )
+ items = []
+ for entity_id, value in invalid_fps_items:
+ ent_path = self.get_ent_path(entity_id)
+ items.append("{} - \"{}\"".format(ent_path, value))
+ self.report_items["error"][fps_msg] = items
+
# process hierarchical attributes
self.set_hierarchical_attribute(
hier_attrs, sync_ids, cust_attr_type_name_by_id
@@ -1002,8 +1146,15 @@ class SyncEntitiesFactory:
if key.startswith("avalon_"):
store_key = "avalon_attrs"
+ default_value = attr["default"]
+ if key in FPS_KEYS:
+ try:
+ default_value = convert_to_fps(default_value)
+ except InvalidFpsValue:
+ pass
+
self.entities_dict[self.ft_project_id][store_key][key] = (
- attr["default"]
+ default_value
)
# Add attribute ids to entities dictionary
@@ -1045,6 +1196,7 @@ class SyncEntitiesFactory:
True
)
+ invalid_fps_items = []
avalon_hier = []
for item in items:
value = item["value"]
@@ -1064,6 +1216,13 @@ class SyncEntitiesFactory:
entity_id = item["entity_id"]
key = attribute_key_by_id[attr_id]
+ if key in FPS_KEYS:
+ try:
+ value = convert_to_fps(value)
+ except InvalidFpsValue:
+ invalid_fps_items.append((entity_id, value))
+ continue
+
if key.startswith("avalon_"):
store_key = "avalon_attrs"
avalon_hier.append(key)
@@ -1071,6 +1230,16 @@ class SyncEntitiesFactory:
store_key = "hier_attrs"
self.entities_dict[entity_id][store_key][key] = value
+ if invalid_fps_items:
+ fps_msg = (
+ "These entities have invalid fps value in custom attributes"
+ )
+ items = []
+ for entity_id, value in invalid_fps_items:
+ ent_path = self.get_ent_path(entity_id)
+ items.append("{} - \"{}\"".format(ent_path, value))
+ self.report_items["error"][fps_msg] = items
+
# Get dictionary with not None hierarchical values to pull to childs
top_id = self.ft_project_id
project_values = {}
@@ -1147,10 +1316,8 @@ class SyncEntitiesFactory:
ids_len = len(tupled_ids)
chunk_size = int(5000 / ids_len)
all_links = []
- for idx in range(0, ids_len, chunk_size):
- entity_ids_joined = join_query_keys(
- tupled_ids[idx:idx + chunk_size]
- )
+ for chunk in create_chunks(ftrack_ids, chunk_size):
+ entity_ids_joined = join_query_keys(chunk)
all_links.extend(self.session.query((
"select from_id, to_id from"
diff --git a/openpype/modules/ftrack/lib/constants.py b/openpype/modules/ftrack/lib/constants.py
index e6e2013d2b..636dcfbc3d 100644
--- a/openpype/modules/ftrack/lib/constants.py
+++ b/openpype/modules/ftrack/lib/constants.py
@@ -12,3 +12,9 @@ CUST_ATTR_APPLICATIONS = "applications"
CUST_ATTR_TOOLS = "tools_env"
# Intent custom attribute name
CUST_ATTR_INTENT = "intent"
+
+FPS_KEYS = {
+ "fps",
+ # For development purposes
+ "fps_string"
+}
diff --git a/openpype/modules/ftrack/plugins/publish/collect_ftrack_api.py b/openpype/modules/ftrack/plugins/publish/collect_ftrack_api.py
index a348617cfc..07af217fb6 100644
--- a/openpype/modules/ftrack/plugins/publish/collect_ftrack_api.py
+++ b/openpype/modules/ftrack/plugins/publish/collect_ftrack_api.py
@@ -1,4 +1,3 @@
-import os
import logging
import pyblish.api
import avalon.api
@@ -43,37 +42,48 @@ class CollectFtrackApi(pyblish.api.ContextPlugin):
).format(project_name))
project_entity = project_entities[0]
+
self.log.debug("Project found: {0}".format(project_entity))
- # Find asset entity
- entity_query = (
- 'TypedContext where project_id is "{0}"'
- ' and name is "{1}"'
- ).format(project_entity["id"], asset_name)
- self.log.debug("Asset entity query: < {0} >".format(entity_query))
- asset_entities = []
- for entity in session.query(entity_query).all():
- # Skip tasks
- if entity.entity_type.lower() != "task":
- asset_entities.append(entity)
+ asset_entity = None
+ if asset_name:
+ # Find asset entity
+ entity_query = (
+ 'TypedContext where project_id is "{0}"'
+ ' and name is "{1}"'
+ ).format(project_entity["id"], asset_name)
+ self.log.debug("Asset entity query: < {0} >".format(entity_query))
+ asset_entities = []
+ for entity in session.query(entity_query).all():
+ # Skip tasks
+ if entity.entity_type.lower() != "task":
+ asset_entities.append(entity)
- if len(asset_entities) == 0:
- raise AssertionError((
- "Entity with name \"{0}\" not found"
- " in Ftrack project \"{1}\"."
- ).format(asset_name, project_name))
+ if len(asset_entities) == 0:
+ raise AssertionError((
+ "Entity with name \"{0}\" not found"
+ " in Ftrack project \"{1}\"."
+ ).format(asset_name, project_name))
- elif len(asset_entities) > 1:
- raise AssertionError((
- "Found more than one entity with name \"{0}\""
- " in Ftrack project \"{1}\"."
- ).format(asset_name, project_name))
+ elif len(asset_entities) > 1:
+ raise AssertionError((
+ "Found more than one entity with name \"{0}\""
+ " in Ftrack project \"{1}\"."
+ ).format(asset_name, project_name))
+
+ asset_entity = asset_entities[0]
- asset_entity = asset_entities[0]
self.log.debug("Asset found: {0}".format(asset_entity))
+ task_entity = None
# Find task entity if task is set
- if task_name:
+ if not asset_entity:
+ self.log.warning(
+ "Asset entity is not set. Skipping query of task entity."
+ )
+ elif not task_name:
+ self.log.warning("Task name is not set.")
+ else:
task_query = (
'Task where name is "{0}" and parent_id is "{1}"'
).format(task_name, asset_entity["id"])
@@ -88,10 +98,6 @@ class CollectFtrackApi(pyblish.api.ContextPlugin):
else:
self.log.debug("Task entity found: {0}".format(task_entity))
- else:
- task_entity = None
- self.log.warning("Task name is not set.")
-
context.data["ftrackSession"] = session
context.data["ftrackPythonModule"] = ftrack_api
context.data["ftrackProject"] = project_entity
diff --git a/openpype/modules/interfaces.py b/openpype/modules/interfaces.py
index 7c301c15b4..13cbea690b 100644
--- a/openpype/modules/interfaces.py
+++ b/openpype/modules/interfaces.py
@@ -122,6 +122,7 @@ class ITrayAction(ITrayModule):
admin_action = False
_admin_submenu = None
+ _action_item = None
@property
@abstractmethod
@@ -149,6 +150,7 @@ class ITrayAction(ITrayModule):
tray_menu.addAction(action)
action.triggered.connect(self.on_action_trigger)
+ self._action_item = action
def tray_start(self):
return
diff --git a/openpype/modules/traypublish_action.py b/openpype/modules/traypublish_action.py
new file mode 100644
index 0000000000..39163b8eb8
--- /dev/null
+++ b/openpype/modules/traypublish_action.py
@@ -0,0 +1,49 @@
+import os
+from openpype.lib import get_openpype_execute_args
+from openpype.lib.execute import run_detached_process
+from openpype.modules import OpenPypeModule
+from openpype_interfaces import ITrayAction
+
+
+class TrayPublishAction(OpenPypeModule, ITrayAction):
+ label = "New Publish (beta)"
+ name = "traypublish_tool"
+
+ def initialize(self, modules_settings):
+ import openpype
+ self.enabled = True
+ self.publish_paths = [
+ os.path.join(
+ openpype.PACKAGE_DIR,
+ "hosts",
+ "traypublisher",
+ "plugins",
+ "publish"
+ )
+ ]
+ self._experimental_tools = None
+
+ def tray_init(self):
+ from openpype.tools.experimental_tools import ExperimentalTools
+
+ self._experimental_tools = ExperimentalTools()
+
+ def tray_menu(self, *args, **kwargs):
+ super(TrayPublishAction, self).tray_menu(*args, **kwargs)
+ traypublisher = self._experimental_tools.get("traypublisher")
+ visible = False
+ if traypublisher and traypublisher.enabled:
+ visible = True
+ self._action_item.setVisible(visible)
+
+ def on_action_trigger(self):
+ self.run_traypublisher()
+
+ def connect_with_modules(self, enabled_modules):
+ """Collect publish paths from other modules."""
+ publish_paths = self.manager.collect_plugin_paths()["publish"]
+ self.publish_paths.extend(publish_paths)
+
+ def run_traypublisher(self):
+ args = get_openpype_execute_args("traypublisher")
+ run_detached_process(args)
diff --git a/openpype/pipeline/create/README.md b/openpype/pipeline/create/README.md
index 9eef7c72a7..02b64e52ea 100644
--- a/openpype/pipeline/create/README.md
+++ b/openpype/pipeline/create/README.md
@@ -14,7 +14,7 @@ Except creating and removing instances are all changes not automatically propaga
## CreatedInstance
-Product of creation is "instance" which holds basic data defying it. Core data are `creator_identifier`, `family` and `subset`. Other data can be keys used to fill subset name or metadata modifying publishing process of the instance (more described later). All instances have `id` which holds constant `pyblish.avalon.instance` and `uuid` which is identifier of the instance.
+Product of creation is "instance" which holds basic data defying it. Core data are `creator_identifier`, `family` and `subset`. Other data can be keys used to fill subset name or metadata modifying publishing process of the instance (more described later). All instances have `id` which holds constant `pyblish.avalon.instance` and `instance_id` which is identifier of the instance.
Family tells how should be instance processed and subset what name will published item have.
- There are cases when subset is not fully filled during creation and may change during publishing. That is in most of cases caused because instance is related to other instance or instance data do not represent final product.
@@ -26,7 +26,7 @@ Family tells how should be instance processed and subset what name will publishe
## Identifier that this data represents instance for publishing (automatically assigned)
"id": "pyblish.avalon.instance",
## Identifier of this specific instance (automatically assigned)
- "uuid": ,
+ "instance_id": ,
## Instance family (used from Creator)
"family": ,
diff --git a/openpype/pipeline/create/context.py b/openpype/pipeline/create/context.py
index 4454d31d83..e11d32091f 100644
--- a/openpype/pipeline/create/context.py
+++ b/openpype/pipeline/create/context.py
@@ -361,7 +361,7 @@ class CreatedInstance:
# their individual children but not on their own
__immutable_keys = (
"id",
- "uuid",
+ "instance_id",
"family",
"creator_identifier",
"creator_attributes",
@@ -434,8 +434,8 @@ class CreatedInstance:
if data:
self._data.update(data)
- if not self._data.get("uuid"):
- self._data["uuid"] = str(uuid4())
+ if not self._data.get("instance_id"):
+ self._data["instance_id"] = str(uuid4())
self._asset_is_valid = self.has_set_asset
self._task_is_valid = self.has_set_task
@@ -551,7 +551,7 @@ class CreatedInstance:
@property
def id(self):
"""Instance identifier."""
- return self._data["uuid"]
+ return self._data["instance_id"]
@property
def data(self):
diff --git a/openpype/plugins/publish/collect_anatomy_context_data.py b/openpype/plugins/publish/collect_anatomy_context_data.py
index b0474b93ce..bd8d9e50c4 100644
--- a/openpype/plugins/publish/collect_anatomy_context_data.py
+++ b/openpype/plugins/publish/collect_anatomy_context_data.py
@@ -44,42 +44,18 @@ class CollectAnatomyContextData(pyblish.api.ContextPlugin):
label = "Collect Anatomy Context Data"
def process(self, context):
-
- task_name = api.Session["AVALON_TASK"]
-
project_entity = context.data["projectEntity"]
- asset_entity = context.data["assetEntity"]
-
- asset_tasks = asset_entity["data"]["tasks"]
- task_type = asset_tasks.get(task_name, {}).get("type")
-
- project_task_types = project_entity["config"]["tasks"]
- task_code = project_task_types.get(task_type, {}).get("short_name")
-
- asset_parents = asset_entity["data"]["parents"]
- hierarchy = "/".join(asset_parents)
-
- parent_name = project_entity["name"]
- if asset_parents:
- parent_name = asset_parents[-1]
-
context_data = {
"project": {
"name": project_entity["name"],
"code": project_entity["data"].get("code")
},
- "asset": asset_entity["name"],
- "parent": parent_name,
- "hierarchy": hierarchy,
- "task": {
- "name": task_name,
- "type": task_type,
- "short": task_code,
- },
"username": context.data["user"],
"app": context.data["hostName"]
}
+ context.data["anatomyData"] = context_data
+
# add system general settings anatomy data
system_general_data = get_system_general_anatomy_data()
context_data.update(system_general_data)
@@ -87,7 +63,33 @@ class CollectAnatomyContextData(pyblish.api.ContextPlugin):
datetime_data = context.data.get("datetimeData") or {}
context_data.update(datetime_data)
- context.data["anatomyData"] = context_data
+ asset_entity = context.data.get("assetEntity")
+ if asset_entity:
+ task_name = api.Session["AVALON_TASK"]
+
+ asset_tasks = asset_entity["data"]["tasks"]
+ task_type = asset_tasks.get(task_name, {}).get("type")
+
+ project_task_types = project_entity["config"]["tasks"]
+ task_code = project_task_types.get(task_type, {}).get("short_name")
+
+ asset_parents = asset_entity["data"]["parents"]
+ hierarchy = "/".join(asset_parents)
+
+ parent_name = project_entity["name"]
+ if asset_parents:
+ parent_name = asset_parents[-1]
+
+ context_data.update({
+ "asset": asset_entity["name"],
+ "parent": parent_name,
+ "hierarchy": hierarchy,
+ "task": {
+ "name": task_name,
+ "type": task_type,
+ "short": task_code,
+ }
+ })
self.log.info("Global anatomy Data collected")
self.log.debug(json.dumps(context_data, indent=4))
diff --git a/openpype/plugins/publish/collect_anatomy_instance_data.py b/openpype/plugins/publish/collect_anatomy_instance_data.py
index 74b556e28a..42836e796b 100644
--- a/openpype/plugins/publish/collect_anatomy_instance_data.py
+++ b/openpype/plugins/publish/collect_anatomy_instance_data.py
@@ -52,7 +52,7 @@ class CollectAnatomyInstanceData(pyblish.api.ContextPlugin):
def fill_missing_asset_docs(self, context):
self.log.debug("Qeurying asset documents for instances.")
- context_asset_doc = context.data["assetEntity"]
+ context_asset_doc = context.data.get("assetEntity")
instances_with_missing_asset_doc = collections.defaultdict(list)
for instance in context:
@@ -69,7 +69,7 @@ class CollectAnatomyInstanceData(pyblish.api.ContextPlugin):
# Check if asset name is the same as what is in context
# - they may be different, e.g. in NukeStudio
- if context_asset_doc["name"] == _asset_name:
+ if context_asset_doc and context_asset_doc["name"] == _asset_name:
instance.data["assetEntity"] = context_asset_doc
else:
@@ -212,7 +212,7 @@ class CollectAnatomyInstanceData(pyblish.api.ContextPlugin):
self.log.debug("Storing anatomy data to instance data.")
project_doc = context.data["projectEntity"]
- context_asset_doc = context.data["assetEntity"]
+ context_asset_doc = context.data.get("assetEntity")
project_task_types = project_doc["config"]["tasks"]
@@ -240,7 +240,13 @@ class CollectAnatomyInstanceData(pyblish.api.ContextPlugin):
# Hiearchy
asset_doc = instance.data.get("assetEntity")
- if asset_doc and asset_doc["_id"] != context_asset_doc["_id"]:
+ if (
+ asset_doc
+ and (
+ not context_asset_doc
+ or asset_doc["_id"] != context_asset_doc["_id"]
+ )
+ ):
parents = asset_doc["data"].get("parents") or list()
parent_name = project_doc["name"]
if parents:
diff --git a/openpype/plugins/publish/collect_avalon_entities.py b/openpype/plugins/publish/collect_avalon_entities.py
index a6120d42fe..c099a2cf75 100644
--- a/openpype/plugins/publish/collect_avalon_entities.py
+++ b/openpype/plugins/publish/collect_avalon_entities.py
@@ -33,6 +33,11 @@ class CollectAvalonEntities(pyblish.api.ContextPlugin):
).format(project_name)
self.log.debug("Collected Project \"{}\"".format(project_entity))
+ context.data["projectEntity"] = project_entity
+
+ if not asset_name:
+ self.log.info("Context is not set. Can't collect global data.")
+ return
asset_entity = io.find_one({
"type": "asset",
"name": asset_name,
@@ -44,7 +49,6 @@ class CollectAvalonEntities(pyblish.api.ContextPlugin):
self.log.debug("Collected Asset \"{}\"".format(asset_entity))
- context.data["projectEntity"] = project_entity
context.data["assetEntity"] = asset_entity
data = asset_entity['data']
diff --git a/openpype/plugins/publish/integrate_new.py b/openpype/plugins/publish/integrate_new.py
index 486718d8c4..6e0940d459 100644
--- a/openpype/plugins/publish/integrate_new.py
+++ b/openpype/plugins/publish/integrate_new.py
@@ -148,7 +148,10 @@ class IntegrateAssetNew(pyblish.api.InstancePlugin):
project_entity = instance.data["projectEntity"]
- context_asset_name = context.data["assetEntity"]["name"]
+ context_asset_name = None
+ context_asset_doc = context.data.get("assetEntity")
+ if context_asset_doc:
+ context_asset_name = context_asset_doc["name"]
asset_name = instance.data["asset"]
asset_entity = instance.data.get("assetEntity")
diff --git a/openpype/plugins/publish/validate_aseset_docs.py b/openpype/plugins/publish/validate_aseset_docs.py
new file mode 100644
index 0000000000..eed75cdf8a
--- /dev/null
+++ b/openpype/plugins/publish/validate_aseset_docs.py
@@ -0,0 +1,31 @@
+import pyblish.api
+from openpype.pipeline import PublishValidationError
+
+
+class ValidateContainers(pyblish.api.InstancePlugin):
+ """Validate existence of asset asset documents on instances.
+
+ Without asset document it is not possible to publish the instance.
+
+ If context has set asset document the validation is skipped.
+
+ Plugin was added because there are cases when context asset is not defined
+ e.g. in tray publisher.
+ """
+
+ label = "Validate Asset docs"
+ order = pyblish.api.ValidatorOrder
+
+ def process(self, instance):
+ context_asset_doc = instance.context.data.get("assetEntity")
+ if context_asset_doc:
+ return
+
+ if instance.data.get("assetEntity"):
+ self.log.info("Instance have set asset document in it's data.")
+
+ else:
+ raise PublishValidationError((
+ "Instance \"{}\" don't have set asset"
+ " document which is needed for publishing."
+ ).format(instance.data["name"]))
diff --git a/openpype/pype_commands.py b/openpype/pype_commands.py
index 47f5e7fcc0..c05eece2be 100644
--- a/openpype/pype_commands.py
+++ b/openpype/pype_commands.py
@@ -80,6 +80,11 @@ class PypeCommands:
from openpype.tools import standalonepublish
standalonepublish.main()
+ @staticmethod
+ def launch_traypublisher():
+ from openpype.tools import traypublisher
+ traypublisher.main()
+
@staticmethod
def publish(paths, targets=None, gui=False):
"""Start headless publishing.
@@ -363,7 +368,7 @@ class PypeCommands:
pass
def run_tests(self, folder, mark, pyargs,
- test_data_folder, persist, app_variant):
+ test_data_folder, persist, app_variant, timeout):
"""
Runs tests from 'folder'
@@ -401,6 +406,9 @@ class PypeCommands:
if app_variant:
args.extend(["--app_variant", app_variant])
+ if timeout:
+ args.extend(["--timeout", timeout])
+
print("run_tests args: {}".format(args))
import pytest
pytest.main(args)
diff --git a/openpype/settings/defaults/project_anatomy/imageio.json b/openpype/settings/defaults/project_anatomy/imageio.json
index 4a1496fe1a..1c86509155 100644
--- a/openpype/settings/defaults/project_anatomy/imageio.json
+++ b/openpype/settings/defaults/project_anatomy/imageio.json
@@ -177,6 +177,17 @@
}
},
"maya": {
+ "colorManagementPreference_v2": {
+ "enabled": true,
+ "configFilePath": {
+ "windows": [],
+ "darwin": [],
+ "linux": []
+ },
+ "renderSpace": "ACEScg",
+ "viewName": "ACES 1.0 SDR-video",
+ "displayName": "sRGB"
+ },
"colorManagementPreference": {
"configFilePath": {
"windows": [],
diff --git a/openpype/settings/defaults/project_settings/maya.json b/openpype/settings/defaults/project_settings/maya.json
index 24e8e4a29b..c25f416562 100644
--- a/openpype/settings/defaults/project_settings/maya.json
+++ b/openpype/settings/defaults/project_settings/maya.json
@@ -589,6 +589,12 @@
12,
255
],
+ "vrayscene_layer": [
+ 255,
+ 150,
+ 12,
+ 255
+ ],
"yeticache": [
99,
206,
diff --git a/openpype/settings/defaults/project_settings/photoshop.json b/openpype/settings/defaults/project_settings/photoshop.json
index f095986ee6..118b9c721e 100644
--- a/openpype/settings/defaults/project_settings/photoshop.json
+++ b/openpype/settings/defaults/project_settings/photoshop.json
@@ -28,6 +28,7 @@
]
},
"ExtractReview": {
+ "make_image_sequence": false,
"jpg_options": {
"tags": []
},
@@ -43,4 +44,4 @@
"create_first_version": false,
"custom_templates": []
}
-}
\ No newline at end of file
+}
diff --git a/openpype/settings/defaults/system_settings/applications.json b/openpype/settings/defaults/system_settings/applications.json
index 2f99200a88..0fb99a2608 100644
--- a/openpype/settings/defaults/system_settings/applications.json
+++ b/openpype/settings/defaults/system_settings/applications.json
@@ -93,7 +93,7 @@
}
},
"__dynamic_keys_labels__": {
- "2022": "2022 (Testing Only)"
+ "2022": "2022"
}
}
},
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 f54aa847b5..b499ccc4be 100644
--- a/openpype/settings/entities/schemas/projects_schema/schema_project_photoshop.json
+++ b/openpype/settings/entities/schemas/projects_schema/schema_project_photoshop.json
@@ -164,6 +164,11 @@
"key": "ExtractReview",
"label": "Extract Review",
"children": [
+ {
+ "type": "boolean",
+ "key": "make_image_sequence",
+ "label": "Makes an image sequence instead of a flatten image"
+ },
{
"type": "dict",
"collapsible": false,
diff --git a/openpype/settings/entities/schemas/projects_schema/schemas/schema_anatomy_imageio.json b/openpype/settings/entities/schemas/projects_schema/schemas/schema_anatomy_imageio.json
index e000adacb0..3bec19c3d0 100644
--- a/openpype/settings/entities/schemas/projects_schema/schemas/schema_anatomy_imageio.json
+++ b/openpype/settings/entities/schemas/projects_schema/schemas/schema_anatomy_imageio.json
@@ -377,11 +377,47 @@
"type": "dict",
"label": "Maya",
"children": [
+ {
+ "key": "colorManagementPreference_v2",
+ "type": "dict",
+ "label": "Color Management Preference v2 (Maya 2022+)",
+ "collapsible": true,
+ "checkbox_key": "enabled",
+ "children": [
+ {
+ "type": "boolean",
+ "key": "enabled",
+ "label": "Use Color Management Preference v2"
+ },
+ {
+ "type": "path",
+ "key": "configFilePath",
+ "label": "OCIO Config File Path",
+ "multiplatform": true,
+ "multipath": true
+ },
+ {
+ "type": "text",
+ "key": "renderSpace",
+ "label": "Rendering Space"
+ },
+ {
+ "type": "text",
+ "key": "displayName",
+ "label": "Display"
+ },
+ {
+ "type": "text",
+ "key": "viewName",
+ "label": "View"
+ }
+ ]
+ },
{
"key": "colorManagementPreference",
"type": "dict",
- "label": "Color Managment Preference",
- "collapsible": false,
+ "label": "Color Management Preference (legacy)",
+ "collapsible": true,
"children": [
{
"type": "path",
@@ -401,7 +437,7 @@
"label": "Viewer Transform"
}
]
- }
+ }
]
},
{
diff --git a/openpype/settings/entities/schemas/projects_schema/schemas/schema_maya_load.json b/openpype/settings/entities/schemas/projects_schema/schemas/schema_maya_load.json
index 7c87644817..6b2315abc0 100644
--- a/openpype/settings/entities/schemas/projects_schema/schemas/schema_maya_load.json
+++ b/openpype/settings/entities/schemas/projects_schema/schemas/schema_maya_load.json
@@ -75,6 +75,11 @@
"label": "Vray Proxy:",
"key": "vrayproxy"
},
+ {
+ "type": "color",
+ "label": "Vray Scene:",
+ "key": "vrayscene_layer"
+ },
{
"type": "color",
"label": "Yeti Cache:",
diff --git a/openpype/settings/handlers.py b/openpype/settings/handlers.py
index 9f2b46d758..2109b53b09 100644
--- a/openpype/settings/handlers.py
+++ b/openpype/settings/handlers.py
@@ -694,7 +694,7 @@ class MongoSettingsHandler(SettingsHandler):
return self.collection.find_one(
{"type": self._version_order_key},
projection
- )
+ ) or {}
def _check_version_order(self):
"""This method will work only in OpenPype process.
diff --git a/openpype/style/style.css b/openpype/style/style.css
index c96e87aa02..ba40b780ab 100644
--- a/openpype/style/style.css
+++ b/openpype/style/style.css
@@ -1261,6 +1261,12 @@ QScrollBar::add-page:vertical, QScrollBar::sub-page:vertical {
background: {color:restart-btn-bg};
}
+/* Tray publisher */
+#ChooseProjectLabel {
+ font-size: 15pt;
+ font-weight: 750;
+}
+
/* Globally used names */
#Separator {
background: {color:bg-menu-separator};
diff --git a/openpype/tools/experimental_tools/dialog.py b/openpype/tools/experimental_tools/dialog.py
index 295afbe68d..0099492207 100644
--- a/openpype/tools/experimental_tools/dialog.py
+++ b/openpype/tools/experimental_tools/dialog.py
@@ -82,7 +82,7 @@ class ExperimentalToolsDialog(QtWidgets.QDialog):
tool_btns_layout.addWidget(tool_btns_label, 0)
experimental_tools = ExperimentalTools(
- parent=parent, filter_hosts=True
+ parent_widget=parent, refresh=False
)
# Main layout
@@ -116,7 +116,8 @@ class ExperimentalToolsDialog(QtWidgets.QDialog):
self._experimental_tools.refresh_availability()
buttons_to_remove = set(self._buttons_by_tool_identifier.keys())
- for idx, tool in enumerate(self._experimental_tools.tools):
+ tools = self._experimental_tools.get_tools_for_host()
+ for idx, tool in enumerate(tools):
identifier = tool.identifier
if identifier in buttons_to_remove:
buttons_to_remove.remove(identifier)
diff --git a/openpype/tools/experimental_tools/tools_def.py b/openpype/tools/experimental_tools/tools_def.py
index 316359c0f3..fa2971dc1d 100644
--- a/openpype/tools/experimental_tools/tools_def.py
+++ b/openpype/tools/experimental_tools/tools_def.py
@@ -5,7 +5,32 @@ from openpype.settings import get_local_settings
LOCAL_EXPERIMENTAL_KEY = "experimental_tools"
-class ExperimentalTool:
+class ExperimentalTool(object):
+ """Definition of experimental tool.
+
+ Definition is used in local settings.
+
+ Args:
+ identifier (str): String identifier of tool (unique).
+ label (str): Label shown in UI.
+ """
+ def __init__(self, identifier, label, tooltip):
+ self.identifier = identifier
+ self.label = label
+ self.tooltip = tooltip
+ self._enabled = True
+
+ @property
+ def enabled(self):
+ """Is tool enabled and button is clickable."""
+ return self._enabled
+
+ def set_enabled(self, enabled=True):
+ """Change if tool is enabled."""
+ self._enabled = enabled
+
+
+class ExperimentalHostTool(ExperimentalTool):
"""Definition of experimental tool.
Definition is used in local settings and in experimental tools dialog.
@@ -19,12 +44,10 @@ class ExperimentalTool:
Some tools may not be available in all hosts.
"""
def __init__(
- self, identifier, label, callback, tooltip, hosts_filter=None
+ self, identifier, label, tooltip, callback, hosts_filter=None
):
- self.identifier = identifier
- self.label = label
+ super(ExperimentalHostTool, self).__init__(identifier, label, tooltip)
self.callback = callback
- self.tooltip = tooltip
self.hosts_filter = hosts_filter
self._enabled = True
@@ -33,18 +56,9 @@ class ExperimentalTool:
return host_name in self.hosts_filter
return True
- @property
- def enabled(self):
- """Is tool enabled and button is clickable."""
- return self._enabled
-
- def set_enabled(self, enabled=True):
- """Change if tool is enabled."""
- self._enabled = enabled
-
- def execute(self):
+ def execute(self, *args, **kwargs):
"""Trigger registered callback."""
- self.callback()
+ self.callback(*args, **kwargs)
class ExperimentalTools:
@@ -53,57 +67,36 @@ class ExperimentalTools:
To add/remove experimental tool just add/remove tool to
`experimental_tools` variable in __init__ function.
- Args:
- parent (QtWidgets.QWidget): Parent widget for tools.
- host_name (str): Name of host in which context we're now. Environment
- value 'AVALON_APP' is used when not passed.
- filter_hosts (bool): Should filter tools. By default is set to 'True'
- when 'host_name' is passed. Is always set to 'False' if 'host_name'
- is not defined.
+ --- Example tool (callback will just print on click) ---
+ def example_callback(*args):
+ print("Triggered tool")
+
+ experimental_tools = [
+ ExperimentalHostTool(
+ "example",
+ "Example experimental tool",
+ example_callback,
+ "Example tool tooltip."
+ )
+ ]
+ ---
"""
- def __init__(self, parent=None, host_name=None, filter_hosts=None):
+ def __init__(self, parent_widget=None, refresh=True):
# Definition of experimental tools
experimental_tools = [
- ExperimentalTool(
+ ExperimentalHostTool(
"publisher",
"New publisher",
- self._show_publisher,
- "Combined creation and publishing into one tool."
+ "Combined creation and publishing into one tool.",
+ self._show_publisher
+ ),
+ ExperimentalTool(
+ "traypublisher",
+ "New Standalone Publisher",
+ "Standalone publisher using new publisher. Requires restart"
)
]
- # --- Example tool (callback will just print on click) ---
- # def example_callback(*args):
- # print("Triggered tool")
- #
- # experimental_tools = [
- # ExperimentalTool(
- # "example",
- # "Example experimental tool",
- # example_callback,
- # "Example tool tooltip."
- # )
- # ]
-
- # Try to get host name from env variable `AVALON_APP`
- if not host_name:
- host_name = os.environ.get("AVALON_APP")
-
- # Decide if filtering by host name should happen
- if filter_hosts is None:
- filter_hosts = host_name is not None
-
- if filter_hosts and not host_name:
- filter_hosts = False
-
- # Filter tools by host name
- if filter_hosts:
- experimental_tools = [
- tool
- for tool in experimental_tools
- if tool.is_available_for_host(host_name)
- ]
-
# Store tools by identifier
tools_by_identifier = {}
for tool in experimental_tools:
@@ -115,10 +108,13 @@ class ExperimentalTools:
self._tools_by_identifier = tools_by_identifier
self._tools = experimental_tools
- self._parent_widget = parent
+ self._parent_widget = parent_widget
self._publisher_tool = None
+ if refresh:
+ self.refresh_availability()
+
@property
def tools(self):
"""Tools in list.
@@ -139,6 +135,22 @@ class ExperimentalTools:
"""
return self._tools_by_identifier
+ def get(self, tool_identifier):
+ """Get tool by identifier."""
+ return self.tools_by_identifier.get(tool_identifier)
+
+ def get_tools_for_host(self, host_name=None):
+ if not host_name:
+ host_name = os.environ.get("AVALON_APP")
+ tools = []
+ for tool in self.tools:
+ if (
+ isinstance(tool, ExperimentalHostTool)
+ and tool.is_available_for_host(host_name)
+ ):
+ tools.append(tool)
+ return tools
+
def refresh_availability(self):
"""Reload local settings and check if any tool changed ability."""
local_settings = get_local_settings()
diff --git a/openpype/tools/mayalookassigner/widgets.py b/openpype/tools/mayalookassigner/widgets.py
index d575e647ce..e546ee705d 100644
--- a/openpype/tools/mayalookassigner/widgets.py
+++ b/openpype/tools/mayalookassigner/widgets.py
@@ -3,9 +3,11 @@ from collections import defaultdict
from Qt import QtWidgets, QtCore
-# TODO: expose this better in avalon core
-from avalon.tools import lib
-from avalon.tools.models import TreeModel
+from openpype.tools.utils.models import TreeModel
+from openpype.tools.utils.lib import (
+ preserve_expanded_rows,
+ preserve_selection,
+)
from .models import (
AssetModel,
@@ -88,8 +90,8 @@ class AssetOutliner(QtWidgets.QWidget):
"""Add all items from the current scene"""
items = []
- with lib.preserve_expanded_rows(self.view):
- with lib.preserve_selection(self.view):
+ with preserve_expanded_rows(self.view):
+ with preserve_selection(self.view):
self.clear()
nodes = commands.get_all_asset_nodes()
items = commands.create_items_from_nodes(nodes)
@@ -100,8 +102,8 @@ class AssetOutliner(QtWidgets.QWidget):
def get_selected_assets(self):
"""Add all selected items from the current scene"""
- with lib.preserve_expanded_rows(self.view):
- with lib.preserve_selection(self.view):
+ with preserve_expanded_rows(self.view):
+ with preserve_selection(self.view):
self.clear()
nodes = commands.get_selected_nodes()
items = commands.create_items_from_nodes(nodes)
diff --git a/openpype/tools/publisher/control.py b/openpype/tools/publisher/control.py
index 2ce0eaad62..5a84b1d8ca 100644
--- a/openpype/tools/publisher/control.py
+++ b/openpype/tools/publisher/control.py
@@ -42,18 +42,23 @@ class MainThreadProcess(QtCore.QObject):
This approach gives ability to update UI meanwhile plugin is in progress.
"""
- timer_interval = 3
+ count_timeout = 2
def __init__(self):
super(MainThreadProcess, self).__init__()
self._items_to_process = collections.deque()
timer = QtCore.QTimer()
- timer.setInterval(self.timer_interval)
+ timer.setInterval(0)
timer.timeout.connect(self._execute)
self._timer = timer
+ self._switch_counter = self.count_timeout
+
+ def process(self, func, *args, **kwargs):
+ item = MainThreadItem(func, *args, **kwargs)
+ self.add_item(item)
def add_item(self, item):
self._items_to_process.append(item)
@@ -62,6 +67,12 @@ class MainThreadProcess(QtCore.QObject):
if not self._items_to_process:
return
+ if self._switch_counter > 0:
+ self._switch_counter -= 1
+ return
+
+ self._switch_counter = self.count_timeout
+
item = self._items_to_process.popleft()
item.process()
@@ -173,11 +184,21 @@ class PublishReport:
self._stored_plugins.append(plugin)
+ plugin_data_item = self._create_plugin_data_item(plugin)
+
+ self._plugin_data_with_plugin.append({
+ "plugin": plugin,
+ "data": plugin_data_item
+ })
+ self._plugin_data.append(plugin_data_item)
+ return plugin_data_item
+
+ def _create_plugin_data_item(self, plugin):
label = None
if hasattr(plugin, "label"):
label = plugin.label
- plugin_data_item = {
+ return {
"name": plugin.__name__,
"label": label,
"order": plugin.order,
@@ -186,12 +207,6 @@ class PublishReport:
"skipped": False,
"passed": False
}
- self._plugin_data_with_plugin.append({
- "plugin": plugin,
- "data": plugin_data_item
- })
- self._plugin_data.append(plugin_data_item)
- return plugin_data_item
def set_plugin_skipped(self):
"""Set that current plugin has been skipped."""
@@ -241,7 +256,7 @@ class PublishReport:
if publish_plugins:
for plugin in publish_plugins:
if plugin not in self._stored_plugins:
- plugins_data.append(self._add_plugin_data_item(plugin))
+ plugins_data.append(self._create_plugin_data_item(plugin))
crashed_file_paths = {}
if self._publish_discover_result is not None:
@@ -971,6 +986,9 @@ class PublisherController:
self._publish_next_process()
+ def reset_project_data_cache(self):
+ self._asset_docs_cache.reset()
+
def collect_families_from_instances(instances, only_active=False):
"""Collect all families for passed publish instances.
diff --git a/openpype/tools/publisher/publish_report_viewer/__init__.py b/openpype/tools/publisher/publish_report_viewer/__init__.py
index 3cfaaa5a05..ce1cc3729c 100644
--- a/openpype/tools/publisher/publish_report_viewer/__init__.py
+++ b/openpype/tools/publisher/publish_report_viewer/__init__.py
@@ -1,3 +1,6 @@
+from .report_items import (
+ PublishReport
+)
from .widgets import (
PublishReportViewerWidget
)
@@ -8,6 +11,8 @@ from .window import (
__all__ = (
+ "PublishReport",
+
"PublishReportViewerWidget",
"PublishReportViewerWindow",
diff --git a/openpype/tools/publisher/publish_report_viewer/model.py b/openpype/tools/publisher/publish_report_viewer/model.py
index 460d3e12d1..a88129a358 100644
--- a/openpype/tools/publisher/publish_report_viewer/model.py
+++ b/openpype/tools/publisher/publish_report_viewer/model.py
@@ -28,6 +28,8 @@ class InstancesModel(QtGui.QStandardItemModel):
self.clear()
self._items_by_id.clear()
self._plugin_items_by_id.clear()
+ if not report_item:
+ return
root_item = self.invisibleRootItem()
@@ -119,6 +121,8 @@ class PluginsModel(QtGui.QStandardItemModel):
self.clear()
self._items_by_id.clear()
self._plugin_items_by_id.clear()
+ if not report_item:
+ return
root_item = self.invisibleRootItem()
diff --git a/openpype/tools/publisher/publish_report_viewer/report_items.py b/openpype/tools/publisher/publish_report_viewer/report_items.py
new file mode 100644
index 0000000000..b47d14da25
--- /dev/null
+++ b/openpype/tools/publisher/publish_report_viewer/report_items.py
@@ -0,0 +1,126 @@
+import uuid
+import collections
+import copy
+
+
+class PluginItem:
+ def __init__(self, plugin_data):
+ self._id = uuid.uuid4()
+
+ self.name = plugin_data["name"]
+ self.label = plugin_data["label"]
+ self.order = plugin_data["order"]
+ self.skipped = plugin_data["skipped"]
+ self.passed = plugin_data["passed"]
+
+ errored = False
+ for instance_data in plugin_data["instances_data"]:
+ for log_item in instance_data["logs"]:
+ errored = log_item["type"] == "error"
+ if errored:
+ break
+ if errored:
+ break
+
+ self.errored = errored
+
+ @property
+ def id(self):
+ return self._id
+
+
+class InstanceItem:
+ def __init__(self, instance_id, instance_data, logs_by_instance_id):
+ self._id = instance_id
+ self.label = instance_data.get("label") or instance_data.get("name")
+ self.family = instance_data.get("family")
+ self.removed = not instance_data.get("exists", True)
+
+ logs = logs_by_instance_id.get(instance_id) or []
+ errored = False
+ for log_item in logs:
+ if log_item.errored:
+ errored = True
+ break
+
+ self.errored = errored
+
+ @property
+ def id(self):
+ return self._id
+
+
+class LogItem:
+ def __init__(self, log_item_data, plugin_id, instance_id):
+ self._instance_id = instance_id
+ self._plugin_id = plugin_id
+ self._errored = log_item_data["type"] == "error"
+ self.data = log_item_data
+
+ def __getitem__(self, key):
+ return self.data[key]
+
+ @property
+ def errored(self):
+ return self._errored
+
+ @property
+ def instance_id(self):
+ return self._instance_id
+
+ @property
+ def plugin_id(self):
+ return self._plugin_id
+
+
+class PublishReport:
+ def __init__(self, report_data):
+ data = copy.deepcopy(report_data)
+
+ context_data = data["context"]
+ context_data["name"] = "context"
+ context_data["label"] = context_data["label"] or "Context"
+
+ logs = []
+ plugins_items_by_id = {}
+ plugins_id_order = []
+ for plugin_data in data["plugins_data"]:
+ item = PluginItem(plugin_data)
+ plugins_id_order.append(item.id)
+ plugins_items_by_id[item.id] = item
+ for instance_data_item in plugin_data["instances_data"]:
+ instance_id = instance_data_item["id"]
+ for log_item_data in instance_data_item["logs"]:
+ log_item = LogItem(
+ copy.deepcopy(log_item_data), item.id, instance_id
+ )
+ logs.append(log_item)
+
+ logs_by_instance_id = collections.defaultdict(list)
+ for log_item in logs:
+ logs_by_instance_id[log_item.instance_id].append(log_item)
+
+ instance_items_by_id = {}
+ instance_items_by_family = {}
+ context_item = InstanceItem(None, context_data, logs_by_instance_id)
+ instance_items_by_id[context_item.id] = context_item
+ instance_items_by_family[context_item.family] = [context_item]
+
+ for instance_id, instance_data in data["instances"].items():
+ item = InstanceItem(
+ instance_id, instance_data, logs_by_instance_id
+ )
+ instance_items_by_id[item.id] = item
+ if item.family not in instance_items_by_family:
+ instance_items_by_family[item.family] = []
+ instance_items_by_family[item.family].append(item)
+
+ self.instance_items_by_id = instance_items_by_id
+ self.instance_items_by_family = instance_items_by_family
+
+ self.plugins_id_order = plugins_id_order
+ self.plugins_items_by_id = plugins_items_by_id
+
+ self.logs = logs
+
+ self.crashed_plugin_paths = report_data["crashed_file_paths"]
diff --git a/openpype/tools/publisher/publish_report_viewer/widgets.py b/openpype/tools/publisher/publish_report_viewer/widgets.py
index 24f1d33d0e..fd226ea0e4 100644
--- a/openpype/tools/publisher/publish_report_viewer/widgets.py
+++ b/openpype/tools/publisher/publish_report_viewer/widgets.py
@@ -1,10 +1,8 @@
-import copy
-import uuid
-
-from Qt import QtWidgets, QtCore
+from Qt import QtWidgets, QtCore, QtGui
from openpype.widgets.nice_checkbox import NiceCheckbox
+# from openpype.tools.utils import DeselectableTreeView
from .constants import (
ITEM_ID_ROLE,
ITEM_IS_GROUP_ROLE
@@ -16,98 +14,127 @@ from .model import (
PluginsModel,
PluginProxyModel
)
+from .report_items import PublishReport
+
+FILEPATH_ROLE = QtCore.Qt.UserRole + 1
+TRACEBACK_ROLE = QtCore.Qt.UserRole + 2
+IS_DETAIL_ITEM_ROLE = QtCore.Qt.UserRole + 3
-class PluginItem:
- def __init__(self, plugin_data):
- self._id = uuid.uuid4()
+class PluginLoadReportModel(QtGui.QStandardItemModel):
+ def set_report(self, report):
+ parent = self.invisibleRootItem()
+ parent.removeRows(0, parent.rowCount())
- self.name = plugin_data["name"]
- self.label = plugin_data["label"]
- self.order = plugin_data["order"]
- self.skipped = plugin_data["skipped"]
- self.passed = plugin_data["passed"]
+ new_items = []
+ new_items_by_filepath = {}
+ for filepath in report.crashed_plugin_paths.keys():
+ item = QtGui.QStandardItem(filepath)
+ new_items.append(item)
+ new_items_by_filepath[filepath] = item
- logs = []
- errored = False
- for instance_data in plugin_data["instances_data"]:
- for log_item in instance_data["logs"]:
- if not errored:
- errored = log_item["type"] == "error"
- logs.append(copy.deepcopy(log_item))
+ if not new_items:
+ return
- self.errored = errored
- self.logs = logs
-
- @property
- def id(self):
- return self._id
+ parent.appendRows(new_items)
+ for filepath, item in new_items_by_filepath.items():
+ traceback_txt = report.crashed_plugin_paths[filepath]
+ detail_item = QtGui.QStandardItem()
+ detail_item.setData(filepath, FILEPATH_ROLE)
+ detail_item.setData(traceback_txt, TRACEBACK_ROLE)
+ detail_item.setData(True, IS_DETAIL_ITEM_ROLE)
+ item.appendRow(detail_item)
-class InstanceItem:
- def __init__(self, instance_id, instance_data, report_data):
- self._id = instance_id
- self.label = instance_data.get("label") or instance_data.get("name")
- self.family = instance_data.get("family")
- self.removed = not instance_data.get("exists", True)
+class DetailWidget(QtWidgets.QTextEdit):
+ def __init__(self, text, *args, **kwargs):
+ super(DetailWidget, self).__init__(*args, **kwargs)
- logs = []
- for plugin_data in report_data["plugins_data"]:
- for instance_data_item in plugin_data["instances_data"]:
- if instance_data_item["id"] == self._id:
- logs.extend(copy.deepcopy(instance_data_item["logs"]))
+ self.setReadOnly(True)
+ self.setHtml(text)
+ self.setTextInteractionFlags(QtCore.Qt.TextBrowserInteraction)
+ self.setWordWrapMode(
+ QtGui.QTextOption.WrapAtWordBoundaryOrAnywhere
+ )
- errored = False
- for log in logs:
- if log["type"] == "error":
- errored = True
- break
-
- self.errored = errored
- self.logs = logs
-
- @property
- def id(self):
- return self._id
+ def sizeHint(self):
+ content_margins = (
+ self.contentsMargins().top()
+ + self.contentsMargins().bottom()
+ )
+ size = self.document().documentLayout().documentSize().toSize()
+ size.setHeight(size.height() + content_margins)
+ return size
-class PublishReport:
- def __init__(self, report_data):
- data = copy.deepcopy(report_data)
+class PluginLoadReportWidget(QtWidgets.QWidget):
+ def __init__(self, parent):
+ super(PluginLoadReportWidget, self).__init__(parent)
- context_data = data["context"]
- context_data["name"] = "context"
- context_data["label"] = context_data["label"] or "Context"
+ view = QtWidgets.QTreeView(self)
+ view.setEditTriggers(view.NoEditTriggers)
+ view.setTextElideMode(QtCore.Qt.ElideLeft)
+ view.setHeaderHidden(True)
+ view.setAlternatingRowColors(True)
+ view.setVerticalScrollMode(view.ScrollPerPixel)
- instance_items_by_id = {}
- instance_items_by_family = {}
- context_item = InstanceItem(None, context_data, data)
- instance_items_by_id[context_item.id] = context_item
- instance_items_by_family[context_item.family] = [context_item]
+ model = PluginLoadReportModel()
+ view.setModel(model)
- for instance_id, instance_data in data["instances"].items():
- item = InstanceItem(instance_id, instance_data, data)
- instance_items_by_id[item.id] = item
- if item.family not in instance_items_by_family:
- instance_items_by_family[item.family] = []
- instance_items_by_family[item.family].append(item)
+ layout = QtWidgets.QHBoxLayout(self)
+ layout.setContentsMargins(0, 0, 0, 0)
+ layout.addWidget(view, 1)
- all_logs = []
- plugins_items_by_id = {}
- plugins_id_order = []
- for plugin_data in data["plugins_data"]:
- item = PluginItem(plugin_data)
- plugins_id_order.append(item.id)
- plugins_items_by_id[item.id] = item
- all_logs.extend(copy.deepcopy(item.logs))
+ view.expanded.connect(self._on_expand)
- self.instance_items_by_id = instance_items_by_id
- self.instance_items_by_family = instance_items_by_family
+ self._view = view
+ self._model = model
+ self._widgets_by_filepath = {}
- self.plugins_id_order = plugins_id_order
- self.plugins_items_by_id = plugins_items_by_id
+ def _on_expand(self, index):
+ for row in range(self._model.rowCount(index)):
+ child_index = self._model.index(row, index.column(), index)
+ self._create_widget(child_index)
- self.logs = all_logs
+ def showEvent(self, event):
+ super(PluginLoadReportWidget, self).showEvent(event)
+ self._update_widgets_size_hints()
+
+ def resizeEvent(self, event):
+ super(PluginLoadReportWidget, self).resizeEvent(event)
+ self._update_widgets_size_hints()
+
+ def _update_widgets_size_hints(self):
+ for item in self._widgets_by_filepath.values():
+ widget, index = item
+ if not widget.isVisible():
+ continue
+ self._model.setData(
+ index, widget.sizeHint(), QtCore.Qt.SizeHintRole
+ )
+
+ def _create_widget(self, index):
+ if not index.data(IS_DETAIL_ITEM_ROLE):
+ return
+
+ filepath = index.data(FILEPATH_ROLE)
+ if filepath in self._widgets_by_filepath:
+ return
+
+ traceback_txt = index.data(TRACEBACK_ROLE)
+ detail_text = (
+ "Filepath:
"
+ "{}
"
+ "Traceback:
"
+ "{}"
+ ).format(filepath, traceback_txt.replace("\n", "
"))
+ widget = DetailWidget(detail_text, self)
+ self._view.setIndexWidget(index, widget)
+ self._widgets_by_filepath[filepath] = (widget, index)
+
+ def set_report(self, report):
+ self._widgets_by_filepath = {}
+ self._model.set_report(report)
class DetailsWidget(QtWidgets.QWidget):
@@ -123,11 +150,50 @@ class DetailsWidget(QtWidgets.QWidget):
layout.addWidget(output_widget)
self._output_widget = output_widget
+ self._report_item = None
+ self._instance_filter = set()
+ self._plugin_filter = set()
def clear(self):
self._output_widget.setPlainText("")
- def set_logs(self, logs):
+ def set_report(self, report):
+ self._report_item = report
+ self._plugin_filter = set()
+ self._instance_filter = set()
+ self._update_logs()
+
+ def set_plugin_filter(self, plugin_filter):
+ self._plugin_filter = plugin_filter
+ self._update_logs()
+
+ def set_instance_filter(self, instance_filter):
+ self._instance_filter = instance_filter
+ self._update_logs()
+
+ def _update_logs(self):
+ if not self._report_item:
+ self._output_widget.setPlainText("")
+ return
+
+ filtered_logs = []
+ for log in self._report_item.logs:
+ if (
+ self._instance_filter
+ and log.instance_id not in self._instance_filter
+ ):
+ continue
+
+ if (
+ self._plugin_filter
+ and log.plugin_id not in self._plugin_filter
+ ):
+ continue
+ filtered_logs.append(log)
+
+ self._set_logs(filtered_logs)
+
+ def _set_logs(self, logs):
lines = []
for log in logs:
if log["type"] == "record":
@@ -148,6 +214,60 @@ class DetailsWidget(QtWidgets.QWidget):
self._output_widget.setPlainText(text)
+class DeselectableTreeView(QtWidgets.QTreeView):
+ """A tree view that deselects on clicking on an empty area in the view"""
+
+ def mousePressEvent(self, event):
+ index = self.indexAt(event.pos())
+ clear_selection = False
+ if not index.isValid():
+ modifiers = QtWidgets.QApplication.keyboardModifiers()
+ if modifiers == QtCore.Qt.ShiftModifier:
+ return
+ elif modifiers == QtCore.Qt.ControlModifier:
+ return
+ clear_selection = True
+ else:
+ indexes = self.selectedIndexes()
+ if len(indexes) == 1 and index in indexes:
+ clear_selection = True
+
+ if clear_selection:
+ # clear the selection
+ self.clearSelection()
+ # clear the current index
+ self.setCurrentIndex(QtCore.QModelIndex())
+ event.accept()
+ return
+
+ QtWidgets.QTreeView.mousePressEvent(self, event)
+
+
+class DetailsPopup(QtWidgets.QDialog):
+ closed = QtCore.Signal()
+
+ def __init__(self, parent, center_widget):
+ super(DetailsPopup, self).__init__(parent)
+ self.setWindowTitle("Report Details")
+ layout = QtWidgets.QHBoxLayout(self)
+
+ self._center_widget = center_widget
+ self._first_show = True
+ self._layout = layout
+
+ def showEvent(self, event):
+ layout = self.layout()
+ layout.insertWidget(0, self._center_widget)
+ super(DetailsPopup, self).showEvent(event)
+ if self._first_show:
+ self._first_show = False
+ self.resize(700, 400)
+
+ def closeEvent(self, event):
+ super(DetailsPopup, self).closeEvent(event)
+ self.closed.emit()
+
+
class PublishReportViewerWidget(QtWidgets.QWidget):
def __init__(self, parent=None):
super(PublishReportViewerWidget, self).__init__(parent)
@@ -171,12 +291,13 @@ class PublishReportViewerWidget(QtWidgets.QWidget):
removed_instances_layout.addWidget(removed_instances_check, 0)
removed_instances_layout.addWidget(removed_instances_label, 1)
- instances_view = QtWidgets.QTreeView(self)
+ instances_view = DeselectableTreeView(self)
instances_view.setObjectName("PublishDetailViews")
instances_view.setModel(instances_proxy)
instances_view.setIndentation(0)
instances_view.setHeaderHidden(True)
instances_view.setEditTriggers(QtWidgets.QTreeView.NoEditTriggers)
+ instances_view.setSelectionMode(QtWidgets.QTreeView.ExtendedSelection)
instances_view.setExpandsOnDoubleClick(False)
instances_delegate = GroupItemDelegate(instances_view)
@@ -191,29 +312,49 @@ class PublishReportViewerWidget(QtWidgets.QWidget):
skipped_plugins_layout.addWidget(skipped_plugins_check, 0)
skipped_plugins_layout.addWidget(skipped_plugins_label, 1)
- plugins_view = QtWidgets.QTreeView(self)
+ plugins_view = DeselectableTreeView(self)
plugins_view.setObjectName("PublishDetailViews")
plugins_view.setModel(plugins_proxy)
plugins_view.setIndentation(0)
plugins_view.setHeaderHidden(True)
+ plugins_view.setSelectionMode(QtWidgets.QTreeView.ExtendedSelection)
plugins_view.setEditTriggers(QtWidgets.QTreeView.NoEditTriggers)
plugins_view.setExpandsOnDoubleClick(False)
plugins_delegate = GroupItemDelegate(plugins_view)
plugins_view.setItemDelegate(plugins_delegate)
- details_widget = DetailsWidget(self)
+ details_widget = QtWidgets.QWidget(self)
+ details_tab_widget = QtWidgets.QTabWidget(details_widget)
+ details_popup_btn = QtWidgets.QPushButton("PopUp", details_widget)
- layout = QtWidgets.QGridLayout(self)
+ details_layout = QtWidgets.QVBoxLayout(details_widget)
+ details_layout.setContentsMargins(0, 0, 0, 0)
+ details_layout.addWidget(details_tab_widget, 1)
+ details_layout.addWidget(details_popup_btn, 0)
+
+ details_popup = DetailsPopup(self, details_tab_widget)
+
+ logs_text_widget = DetailsWidget(details_tab_widget)
+ plugin_load_report_widget = PluginLoadReportWidget(details_tab_widget)
+
+ details_tab_widget.addTab(logs_text_widget, "Logs")
+ details_tab_widget.addTab(plugin_load_report_widget, "Crashed plugins")
+
+ middle_widget = QtWidgets.QWidget(self)
+ middle_layout = QtWidgets.QGridLayout(middle_widget)
+ middle_layout.setContentsMargins(0, 0, 0, 0)
# Row 1
- layout.addLayout(removed_instances_layout, 0, 0)
- layout.addLayout(skipped_plugins_layout, 0, 1)
+ middle_layout.addLayout(removed_instances_layout, 0, 0)
+ middle_layout.addLayout(skipped_plugins_layout, 0, 1)
# Row 2
- layout.addWidget(instances_view, 1, 0)
- layout.addWidget(plugins_view, 1, 1)
- layout.addWidget(details_widget, 1, 2)
+ middle_layout.addWidget(instances_view, 1, 0)
+ middle_layout.addWidget(plugins_view, 1, 1)
- layout.setColumnStretch(2, 1)
+ layout = QtWidgets.QHBoxLayout(self)
+ layout.setContentsMargins(0, 0, 0, 0)
+ layout.addWidget(middle_widget, 0)
+ layout.addWidget(details_widget, 1)
instances_view.selectionModel().selectionChanged.connect(
self._on_instance_change
@@ -230,10 +371,13 @@ class PublishReportViewerWidget(QtWidgets.QWidget):
removed_instances_check.stateChanged.connect(
self._on_removed_instances_check
)
+ details_popup_btn.clicked.connect(self._on_details_popup)
+ details_popup.closed.connect(self._on_popup_close)
self._ignore_selection_changes = False
self._report_item = None
- self._details_widget = details_widget
+ self._logs_text_widget = logs_text_widget
+ self._plugin_load_report_widget = plugin_load_report_widget
self._removed_instances_check = removed_instances_check
self._instances_view = instances_view
@@ -248,6 +392,10 @@ class PublishReportViewerWidget(QtWidgets.QWidget):
self._plugins_model = plugins_model
self._plugins_proxy = plugins_proxy
+ self._details_widget = details_widget
+ self._details_tab_widget = details_tab_widget
+ self._details_popup = details_popup
+
def _on_instance_view_clicked(self, index):
if not index.isValid() or not index.data(ITEM_IS_GROUP_ROLE):
return
@@ -266,62 +414,46 @@ class PublishReportViewerWidget(QtWidgets.QWidget):
else:
self._plugins_view.expand(index)
- def set_report(self, report_data):
+ def set_report_data(self, report_data):
+ report = PublishReport(report_data)
+ self.set_report(report)
+
+ def set_report(self, report):
self._ignore_selection_changes = True
- report_item = PublishReport(report_data)
- self._report_item = report_item
+ self._report_item = report
- self._instances_model.set_report(report_item)
- self._plugins_model.set_report(report_item)
- self._details_widget.set_logs(report_item.logs)
+ self._instances_model.set_report(report)
+ self._plugins_model.set_report(report)
+ self._logs_text_widget.set_report(report)
+ self._plugin_load_report_widget.set_report(report)
self._ignore_selection_changes = False
+ self._instances_view.expandAll()
+ self._plugins_view.expandAll()
+
def _on_instance_change(self, *_args):
if self._ignore_selection_changes:
return
- valid_index = None
+ instance_ids = set()
for index in self._instances_view.selectedIndexes():
if index.isValid():
- valid_index = index
- break
+ instance_ids.add(index.data(ITEM_ID_ROLE))
- if valid_index is None:
- return
-
- if self._plugins_view.selectedIndexes():
- self._ignore_selection_changes = True
- self._plugins_view.selectionModel().clearSelection()
- self._ignore_selection_changes = False
-
- plugin_id = valid_index.data(ITEM_ID_ROLE)
- instance_item = self._report_item.instance_items_by_id[plugin_id]
- self._details_widget.set_logs(instance_item.logs)
+ self._logs_text_widget.set_instance_filter(instance_ids)
def _on_plugin_change(self, *_args):
if self._ignore_selection_changes:
return
- valid_index = None
+ plugin_ids = set()
for index in self._plugins_view.selectedIndexes():
if index.isValid():
- valid_index = index
- break
+ plugin_ids.add(index.data(ITEM_ID_ROLE))
- if valid_index is None:
- self._details_widget.set_logs(self._report_item.logs)
- return
-
- if self._instances_view.selectedIndexes():
- self._ignore_selection_changes = True
- self._instances_view.selectionModel().clearSelection()
- self._ignore_selection_changes = False
-
- plugin_id = valid_index.data(ITEM_ID_ROLE)
- plugin_item = self._report_item.plugins_items_by_id[plugin_id]
- self._details_widget.set_logs(plugin_item.logs)
+ self._logs_text_widget.set_plugin_filter(plugin_ids)
def _on_skipped_plugin_check(self):
self._plugins_proxy.set_ignore_skipped(
@@ -332,3 +464,16 @@ class PublishReportViewerWidget(QtWidgets.QWidget):
self._instances_proxy.set_ignore_removed(
self._removed_instances_check.isChecked()
)
+
+ def _on_details_popup(self):
+ self._details_widget.setVisible(False)
+ self._details_popup.show()
+
+ def _on_popup_close(self):
+ self._details_widget.setVisible(True)
+ layout = self._details_widget.layout()
+ layout.insertWidget(0, self._details_tab_widget)
+
+ def close_details_popup(self):
+ if self._details_popup.isVisible():
+ self._details_popup.close()
diff --git a/openpype/tools/publisher/publish_report_viewer/window.py b/openpype/tools/publisher/publish_report_viewer/window.py
index 7a0fef7d91..678884677c 100644
--- a/openpype/tools/publisher/publish_report_viewer/window.py
+++ b/openpype/tools/publisher/publish_report_viewer/window.py
@@ -1,29 +1,355 @@
-from Qt import QtWidgets
+import os
+import json
+import six
+import appdirs
+from Qt import QtWidgets, QtCore, QtGui
from openpype import style
+from openpype.lib import JSONSettingRegistry
+from openpype.resources import get_openpype_icon_filepath
+from openpype.tools import resources
+from openpype.tools.utils import (
+ IconButton,
+ paint_image_with_color
+)
+
+from openpype.tools.utils.delegates import PrettyTimeDelegate
+
if __package__:
from .widgets import PublishReportViewerWidget
+ from .report_items import PublishReport
else:
from widgets import PublishReportViewerWidget
+ from report_items import PublishReport
+
+
+FILEPATH_ROLE = QtCore.Qt.UserRole + 1
+MODIFIED_ROLE = QtCore.Qt.UserRole + 2
+
+
+class PublisherReportRegistry(JSONSettingRegistry):
+ """Class handling storing publish report tool.
+
+ Attributes:
+ vendor (str): Name used for path construction.
+ product (str): Additional name used for path construction.
+
+ """
+
+ def __init__(self):
+ self.vendor = "pypeclub"
+ self.product = "openpype"
+ name = "publish_report_viewer"
+ path = appdirs.user_data_dir(self.product, self.vendor)
+ super(PublisherReportRegistry, self).__init__(name, path)
+
+
+class LoadedFilesMopdel(QtGui.QStandardItemModel):
+ def __init__(self, *args, **kwargs):
+ super(LoadedFilesMopdel, self).__init__(*args, **kwargs)
+ self.setColumnCount(2)
+ self._items_by_filepath = {}
+ self._reports_by_filepath = {}
+
+ self._registry = PublisherReportRegistry()
+
+ self._loading_registry = False
+ self._load_registry()
+
+ def headerData(self, section, orientation, role):
+ if role in (QtCore.Qt.DisplayRole, QtCore.Qt.EditRole):
+ if section == 0:
+ return "Exports"
+ if section == 1:
+ return "Modified"
+ return ""
+ super(LoadedFilesMopdel, self).headerData(section, orientation, role)
+
+ def _load_registry(self):
+ self._loading_registry = True
+ try:
+ filepaths = self._registry.get_item("filepaths")
+ self.add_filepaths(filepaths)
+ except ValueError:
+ pass
+ self._loading_registry = False
+
+ def _store_registry(self):
+ if self._loading_registry:
+ return
+ filepaths = list(self._items_by_filepath.keys())
+ self._registry.set_item("filepaths", filepaths)
+
+ def data(self, index, role=None):
+ if role is None:
+ role = QtCore.Qt.DisplayRole
+
+ col = index.column()
+ if col != 0:
+ index = self.index(index.row(), 0, index.parent())
+
+ if role == QtCore.Qt.ToolTipRole:
+ if col == 0:
+ role = FILEPATH_ROLE
+ elif col == 1:
+ return "File modified"
+ return None
+
+ elif role == QtCore.Qt.DisplayRole:
+ if col == 1:
+ role = MODIFIED_ROLE
+ return super(LoadedFilesMopdel, self).data(index, role)
+
+ def add_filepaths(self, filepaths):
+ if not filepaths:
+ return
+
+ if isinstance(filepaths, six.string_types):
+ filepaths = [filepaths]
+
+ filtered_paths = []
+ for filepath in filepaths:
+ normalized_path = os.path.normpath(filepath)
+ if normalized_path in self._items_by_filepath:
+ continue
+
+ if (
+ os.path.exists(normalized_path)
+ and normalized_path not in filtered_paths
+ ):
+ filtered_paths.append(normalized_path)
+
+ if not filtered_paths:
+ return
+
+ new_items = []
+ for normalized_path in filtered_paths:
+ try:
+ with open(normalized_path, "r") as stream:
+ data = json.load(stream)
+ report = PublishReport(data)
+ except Exception:
+ # TODO handle errors
+ continue
+
+ modified = os.path.getmtime(normalized_path)
+ item = QtGui.QStandardItem(os.path.basename(normalized_path))
+ item.setColumnCount(self.columnCount())
+ item.setData(normalized_path, FILEPATH_ROLE)
+ item.setData(modified, MODIFIED_ROLE)
+ new_items.append(item)
+ self._items_by_filepath[normalized_path] = item
+ self._reports_by_filepath[normalized_path] = report
+
+ if not new_items:
+ return
+
+ parent = self.invisibleRootItem()
+ parent.appendRows(new_items)
+
+ self._store_registry()
+
+ def remove_filepaths(self, filepaths):
+ if not filepaths:
+ return
+
+ if isinstance(filepaths, six.string_types):
+ filepaths = [filepaths]
+
+ filtered_paths = []
+ for filepath in filepaths:
+ normalized_path = os.path.normpath(filepath)
+ if normalized_path in self._items_by_filepath:
+ filtered_paths.append(normalized_path)
+
+ if not filtered_paths:
+ return
+
+ parent = self.invisibleRootItem()
+ for filepath in filtered_paths:
+ self._reports_by_filepath.pop(normalized_path)
+ item = self._items_by_filepath.pop(filepath)
+ parent.removeRow(item.row())
+
+ self._store_registry()
+
+ def get_report_by_filepath(self, filepath):
+ return self._reports_by_filepath.get(filepath)
+
+
+class LoadedFilesView(QtWidgets.QTreeView):
+ selection_changed = QtCore.Signal()
+
+ def __init__(self, *args, **kwargs):
+ super(LoadedFilesView, self).__init__(*args, **kwargs)
+ self.setEditTriggers(self.NoEditTriggers)
+ self.setIndentation(0)
+ self.setAlternatingRowColors(True)
+
+ model = LoadedFilesMopdel()
+ self.setModel(model)
+
+ time_delegate = PrettyTimeDelegate()
+ self.setItemDelegateForColumn(1, time_delegate)
+
+ remove_btn = IconButton(self)
+ remove_icon_path = resources.get_icon_path("delete")
+ loaded_remove_image = QtGui.QImage(remove_icon_path)
+ pix = paint_image_with_color(loaded_remove_image, QtCore.Qt.white)
+ icon = QtGui.QIcon(pix)
+ remove_btn.setIcon(icon)
+
+ model.rowsInserted.connect(self._on_rows_inserted)
+ remove_btn.clicked.connect(self._on_remove_clicked)
+ self.selectionModel().selectionChanged.connect(
+ self._on_selection_change
+ )
+
+ self._model = model
+ self._time_delegate = time_delegate
+ self._remove_btn = remove_btn
+
+ def _update_remove_btn(self):
+ viewport = self.viewport()
+ height = viewport.height() + self.header().height()
+ pos_x = viewport.width() - self._remove_btn.width() - 5
+ pos_y = height - self._remove_btn.height() - 5
+ self._remove_btn.move(max(0, pos_x), max(0, pos_y))
+
+ def _on_rows_inserted(self):
+ header = self.header()
+ header.resizeSections(header.ResizeToContents)
+
+ def resizeEvent(self, event):
+ super(LoadedFilesView, self).resizeEvent(event)
+ self._update_remove_btn()
+
+ def showEvent(self, event):
+ super(LoadedFilesView, self).showEvent(event)
+ self._update_remove_btn()
+ header = self.header()
+ header.resizeSections(header.ResizeToContents)
+
+ def _on_selection_change(self):
+ self.selection_changed.emit()
+
+ def add_filepaths(self, filepaths):
+ self._model.add_filepaths(filepaths)
+ self._fill_selection()
+
+ def remove_filepaths(self, filepaths):
+ self._model.remove_filepaths(filepaths)
+ self._fill_selection()
+
+ def _on_remove_clicked(self):
+ index = self.currentIndex()
+ filepath = index.data(FILEPATH_ROLE)
+ self.remove_filepaths(filepath)
+
+ def _fill_selection(self):
+ index = self.currentIndex()
+ if index.isValid():
+ return
+
+ index = self._model.index(0, 0)
+ if index.isValid():
+ self.setCurrentIndex(index)
+
+ def get_current_report(self):
+ index = self.currentIndex()
+ filepath = index.data(FILEPATH_ROLE)
+ return self._model.get_report_by_filepath(filepath)
+
+
+class LoadedFilesWidget(QtWidgets.QWidget):
+ report_changed = QtCore.Signal()
+
+ def __init__(self, parent):
+ super(LoadedFilesWidget, self).__init__(parent)
+
+ self.setAcceptDrops(True)
+
+ view = LoadedFilesView(self)
+
+ layout = QtWidgets.QVBoxLayout(self)
+ layout.setContentsMargins(0, 0, 0, 0)
+ layout.addWidget(view, 1)
+
+ view.selection_changed.connect(self._on_report_change)
+
+ self._view = view
+
+ def dragEnterEvent(self, event):
+ mime_data = event.mimeData()
+ if mime_data.hasUrls():
+ event.setDropAction(QtCore.Qt.CopyAction)
+ event.accept()
+
+ def dragLeaveEvent(self, event):
+ event.accept()
+
+ def dropEvent(self, event):
+ mime_data = event.mimeData()
+ if mime_data.hasUrls():
+ filepaths = []
+ for url in mime_data.urls():
+ filepath = url.toLocalFile()
+ ext = os.path.splitext(filepath)[-1]
+ if os.path.exists(filepath) and ext == ".json":
+ filepaths.append(filepath)
+ self._add_filepaths(filepaths)
+ event.accept()
+
+ def _on_report_change(self):
+ self.report_changed.emit()
+
+ def _add_filepaths(self, filepaths):
+ self._view.add_filepaths(filepaths)
+
+ def get_current_report(self):
+ return self._view.get_current_report()
class PublishReportViewerWindow(QtWidgets.QWidget):
- # TODO add buttons to be able load report file or paste content of report
default_width = 1200
default_height = 600
def __init__(self, parent=None):
super(PublishReportViewerWindow, self).__init__(parent)
+ self.setWindowTitle("Publish report viewer")
+ icon = QtGui.QIcon(get_openpype_icon_filepath())
+ self.setWindowIcon(icon)
- main_widget = PublishReportViewerWidget(self)
+ body = QtWidgets.QSplitter(self)
+ body.setContentsMargins(0, 0, 0, 0)
+ body.setSizePolicy(
+ QtWidgets.QSizePolicy.Expanding,
+ QtWidgets.QSizePolicy.Expanding
+ )
+ body.setOrientation(QtCore.Qt.Horizontal)
+
+ loaded_files_widget = LoadedFilesWidget(body)
+ main_widget = PublishReportViewerWidget(body)
+
+ body.addWidget(loaded_files_widget)
+ body.addWidget(main_widget)
+ body.setStretchFactor(0, 70)
+ body.setStretchFactor(1, 65)
layout = QtWidgets.QHBoxLayout(self)
- layout.addWidget(main_widget)
+ layout.addWidget(body, 1)
+ loaded_files_widget.report_changed.connect(self._on_report_change)
+
+ self._loaded_files_widget = loaded_files_widget
self._main_widget = main_widget
self.resize(self.default_width, self.default_height)
self.setStyleSheet(style.load_stylesheet())
+ def _on_report_change(self):
+ report = self._loaded_files_widget.get_current_report()
+ self.set_report(report)
+
def set_report(self, report_data):
self._main_widget.set_report(report_data)
diff --git a/openpype/tools/publisher/widgets/create_dialog.py b/openpype/tools/publisher/widgets/create_dialog.py
index f9f8310e09..c5b77eca8b 100644
--- a/openpype/tools/publisher/widgets/create_dialog.py
+++ b/openpype/tools/publisher/widgets/create_dialog.py
@@ -174,6 +174,8 @@ class CreatorDescriptionWidget(QtWidgets.QWidget):
class CreateDialog(QtWidgets.QDialog):
+ default_size = (900, 500)
+
def __init__(
self, controller, asset_name=None, task_name=None, parent=None
):
@@ -262,11 +264,16 @@ class CreateDialog(QtWidgets.QDialog):
mid_layout.addLayout(form_layout, 0)
mid_layout.addWidget(create_btn, 0)
+ splitter_widget = QtWidgets.QSplitter(self)
+ splitter_widget.addWidget(context_widget)
+ splitter_widget.addWidget(mid_widget)
+ splitter_widget.addWidget(pre_create_widget)
+ splitter_widget.setStretchFactor(0, 1)
+ splitter_widget.setStretchFactor(1, 1)
+ splitter_widget.setStretchFactor(2, 1)
+
layout = QtWidgets.QHBoxLayout(self)
- layout.setSpacing(10)
- layout.addWidget(context_widget, 1)
- layout.addWidget(mid_widget, 1)
- layout.addWidget(pre_create_widget, 1)
+ layout.addWidget(splitter_widget, 1)
prereq_timer = QtCore.QTimer()
prereq_timer.setInterval(50)
@@ -289,6 +296,8 @@ class CreateDialog(QtWidgets.QDialog):
controller.add_plugins_refresh_callback(self._on_plugins_refresh)
+ self._splitter_widget = splitter_widget
+
self._pre_create_widget = pre_create_widget
self._context_widget = context_widget
@@ -308,6 +317,7 @@ class CreateDialog(QtWidgets.QDialog):
self.create_btn = create_btn
self._prereq_timer = prereq_timer
+ self._first_show = True
def _context_change_is_enabled(self):
return self._context_widget.isEnabled()
@@ -643,6 +653,16 @@ class CreateDialog(QtWidgets.QDialog):
def showEvent(self, event):
super(CreateDialog, self).showEvent(event)
+ if self._first_show:
+ self._first_show = False
+ width, height = self.default_size
+ self.resize(width, height)
+
+ third_size = int(width / 3)
+ self._splitter_widget.setSizes(
+ [third_size, third_size, width - (2 * third_size)]
+ )
+
if self._last_pos is not None:
self.move(self._last_pos)
diff --git a/openpype/tools/publisher/widgets/publish_widget.py b/openpype/tools/publisher/widgets/publish_widget.py
index e4f3579978..80d0265dd3 100644
--- a/openpype/tools/publisher/widgets/publish_widget.py
+++ b/openpype/tools/publisher/widgets/publish_widget.py
@@ -213,7 +213,6 @@ class PublishFrame(QtWidgets.QFrame):
close_report_btn.setIcon(close_report_icon)
details_layout = QtWidgets.QVBoxLayout(details_widget)
- details_layout.setContentsMargins(0, 0, 0, 0)
details_layout.addWidget(report_view)
details_layout.addWidget(close_report_btn)
@@ -495,10 +494,11 @@ class PublishFrame(QtWidgets.QFrame):
def _on_show_details(self):
self._change_bg_property(2)
self._main_layout.setCurrentWidget(self._details_widget)
- logs = self.controller.get_publish_report()
- self._report_view.set_report(logs)
+ report_data = self.controller.get_publish_report()
+ self._report_view.set_report_data(report_data)
def _on_close_report_clicked(self):
+ self._report_view.close_details_popup()
if self.controller.get_publish_crash_error():
self._change_bg_property()
diff --git a/openpype/tools/publisher/widgets/validations_widget.py b/openpype/tools/publisher/widgets/validations_widget.py
index bb88e1783c..798c1f9d92 100644
--- a/openpype/tools/publisher/widgets/validations_widget.py
+++ b/openpype/tools/publisher/widgets/validations_widget.py
@@ -10,6 +10,9 @@ from openpype.tools.utils import BaseClickableFrame
from .widgets import (
IconValuePixmapLabel
)
+from ..constants import (
+ INSTANCE_ID_ROLE
+)
class ValidationErrorInstanceList(QtWidgets.QListView):
@@ -22,19 +25,20 @@ class ValidationErrorInstanceList(QtWidgets.QListView):
self.setObjectName("ValidationErrorInstanceList")
+ self.setHorizontalScrollBarPolicy(QtCore.Qt.ScrollBarAlwaysOff)
self.setSelectionMode(QtWidgets.QListView.ExtendedSelection)
def minimumSizeHint(self):
- result = super(ValidationErrorInstanceList, self).minimumSizeHint()
- result.setHeight(self.sizeHint().height())
- return result
+ return self.sizeHint()
def sizeHint(self):
+ result = super(ValidationErrorInstanceList, self).sizeHint()
row_count = self.model().rowCount()
height = 0
if row_count > 0:
height = self.sizeHintForRow(0) * row_count
- return QtCore.QSize(self.width(), height)
+ result.setHeight(height)
+ return result
class ValidationErrorTitleWidget(QtWidgets.QWidget):
@@ -47,6 +51,7 @@ class ValidationErrorTitleWidget(QtWidgets.QWidget):
if there is a list (Valdation error may happen on context).
"""
selected = QtCore.Signal(int)
+ instance_changed = QtCore.Signal(int)
def __init__(self, index, error_info, parent):
super(ValidationErrorTitleWidget, self).__init__(parent)
@@ -64,32 +69,38 @@ class ValidationErrorTitleWidget(QtWidgets.QWidget):
toggle_instance_btn.setArrowType(QtCore.Qt.RightArrow)
toggle_instance_btn.setMaximumWidth(14)
- exception = error_info["exception"]
- label_widget = QtWidgets.QLabel(exception.title, title_frame)
+ label_widget = QtWidgets.QLabel(error_info["title"], title_frame)
title_frame_layout = QtWidgets.QHBoxLayout(title_frame)
title_frame_layout.addWidget(toggle_instance_btn)
title_frame_layout.addWidget(label_widget)
instances_model = QtGui.QStandardItemModel()
- instances = error_info["instances"]
+ error_info = error_info["error_info"]
+
+ help_text_by_instance_id = {}
context_validation = False
if (
- not instances
- or (len(instances) == 1 and instances[0] is None)
+ not error_info
+ or (len(error_info) == 1 and error_info[0][0] is None)
):
context_validation = True
toggle_instance_btn.setArrowType(QtCore.Qt.NoArrow)
+ description = self._prepare_description(error_info[0][1])
+ help_text_by_instance_id[None] = description
else:
items = []
- for instance in instances:
+ for instance, exception in error_info:
label = instance.data.get("label") or instance.data.get("name")
item = QtGui.QStandardItem(label)
item.setFlags(
QtCore.Qt.ItemIsEnabled | QtCore.Qt.ItemIsSelectable
)
- item.setData(instance.id)
+ item.setData(label, QtCore.Qt.ToolTipRole)
+ item.setData(instance.id, INSTANCE_ID_ROLE)
items.append(item)
+ description = self._prepare_description(exception)
+ help_text_by_instance_id[instance.id] = description
instances_model.invisibleRootItem().appendRows(items)
@@ -114,17 +125,64 @@ class ValidationErrorTitleWidget(QtWidgets.QWidget):
if not context_validation:
toggle_instance_btn.clicked.connect(self._on_toggle_btn_click)
+ instances_view.selectionModel().selectionChanged.connect(
+ self._on_seleciton_change
+ )
+
self._title_frame = title_frame
self._toggle_instance_btn = toggle_instance_btn
+ self._view_layout = view_layout
+
self._instances_model = instances_model
self._instances_view = instances_view
+ self._context_validation = context_validation
+ self._help_text_by_instance_id = help_text_by_instance_id
+
+ def sizeHint(self):
+ result = super().sizeHint()
+ expected_width = 0
+ for idx in range(self._view_layout.count()):
+ expected_width += self._view_layout.itemAt(idx).sizeHint().width()
+
+ if expected_width < 200:
+ expected_width = 200
+
+ if result.width() < expected_width:
+ result.setWidth(expected_width)
+ return result
+
+ def minimumSizeHint(self):
+ return self.sizeHint()
+
+ def _prepare_description(self, exception):
+ dsc = exception.description
+ detail = exception.detail
+ if detail:
+ dsc += "
{}".format(detail)
+
+ description = dsc
+ if commonmark:
+ description = commonmark.commonmark(dsc)
+ return description
+
def _mouse_release_callback(self):
"""Mark this widget as selected on click."""
self.set_selected(True)
+ def current_desctiption_text(self):
+ if self._context_validation:
+ return self._help_text_by_instance_id[None]
+ index = self._instances_view.currentIndex()
+ # TODO make sure instance is selected
+ if not index.isValid():
+ index = self._instances_model.index(0, 0)
+
+ indence_id = index.data(INSTANCE_ID_ROLE)
+ return self._help_text_by_instance_id[indence_id]
+
@property
def is_selected(self):
"""Is widget marked a selected"""
@@ -167,6 +225,9 @@ class ValidationErrorTitleWidget(QtWidgets.QWidget):
else:
self._toggle_instance_btn.setArrowType(QtCore.Qt.RightArrow)
+ def _on_seleciton_change(self):
+ self.instance_changed.emit(self._index)
+
class ActionButton(BaseClickableFrame):
"""Plugin's action callback button.
@@ -185,13 +246,15 @@ class ActionButton(BaseClickableFrame):
action_label = action.label or action.__name__
action_icon = getattr(action, "icon", None)
label_widget = QtWidgets.QLabel(action_label, self)
+ icon_label = None
if action_icon:
icon_label = IconValuePixmapLabel(action_icon, self)
layout = QtWidgets.QHBoxLayout(self)
layout.setContentsMargins(5, 0, 5, 0)
layout.addWidget(label_widget, 1)
- layout.addWidget(icon_label, 0)
+ if icon_label:
+ layout.addWidget(icon_label, 0)
self.setSizePolicy(
QtWidgets.QSizePolicy.Minimum,
@@ -231,6 +294,7 @@ class ValidateActionsWidget(QtWidgets.QFrame):
item = self._content_layout.takeAt(0)
widget = item.widget()
if widget:
+ widget.setVisible(False)
widget.deleteLater()
self._actions_mapping = {}
@@ -363,24 +427,23 @@ class ValidationsWidget(QtWidgets.QWidget):
errors_scroll.setWidgetResizable(True)
errors_widget = QtWidgets.QWidget(errors_scroll)
- errors_widget.setFixedWidth(200)
errors_widget.setAttribute(QtCore.Qt.WA_TranslucentBackground)
errors_layout = QtWidgets.QVBoxLayout(errors_widget)
errors_layout.setContentsMargins(0, 0, 0, 0)
errors_scroll.setWidget(errors_widget)
- error_details_widget = QtWidgets.QWidget(self)
- error_details_input = QtWidgets.QTextEdit(error_details_widget)
+ error_details_frame = QtWidgets.QFrame(self)
+ error_details_input = QtWidgets.QTextEdit(error_details_frame)
error_details_input.setObjectName("InfoText")
error_details_input.setTextInteractionFlags(
QtCore.Qt.TextBrowserInteraction
)
actions_widget = ValidateActionsWidget(controller, self)
- actions_widget.setFixedWidth(140)
+ actions_widget.setMinimumWidth(140)
- error_details_layout = QtWidgets.QHBoxLayout(error_details_widget)
+ error_details_layout = QtWidgets.QHBoxLayout(error_details_frame)
error_details_layout.addWidget(error_details_input, 1)
error_details_layout.addWidget(actions_widget, 0)
@@ -389,7 +452,7 @@ class ValidationsWidget(QtWidgets.QWidget):
content_layout.setContentsMargins(0, 0, 0, 0)
content_layout.addWidget(errors_scroll, 0)
- content_layout.addWidget(error_details_widget, 1)
+ content_layout.addWidget(error_details_frame, 1)
top_label = QtWidgets.QLabel("Publish validation report", self)
top_label.setObjectName("PublishInfoMainLabel")
@@ -403,7 +466,7 @@ class ValidationsWidget(QtWidgets.QWidget):
self._top_label = top_label
self._errors_widget = errors_widget
self._errors_layout = errors_layout
- self._error_details_widget = error_details_widget
+ self._error_details_frame = error_details_frame
self._error_details_input = error_details_input
self._actions_widget = actions_widget
@@ -423,7 +486,7 @@ class ValidationsWidget(QtWidgets.QWidget):
widget.deleteLater()
self._top_label.setVisible(False)
- self._error_details_widget.setVisible(False)
+ self._error_details_frame.setVisible(False)
self._errors_widget.setVisible(False)
self._actions_widget.setVisible(False)
@@ -434,34 +497,35 @@ class ValidationsWidget(QtWidgets.QWidget):
return
self._top_label.setVisible(True)
- self._error_details_widget.setVisible(True)
+ self._error_details_frame.setVisible(True)
self._errors_widget.setVisible(True)
errors_by_title = []
for plugin_info in errors:
titles = []
- exception_by_title = {}
- instances_by_title = {}
+ error_info_by_title = {}
for error_info in plugin_info["errors"]:
exception = error_info["exception"]
title = exception.title
if title not in titles:
titles.append(title)
- instances_by_title[title] = []
- exception_by_title[title] = exception
- instances_by_title[title].append(error_info["instance"])
+ error_info_by_title[title] = []
+ error_info_by_title[title].append(
+ (error_info["instance"], exception)
+ )
for title in titles:
errors_by_title.append({
"plugin": plugin_info["plugin"],
- "exception": exception_by_title[title],
- "instances": instances_by_title[title]
+ "error_info": error_info_by_title[title],
+ "title": title
})
for idx, item in enumerate(errors_by_title):
widget = ValidationErrorTitleWidget(idx, item, self)
widget.selected.connect(self._on_select)
+ widget.instance_changed.connect(self._on_instance_change)
self._errors_layout.addWidget(widget)
self._title_widgets[idx] = widget
self._error_info[idx] = item
@@ -471,6 +535,8 @@ class ValidationsWidget(QtWidgets.QWidget):
if self._title_widgets:
self._title_widgets[0].set_selected(True)
+ self.updateGeometry()
+
def _on_select(self, index):
if self._previous_select:
if self._previous_select.index == index:
@@ -481,10 +547,19 @@ class ValidationsWidget(QtWidgets.QWidget):
error_item = self._error_info[index]
- dsc = error_item["exception"].description
+ self._actions_widget.set_plugin(error_item["plugin"])
+
+ self._update_description()
+
+ def _on_instance_change(self, index):
+ if self._previous_select and self._previous_select.index != index:
+ return
+ self._update_description()
+
+ def _update_description(self):
+ description = self._previous_select.current_desctiption_text()
if commonmark:
- html = commonmark.commonmark(dsc)
+ html = commonmark.commonmark(description)
self._error_details_input.setHtml(html)
else:
- self._error_details_input.setMarkdown(dsc)
- self._actions_widget.set_plugin(error_item["plugin"])
+ self._error_details_input.setMarkdown(description)
diff --git a/openpype/tools/publisher/widgets/widgets.py b/openpype/tools/publisher/widgets/widgets.py
index a63258efb7..fb1f0e54aa 100644
--- a/openpype/tools/publisher/widgets/widgets.py
+++ b/openpype/tools/publisher/widgets/widgets.py
@@ -535,6 +535,7 @@ class TasksCombobox(QtWidgets.QComboBox):
return
self._text = text
+ self.repaint()
def paintEvent(self, event):
"""Paint custom text without using QLineEdit.
@@ -548,6 +549,7 @@ class TasksCombobox(QtWidgets.QComboBox):
self.initStyleOption(opt)
if self._text is not None:
opt.currentText = self._text
+
style = self.style()
style.drawComplexControl(
QtWidgets.QStyle.CC_ComboBox, opt, painter, self
@@ -609,11 +611,15 @@ class TasksCombobox(QtWidgets.QComboBox):
if self._selected_items:
is_valid = True
+ valid_task_names = []
for task_name in self._selected_items:
- is_valid = self._model.is_task_name_valid(asset_name, task_name)
- if not is_valid:
- break
+ _is_valid = self._model.is_task_name_valid(asset_name, task_name)
+ if _is_valid:
+ valid_task_names.append(task_name)
+ else:
+ is_valid = _is_valid
+ self._selected_items = valid_task_names
if len(self._selected_items) == 0:
self.set_selected_item("")
@@ -625,6 +631,7 @@ class TasksCombobox(QtWidgets.QComboBox):
if multiselection_text is None:
multiselection_text = "|".join(self._selected_items)
self.set_selected_item(multiselection_text)
+
self._set_is_valid(is_valid)
def set_selected_items(self, asset_task_combinations=None):
@@ -708,8 +715,7 @@ class TasksCombobox(QtWidgets.QComboBox):
idx = self.findText(item_name)
# Set current index (must be set to -1 if is invalid)
self.setCurrentIndex(idx)
- if idx < 0:
- self.set_text(item_name)
+ self.set_text(item_name)
def reset_to_origin(self):
"""Change to task names set with last `set_selected_items` call."""
diff --git a/openpype/tools/publisher/window.py b/openpype/tools/publisher/window.py
index 642bd17589..b74e95b227 100644
--- a/openpype/tools/publisher/window.py
+++ b/openpype/tools/publisher/window.py
@@ -84,7 +84,7 @@ class PublisherWindow(QtWidgets.QDialog):
# Content
# Subset widget
- subset_frame = QtWidgets.QWidget(self)
+ subset_frame = QtWidgets.QFrame(self)
subset_views_widget = BorderedLabelWidget(
"Subsets to publish", subset_frame
@@ -225,6 +225,9 @@ class PublisherWindow(QtWidgets.QDialog):
controller.add_publish_validated_callback(self._on_publish_validated)
controller.add_publish_stopped_callback(self._on_publish_stop)
+ # Store header for TrayPublisher
+ self._header_layout = header_layout
+
self.content_stacked_layout = content_stacked_layout
self.publish_frame = publish_frame
self.subset_frame = subset_frame
diff --git a/openpype/tools/sceneinventory/model.py b/openpype/tools/sceneinventory/model.py
index d2b7f8b70f..6435e5c488 100644
--- a/openpype/tools/sceneinventory/model.py
+++ b/openpype/tools/sceneinventory/model.py
@@ -8,7 +8,7 @@ from avalon import api, io, style, schema
from avalon.vendor import qtawesome
from avalon.lib import HeroVersionType
-from avalon.tools.models import TreeModel, Item
+from openpype.tools.utils.models import TreeModel, Item
from .lib import (
get_site_icons,
diff --git a/openpype/tools/sceneinventory/view.py b/openpype/tools/sceneinventory/view.py
index 2ae8c95be4..1ed3c9fcb6 100644
--- a/openpype/tools/sceneinventory/view.py
+++ b/openpype/tools/sceneinventory/view.py
@@ -7,9 +7,13 @@ from Qt import QtWidgets, QtCore
from avalon import io, api, style
from avalon.vendor import qtawesome
from avalon.lib import HeroVersionType
-from avalon.tools import lib as tools_lib
from openpype.modules import ModulesManager
+from openpype.tools.utils.lib import (
+ get_progress_for_repre,
+ iter_model_rows,
+ format_version
+)
from .switch_dialog import SwitchAssetDialog
from .model import InventoryModel
@@ -373,7 +377,7 @@ class SceneInventoryView(QtWidgets.QTreeView):
if not repre_doc:
continue
- progress = tools_lib.get_progress_for_repre(
+ progress = get_progress_for_repre(
repre_doc,
active_site,
remote_site
@@ -544,7 +548,7 @@ class SceneInventoryView(QtWidgets.QTreeView):
"toggle": selection_model.Toggle,
}[options.get("mode", "select")]
- for item in tools_lib.iter_model_rows(model, 0):
+ for item in iter_model_rows(model, 0):
item = item.data(InventoryModel.ItemRole)
if item.get("isGroupNode"):
continue
@@ -704,7 +708,7 @@ class SceneInventoryView(QtWidgets.QTreeView):
labels = []
for version in all_versions:
is_hero = version["type"] == "hero_version"
- label = tools_lib.format_version(version["name"], is_hero)
+ label = format_version(version["name"], is_hero)
labels.append(label)
versions_by_label[label] = version["name"]
diff --git a/openpype/tools/settings/local_settings/experimental_widget.py b/openpype/tools/settings/local_settings/experimental_widget.py
index e863d9afb0..22ef952356 100644
--- a/openpype/tools/settings/local_settings/experimental_widget.py
+++ b/openpype/tools/settings/local_settings/experimental_widget.py
@@ -28,7 +28,7 @@ class LocalExperimentalToolsWidgets(QtWidgets.QWidget):
layout.addRow(empty_label)
- experimental_defs = ExperimentalTools(filter_hosts=False)
+ experimental_defs = ExperimentalTools(refresh=False)
checkboxes_by_identifier = {}
for tool in experimental_defs.tools:
checkbox = QtWidgets.QCheckBox(self)
diff --git a/openpype/tools/settings/settings/base.py b/openpype/tools/settings/settings/base.py
index bbfbc58627..706e2fdcf0 100644
--- a/openpype/tools/settings/settings/base.py
+++ b/openpype/tools/settings/settings/base.py
@@ -1,6 +1,7 @@
import sys
import json
import traceback
+import functools
from Qt import QtWidgets, QtGui, QtCore
@@ -325,7 +326,8 @@ class BaseWidget(QtWidgets.QWidget):
action = QtWidgets.QAction(project_name)
submenu.addAction(action)
- actions_mapping[action] = lambda: self._apply_values_from_project(
+ actions_mapping[action] = functools.partial(
+ self._apply_values_from_project,
project_name
)
menu.addMenu(submenu)
diff --git a/openpype/tools/settings/settings/categories.py b/openpype/tools/settings/settings/categories.py
index 14e25a54d8..663d497c36 100644
--- a/openpype/tools/settings/settings/categories.py
+++ b/openpype/tools/settings/settings/categories.py
@@ -715,7 +715,12 @@ class SettingsCategoryWidget(QtWidgets.QWidget):
self._outdated_version_label,
self._require_restart_label,
}
- if self.entity.require_restart:
+ if self.is_modifying_defaults or self.entity is None:
+ require_restart = False
+ else:
+ require_restart = self.entity.require_restart
+
+ if require_restart:
visible_label = self._require_restart_label
elif self._is_loaded_version_outdated:
visible_label = self._outdated_version_label
diff --git a/openpype/tools/settings/settings/search_dialog.py b/openpype/tools/settings/settings/search_dialog.py
index 3f987c0010..e6538cfe67 100644
--- a/openpype/tools/settings/settings/search_dialog.py
+++ b/openpype/tools/settings/settings/search_dialog.py
@@ -30,7 +30,7 @@ class RecursiveSortFilterProxyModel(QtCore.QSortFilterProxyModel):
regex = self.filterRegExp()
if not regex.isEmpty() and regex.isValid():
pattern = regex.pattern()
- compiled_regex = re.compile(pattern)
+ compiled_regex = re.compile(pattern, re.IGNORECASE)
source_model = self.sourceModel()
# Check current index itself in all columns
@@ -75,6 +75,7 @@ class SearchEntitiesDialog(QtWidgets.QDialog):
filter_changed_timer = QtCore.QTimer()
filter_changed_timer.setInterval(200)
+ filter_changed_timer.setSingleShot(True)
view.selectionModel().selectionChanged.connect(
self._on_selection_change
diff --git a/openpype/tools/standalonepublish/publish.py b/openpype/tools/standalonepublish/publish.py
index af269c4381..582e7eccf8 100644
--- a/openpype/tools/standalonepublish/publish.py
+++ b/openpype/tools/standalonepublish/publish.py
@@ -3,10 +3,10 @@ import sys
import openpype
import pyblish.api
+from openpype.tools.utils.host_tools import show_publish
def main(env):
- from avalon.tools import publish
# Registers pype's Global pyblish plugins
openpype.install()
@@ -19,7 +19,7 @@ def main(env):
continue
pyblish.api.register_plugin_path(path)
- return publish.show()
+ return show_publish()
if __name__ == "__main__":
diff --git a/openpype/tools/traypublisher/__init__.py b/openpype/tools/traypublisher/__init__.py
new file mode 100644
index 0000000000..188a234a9e
--- /dev/null
+++ b/openpype/tools/traypublisher/__init__.py
@@ -0,0 +1,6 @@
+from .window import main
+
+
+__all__ = (
+ "main",
+)
diff --git a/openpype/tools/traypublisher/window.py b/openpype/tools/traypublisher/window.py
new file mode 100644
index 0000000000..53f8ca450a
--- /dev/null
+++ b/openpype/tools/traypublisher/window.py
@@ -0,0 +1,158 @@
+"""Tray publisher is extending publisher tool.
+
+Adds ability to select project using overlay widget with list of projects.
+
+Tray publisher can be considered as host implementeation with creators and
+publishing plugins.
+"""
+
+from Qt import QtWidgets, QtCore
+
+import avalon.api
+from avalon.api import AvalonMongoDB
+from openpype.hosts.traypublisher import (
+ api as traypublisher
+)
+from openpype.tools.publisher import PublisherWindow
+from openpype.tools.utils.constants import PROJECT_NAME_ROLE
+from openpype.tools.utils.models import (
+ ProjectModel,
+ ProjectSortFilterProxy
+)
+
+
+class StandaloneOverlayWidget(QtWidgets.QFrame):
+ project_selected = QtCore.Signal(str)
+
+ def __init__(self, publisher_window):
+ super(StandaloneOverlayWidget, self).__init__(publisher_window)
+ self.setObjectName("OverlayFrame")
+
+ # Create db connection for projects model
+ dbcon = AvalonMongoDB()
+ dbcon.install()
+
+ header_label = QtWidgets.QLabel("Choose project", self)
+ header_label.setObjectName("ChooseProjectLabel")
+ # Create project models and view
+ projects_model = ProjectModel(dbcon)
+ projects_proxy = ProjectSortFilterProxy()
+ projects_proxy.setSourceModel(projects_model)
+
+ projects_view = QtWidgets.QListView(self)
+ projects_view.setModel(projects_proxy)
+ projects_view.setEditTriggers(
+ QtWidgets.QAbstractItemView.NoEditTriggers
+ )
+
+ confirm_btn = QtWidgets.QPushButton("Choose", self)
+ btns_layout = QtWidgets.QHBoxLayout()
+ btns_layout.addStretch(1)
+ btns_layout.addWidget(confirm_btn, 0)
+
+ layout = QtWidgets.QGridLayout(self)
+ layout.addWidget(header_label, 0, 1, alignment=QtCore.Qt.AlignCenter)
+ layout.addWidget(projects_view, 1, 1)
+ layout.addLayout(btns_layout, 2, 1)
+ layout.setColumnStretch(0, 1)
+ layout.setColumnStretch(1, 0)
+ layout.setColumnStretch(2, 1)
+ layout.setRowStretch(0, 0)
+ layout.setRowStretch(1, 1)
+ layout.setRowStretch(2, 0)
+
+ projects_view.doubleClicked.connect(self._on_double_click)
+ confirm_btn.clicked.connect(self._on_confirm_click)
+
+ self._projects_view = projects_view
+ self._projects_model = projects_model
+ self._confirm_btn = confirm_btn
+
+ self._publisher_window = publisher_window
+
+ def showEvent(self, event):
+ self._projects_model.refresh()
+ super(StandaloneOverlayWidget, self).showEvent(event)
+
+ def _on_double_click(self):
+ self.set_selected_project()
+
+ def _on_confirm_click(self):
+ self.set_selected_project()
+
+ def set_selected_project(self):
+ index = self._projects_view.currentIndex()
+
+ project_name = index.data(PROJECT_NAME_ROLE)
+ if not project_name:
+ return
+
+ traypublisher.set_project_name(project_name)
+ self.setVisible(False)
+ self.project_selected.emit(project_name)
+
+
+class TrayPublishWindow(PublisherWindow):
+ def __init__(self, *args, **kwargs):
+ super(TrayPublishWindow, self).__init__(reset_on_show=False)
+
+ overlay_widget = StandaloneOverlayWidget(self)
+
+ btns_widget = QtWidgets.QWidget(self)
+
+ back_to_overlay_btn = QtWidgets.QPushButton(
+ "Change project", btns_widget
+ )
+ save_btn = QtWidgets.QPushButton("Save", btns_widget)
+ # TODO implement save mechanism of tray publisher
+ save_btn.setVisible(False)
+
+ btns_layout = QtWidgets.QHBoxLayout(btns_widget)
+ btns_layout.setContentsMargins(0, 0, 0, 0)
+
+ btns_layout.addWidget(save_btn, 0)
+ btns_layout.addWidget(back_to_overlay_btn, 0)
+
+ self._header_layout.addWidget(btns_widget, 0)
+
+ overlay_widget.project_selected.connect(self._on_project_select)
+ back_to_overlay_btn.clicked.connect(self._on_back_to_overlay)
+ save_btn.clicked.connect(self._on_tray_publish_save)
+
+ self._back_to_overlay_btn = back_to_overlay_btn
+ self._overlay_widget = overlay_widget
+
+ def _on_back_to_overlay(self):
+ self._overlay_widget.setVisible(True)
+ self._resize_overlay()
+
+ def _resize_overlay(self):
+ self._overlay_widget.resize(
+ self.width(),
+ self.height()
+ )
+
+ def resizeEvent(self, event):
+ super(TrayPublishWindow, self).resizeEvent(event)
+ self._resize_overlay()
+
+ def _on_project_select(self, project_name):
+ # TODO register project specific plugin paths
+ self.controller.save_changes()
+ self.controller.reset_project_data_cache()
+
+ self.reset()
+ if not self.controller.instances:
+ self._on_create_clicked()
+
+ def _on_tray_publish_save(self):
+ self.controller.save_changes()
+ print("NOT YET IMPLEMENTED")
+
+
+def main():
+ avalon.api.install(traypublisher)
+ app = QtWidgets.QApplication([])
+ window = TrayPublishWindow()
+ window.show()
+ app.exec_()
diff --git a/openpype/tools/utils/__init__.py b/openpype/tools/utils/__init__.py
index 46af051069..c15e9f8139 100644
--- a/openpype/tools/utils/__init__.py
+++ b/openpype/tools/utils/__init__.py
@@ -2,11 +2,12 @@ from .widgets import (
PlaceholderLineEdit,
BaseClickableFrame,
ClickableFrame,
+ ClickableLabel,
ExpandBtn,
PixmapLabel,
IconButton,
)
-
+from .views import DeselectableTreeView
from .error_dialog import ErrorMessageBox
from .lib import (
WrappedCallbackItem,
@@ -14,6 +15,7 @@ from .lib import (
get_warning_pixmap,
set_style_property,
DynamicQThread,
+ qt_app_context,
)
from .models import (
@@ -24,10 +26,13 @@ __all__ = (
"PlaceholderLineEdit",
"BaseClickableFrame",
"ClickableFrame",
+ "ClickableLabel",
"ExpandBtn",
"PixmapLabel",
"IconButton",
+ "DeselectableTreeView",
+
"ErrorMessageBox",
"WrappedCallbackItem",
@@ -35,6 +40,7 @@ __all__ = (
"get_warning_pixmap",
"set_style_property",
"DynamicQThread",
+ "qt_app_context",
"RecursiveSortFilterProxyModel",
)
diff --git a/openpype/tools/utils/host_tools.py b/openpype/tools/utils/host_tools.py
index a7ad8fef3b..6ce9e818d9 100644
--- a/openpype/tools/utils/host_tools.py
+++ b/openpype/tools/utils/host_tools.py
@@ -3,8 +3,9 @@
It is possible to create `HostToolsHelper` in host implementation or
use singleton approach with global functions (using helper anyway).
"""
-
+import os
import avalon.api
+import pyblish.api
from .lib import qt_app_context
@@ -196,10 +197,29 @@ class HostToolsHelper:
library_loader_tool.refresh()
def show_publish(self, parent=None):
- """Publish UI."""
- from avalon.tools import publish
+ """Try showing the most desirable publish GUI
- publish.show(parent)
+ This function cycles through the currently registered
+ graphical user interfaces, if any, and presents it to
+ the user.
+ """
+
+ pyblish_show = self._discover_pyblish_gui()
+ return pyblish_show(parent)
+
+ def _discover_pyblish_gui(self):
+ """Return the most desirable of the currently registered GUIs"""
+ # Prefer last registered
+ guis = list(reversed(pyblish.api.registered_guis()))
+ for gui in guis:
+ try:
+ gui = __import__(gui).show
+ except (ImportError, AttributeError):
+ continue
+ else:
+ return gui
+
+ raise ImportError("No Pyblish GUI found")
def get_look_assigner_tool(self, parent):
"""Create, cache and return look assigner tool window."""
@@ -394,3 +414,11 @@ def show_publish(parent=None):
def show_experimental_tools_dialog(parent=None):
_SingletonPoint.show_tool_by_name("experimental_tools", parent)
+
+
+def get_pyblish_icon():
+ pyblish_dir = os.path.abspath(os.path.dirname(pyblish.api.__file__))
+ icon_path = os.path.join(pyblish_dir, "icons", "logo-32x32.svg")
+ if os.path.exists(icon_path):
+ return icon_path
+ return None
diff --git a/openpype/tools/utils/widgets.py b/openpype/tools/utils/widgets.py
index c62b838231..a4e172ea5c 100644
--- a/openpype/tools/utils/widgets.py
+++ b/openpype/tools/utils/widgets.py
@@ -63,6 +63,29 @@ class ClickableFrame(BaseClickableFrame):
self.clicked.emit()
+class ClickableLabel(QtWidgets.QLabel):
+ """Label that catch left mouse click and can trigger 'clicked' signal."""
+ clicked = QtCore.Signal()
+
+ def __init__(self, parent):
+ super(ClickableLabel, self).__init__(parent)
+
+ self._mouse_pressed = False
+
+ def mousePressEvent(self, event):
+ if event.button() == QtCore.Qt.LeftButton:
+ self._mouse_pressed = True
+ super(ClickableLabel, self).mousePressEvent(event)
+
+ def mouseReleaseEvent(self, event):
+ if self._mouse_pressed:
+ self._mouse_pressed = False
+ if self.rect().contains(event.pos()):
+ self.clicked.emit()
+
+ super(ClickableLabel, self).mouseReleaseEvent(event)
+
+
class ExpandBtnLabel(QtWidgets.QLabel):
"""Label showing expand icon meant for ExpandBtn."""
def __init__(self, parent):
diff --git a/openpype/tools/workfiles/model.py b/openpype/tools/workfiles/model.py
index 583f495606..3425cc3df0 100644
--- a/openpype/tools/workfiles/model.py
+++ b/openpype/tools/workfiles/model.py
@@ -1,11 +1,11 @@
import os
import logging
-from Qt import QtCore, QtGui
+from Qt import QtCore
from avalon import style
from avalon.vendor import qtawesome
-from avalon.tools.models import TreeModel, Item
+from openpype.tools.utils.models import TreeModel, Item
log = logging.getLogger(__name__)
diff --git a/openpype/version.py b/openpype/version.py
index cb3658a827..b41951a34c 100644
--- a/openpype/version.py
+++ b/openpype/version.py
@@ -1,3 +1,3 @@
# -*- coding: utf-8 -*-
"""Package declaring Pype version."""
-__version__ = "3.9.0-nightly.3"
+__version__ = "3.9.0-nightly.5"
diff --git a/openpype/widgets/attribute_defs/files_widget.py b/openpype/widgets/attribute_defs/files_widget.py
index fb48528bdc..87b98e2378 100644
--- a/openpype/widgets/attribute_defs/files_widget.py
+++ b/openpype/widgets/attribute_defs/files_widget.py
@@ -433,7 +433,7 @@ class MultiFilesWidget(QtWidgets.QFrame):
filenames = index.data(FILENAMES_ROLE)
for filename in filenames:
filepaths.add(os.path.join(dirpath, filename))
- return filepaths
+ return list(filepaths)
def set_filters(self, folders_allowed, exts_filter):
self._files_proxy_model.set_allow_folders(folders_allowed)
@@ -552,7 +552,7 @@ class MultiFilesWidget(QtWidgets.QFrame):
self._update_visibility()
def _update_visibility(self):
- files_exists = self._files_model.rowCount() > 0
+ files_exists = self._files_proxy_model.rowCount() > 0
self._files_view.setVisible(files_exists)
self._empty_widget.setVisible(not files_exists)
diff --git a/openpype/widgets/color_widgets/color_inputs.py b/openpype/widgets/color_widgets/color_inputs.py
index 6f5d4baa02..d6564ca29b 100644
--- a/openpype/widgets/color_widgets/color_inputs.py
+++ b/openpype/widgets/color_widgets/color_inputs.py
@@ -8,42 +8,56 @@ class AlphaSlider(QtWidgets.QSlider):
def __init__(self, *args, **kwargs):
super(AlphaSlider, self).__init__(*args, **kwargs)
self._mouse_clicked = False
+ self._handle_size = 0
+
self.setSingleStep(1)
self.setMinimum(0)
self.setMaximum(255)
self.setValue(255)
- self._checkerboard = None
-
- def checkerboard(self):
- if self._checkerboard is None:
- self._checkerboard = draw_checkerboard_tile(
- 3, QtGui.QColor(173, 173, 173), QtGui.QColor(27, 27, 27)
- )
- return self._checkerboard
+ self._handle_brush = QtGui.QBrush(QtGui.QColor(127, 127, 127))
def mousePressEvent(self, event):
self._mouse_clicked = True
if event.button() == QtCore.Qt.LeftButton:
- self._set_value_to_pos(event.pos().x())
+ self._set_value_to_pos(event.pos())
return event.accept()
return super(AlphaSlider, self).mousePressEvent(event)
- def _set_value_to_pos(self, pos_x):
- value = (
- self.maximum() - self.minimum()
- ) * pos_x / self.width() + self.minimum()
- self.setValue(value)
-
def mouseMoveEvent(self, event):
if self._mouse_clicked:
- self._set_value_to_pos(event.pos().x())
+ self._set_value_to_pos(event.pos())
+
super(AlphaSlider, self).mouseMoveEvent(event)
def mouseReleaseEvent(self, event):
self._mouse_clicked = True
super(AlphaSlider, self).mouseReleaseEvent(event)
+ def _set_value_to_pos(self, pos):
+ if self.orientation() == QtCore.Qt.Horizontal:
+ self._set_value_to_pos_x(pos.x())
+ else:
+ self._set_value_to_pos_y(pos.y())
+
+ def _set_value_to_pos_x(self, pos_x):
+ _range = self.maximum() - self.minimum()
+ handle_size = self._handle_size
+ half_handle = handle_size / 2
+ pos_x -= half_handle
+ width = self.width() - handle_size
+ value = ((_range * pos_x) / width) + self.minimum()
+ self.setValue(value)
+
+ def _set_value_to_pos_y(self, pos_y):
+ _range = self.maximum() - self.minimum()
+ handle_size = self._handle_size
+ half_handle = handle_size / 2
+ pos_y = self.height() - pos_y - half_handle
+ height = self.height() - handle_size
+ value = (_range * pos_y / height) + self.minimum()
+ self.setValue(value)
+
def paintEvent(self, event):
painter = QtGui.QPainter(self)
opt = QtWidgets.QStyleOptionSlider()
@@ -52,64 +66,82 @@ class AlphaSlider(QtWidgets.QSlider):
painter.fillRect(event.rect(), QtCore.Qt.transparent)
painter.setRenderHint(QtGui.QPainter.HighQualityAntialiasing)
+
+ horizontal = self.orientation() == QtCore.Qt.Horizontal
+
rect = self.style().subControlRect(
QtWidgets.QStyle.CC_Slider,
opt,
QtWidgets.QStyle.SC_SliderGroove,
self
)
- final_height = 9
- offset_top = 0
- if rect.height() > final_height:
- offset_top = int((rect.height() - final_height) / 2)
- rect = QtCore.QRect(
- rect.x(),
- offset_top,
- rect.width(),
- final_height
- )
- pix_rect = QtCore.QRect(event.rect())
- pix_rect.setX(rect.x())
- pix_rect.setWidth(rect.width() - (2 * rect.x()))
- pix = QtGui.QPixmap(pix_rect.width(), pix_rect.height())
- pix_painter = QtGui.QPainter(pix)
- pix_painter.drawTiledPixmap(pix_rect, self.checkerboard())
+ _range = self.maximum() - self.minimum()
+ _offset = self.value() - self.minimum()
+ if horizontal:
+ _handle_half = rect.height() / 2
+ _handle_size = _handle_half * 2
+ width = rect.width() - _handle_size
+ pos_x = ((width / _range) * _offset)
+ pos_y = rect.center().y() - _handle_half + 1
+ else:
+ _handle_half = rect.width() / 2
+ _handle_size = _handle_half * 2
+ height = rect.height() - _handle_size
+ pos_x = rect.center().x() - _handle_half + 1
+ pos_y = height - ((height / _range) * _offset)
+
+ handle_rect = QtCore.QRect(
+ pos_x, pos_y, _handle_size, _handle_size
+ )
+
+ self._handle_size = _handle_size
+ _offset = 2
+ _size = _handle_size - _offset
+ if horizontal:
+ if rect.height() > _size:
+ new_rect = QtCore.QRect(0, 0, rect.width(), _size)
+ center_point = QtCore.QPoint(
+ rect.center().x(), handle_rect.center().y()
+ )
+ new_rect.moveCenter(center_point)
+ rect = new_rect
+
+ ratio = rect.height() / 2
+
+ else:
+ if rect.width() > _size:
+ new_rect = QtCore.QRect(0, 0, _size, rect.height())
+ center_point = QtCore.QPoint(
+ handle_rect.center().x(), rect.center().y()
+ )
+ new_rect.moveCenter(center_point)
+ rect = new_rect
+
+ ratio = rect.width() / 2
+
+ painter.save()
+ clip_path = QtGui.QPainterPath()
+ clip_path.addRoundedRect(rect, ratio, ratio)
+ painter.setClipPath(clip_path)
+ checker_size = int(_handle_size / 3)
+ if checker_size == 0:
+ checker_size = 1
+ checkerboard = draw_checkerboard_tile(
+ checker_size, QtGui.QColor(173, 173, 173), QtGui.QColor(27, 27, 27)
+ )
+ painter.drawTiledPixmap(rect, checkerboard)
gradient = QtGui.QLinearGradient(rect.topLeft(), rect.bottomRight())
gradient.setColorAt(0, QtCore.Qt.transparent)
gradient.setColorAt(1, QtCore.Qt.white)
- pix_painter.fillRect(pix_rect, gradient)
- pix_painter.end()
-
- brush = QtGui.QBrush(pix)
- painter.save()
painter.setPen(QtCore.Qt.NoPen)
- painter.setBrush(brush)
- ratio = rect.height() / 2
- painter.drawRoundedRect(rect, ratio, ratio)
+ painter.fillRect(rect, gradient)
painter.restore()
- _handle_rect = self.style().subControlRect(
- QtWidgets.QStyle.CC_Slider,
- opt,
- QtWidgets.QStyle.SC_SliderHandle,
- self
- )
-
- handle_rect = QtCore.QRect(rect)
- if offset_top > 1:
- height = handle_rect.height()
- handle_rect.setY(handle_rect.y() - 1)
- handle_rect.setHeight(height + 2)
- handle_rect.setX(_handle_rect.x())
- handle_rect.setWidth(handle_rect.height())
-
painter.save()
-
painter.setPen(QtCore.Qt.NoPen)
- painter.setBrush(QtGui.QColor(127, 127, 127))
+ painter.setBrush(self._handle_brush)
painter.drawEllipse(handle_rect)
-
painter.restore()
diff --git a/openpype/widgets/color_widgets/color_triangle.py b/openpype/widgets/color_widgets/color_triangle.py
index f4a86c4fa5..e15b9e9f65 100644
--- a/openpype/widgets/color_widgets/color_triangle.py
+++ b/openpype/widgets/color_widgets/color_triangle.py
@@ -1,5 +1,5 @@
from enum import Enum
-from math import floor, sqrt, sin, cos, acos, pi as PI
+from math import floor, ceil, sqrt, sin, cos, acos, pi as PI
from Qt import QtWidgets, QtCore, QtGui
TWOPI = PI * 2
@@ -187,10 +187,10 @@ class QtColorTriangle(QtWidgets.QWidget):
self.outer_radius = (size - 1) / 2
self.pen_width = int(
- floor(self.outer_radius / self.ellipse_thick_ratio)
+ ceil(self.outer_radius / self.ellipse_thick_ratio)
)
self.ellipse_size = int(
- floor(self.outer_radius / self.ellipse_size_ratio)
+ ceil(self.outer_radius / self.ellipse_size_ratio)
)
cx = float(self.contentsRect().center().x())
@@ -542,10 +542,10 @@ class QtColorTriangle(QtWidgets.QWidget):
self.outer_radius = (size - 1) / 2
self.pen_width = int(
- floor(self.outer_radius / self.ellipse_thick_ratio)
+ ceil(self.outer_radius / self.ellipse_thick_ratio)
)
self.ellipse_size = int(
- floor(self.outer_radius / self.ellipse_size_ratio)
+ ceil(self.outer_radius / self.ellipse_size_ratio)
)
cx = float(self.contentsRect().center().x())
diff --git a/pyproject.toml b/pyproject.toml
index 052ed92bbc..851bf3f735 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -1,6 +1,6 @@
[tool.poetry]
name = "OpenPype"
-version = "3.9.0-nightly.3" # OpenPype
+version = "3.9.0-nightly.5" # OpenPype
description = "Open VFX and Animation pipeline with support."
authors = ["OpenPype Team "]
license = "MIT License"
diff --git a/repos/avalon-core b/repos/avalon-core
index 159d2f23e4..ffe9e910f1 160000
--- a/repos/avalon-core
+++ b/repos/avalon-core
@@ -1 +1 @@
-Subproject commit 159d2f23e4c79c04dfac57b68d2ee6ac67adec1b
+Subproject commit ffe9e910f1f382e222d457d8e4a8426c41ed43ae
diff --git a/repos/avalon-unreal-integration b/repos/avalon-unreal-integration
deleted file mode 160000
index 43f6ea9439..0000000000
--- a/repos/avalon-unreal-integration
+++ /dev/null
@@ -1 +0,0 @@
-Subproject commit 43f6ea943980b29c02a170942b566ae11f2b7080
diff --git a/tests/README.md b/tests/README.md
index 6fb86cd027..bb1cdbdef8 100644
--- a/tests/README.md
+++ b/tests/README.md
@@ -13,11 +13,11 @@ Structure:
How to run:
----------
- use Openpype command 'runtests' from command line (`.venv` in ${OPENPYPE_ROOT} must be activated to use configured Python!)
--- `${OPENPYPE_ROOT}/python start.py runtests`
+-- `python ${OPENPYPE_ROOT}/start.py runtests`
By default, this command will run all tests in ${OPENPYPE_ROOT}/tests.
Specific location could be provided to this command as an argument, either as absolute path, or relative path to ${OPENPYPE_ROOT}.
-(eg. `${OPENPYPE_ROOT}/python start.py runtests ../tests/integration`) will trigger only tests in `integration` folder.
+(eg. `python ${OPENPYPE_ROOT}/start.py start.py runtests ../tests/integration`) will trigger only tests in `integration` folder.
See `${OPENPYPE_ROOT}/cli.py:runtests` for other arguments.
diff --git a/tests/integration/conftest.py b/tests/integration/conftest.py
index 400c0dcc2a..aa850be1a6 100644
--- a/tests/integration/conftest.py
+++ b/tests/integration/conftest.py
@@ -19,6 +19,11 @@ def pytest_addoption(parser):
help="Keep empty to locate latest installed variant or explicit"
)
+ parser.addoption(
+ "--timeout", action="store", default=None,
+ help="Overwrite default timeout"
+ )
+
@pytest.fixture(scope="module")
def test_data_folder(request):
@@ -33,3 +38,8 @@ def persist(request):
@pytest.fixture(scope="module")
def app_variant(request):
return request.config.getoption("--app_variant")
+
+
+@pytest.fixture(scope="module")
+def timeout(request):
+ return request.config.getoption("--timeout")
diff --git a/tests/integration/hosts/aftereffects/lib.py b/tests/integration/hosts/aftereffects/lib.py
new file mode 100644
index 0000000000..9fffc6073d
--- /dev/null
+++ b/tests/integration/hosts/aftereffects/lib.py
@@ -0,0 +1,34 @@
+import os
+import pytest
+import shutil
+
+from tests.lib.testing_classes import HostFixtures
+
+
+class AfterEffectsTestClass(HostFixtures):
+ @pytest.fixture(scope="module")
+ def last_workfile_path(self, download_test_data, output_folder_url):
+ """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.aep")
+ 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.aep")
+ 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"""
+ pass
diff --git a/tests/integration/hosts/aftereffects/test_publish_in_aftereffects.py b/tests/integration/hosts/aftereffects/test_publish_in_aftereffects.py
index 407c4f8a3a..4925cbd2d7 100644
--- a/tests/integration/hosts/aftereffects/test_publish_in_aftereffects.py
+++ b/tests/integration/hosts/aftereffects/test_publish_in_aftereffects.py
@@ -1,11 +1,12 @@
-import pytest
-import os
-import shutil
+import logging
-from tests.lib.testing_classes import PublishTest
+from tests.lib.assert_classes import DBAssert
+from tests.integration.hosts.aftereffects.lib import AfterEffectsTestClass
+
+log = logging.getLogger("test_publish_in_aftereffects")
-class TestPublishInAfterEffects(PublishTest):
+class TestPublishInAfterEffects(AfterEffectsTestClass):
"""Basic test case for publishing in AfterEffects
Uses generic TestCase to prepare fixtures for test data, testing DBs,
@@ -23,7 +24,7 @@ class TestPublishInAfterEffects(PublishTest):
Checks tmp folder if all expected files were published.
"""
- PERSIST = True
+ PERSIST = False
TEST_FILES = [
("1c8261CmHwyMgS-g7S4xL5epAp0jCBmhf",
@@ -32,70 +33,44 @@ class TestPublishInAfterEffects(PublishTest):
]
APP = "aftereffects"
- APP_VARIANT = "2022"
+ APP_VARIANT = ""
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.aep")
- 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.aep")
- shutil.copy(src_path, dest_path)
-
- yield dest_path
-
- @pytest.fixture(scope="module")
- def startup_scripts(self, monkeypatch_session, download_test_data):
- """Points AfterEffects to userSetup file from input data"""
- pass
-
def test_db_asserts(self, dbcon, publish_finished):
"""Host and input data dependent expected results in DB."""
print("test_db_asserts")
+ failures = []
- assert 2 == dbcon.count_documents({"type": "version"}), \
- "Not expected no of versions"
+ failures.append(DBAssert.count_of_types(dbcon, "version", 2))
- assert 0 == dbcon.count_documents({"type": "version",
- "name": {"$ne": 1}}), \
- "Only versions with 1 expected"
+ failures.append(
+ DBAssert.count_of_types(dbcon, "version", 0, name={"$ne": 1}))
- assert 1 == dbcon.count_documents({"type": "subset",
- "name": "imageMainBackgroundcopy"
- }), \
- "modelMain subset must be present"
+ failures.append(
+ DBAssert.count_of_types(dbcon, "subset", 1,
+ name="imageMainBackgroundcopy"))
- assert 1 == dbcon.count_documents({"type": "subset",
- "name": "workfileTest_task"}), \
- "workfileTesttask subset must be present"
+ failures.append(
+ DBAssert.count_of_types(dbcon, "subset", 1,
+ name="workfileTest_task"))
- assert 1 == dbcon.count_documents({"type": "subset",
- "name": "reviewTesttask"}), \
- "reviewTesttask subset must be present"
+ failures.append(
+ DBAssert.count_of_types(dbcon, "subset", 1,
+ name="reviewTesttask"))
- assert 4 == dbcon.count_documents({"type": "representation"}), \
- "Not expected no of representations"
+ failures.append(
+ DBAssert.count_of_types(dbcon, "representation", 4))
- assert 1 == dbcon.count_documents({"type": "representation",
- "context.subset": "renderTestTaskDefault", # noqa E501
- "context.ext": "png"}), \
- "Not expected no of representations with ext 'png'"
+ additional_args = {"context.subset": "renderTestTaskDefault",
+ "context.ext": "png"}
+ failures.append(
+ DBAssert.count_of_types(dbcon, "representation", 1,
+ additional_args=additional_args))
+
+ assert not any(failures)
if __name__ == "__main__":
diff --git a/tests/integration/hosts/photoshop/test_publish_in_photoshop.py b/tests/integration/hosts/photoshop/test_publish_in_photoshop.py
index 32053cd9d4..5387bbe51e 100644
--- a/tests/integration/hosts/photoshop/test_publish_in_photoshop.py
+++ b/tests/integration/hosts/photoshop/test_publish_in_photoshop.py
@@ -1,5 +1,10 @@
+import logging
+
+from tests.lib.assert_classes import DBAssert
from tests.integration.hosts.photoshop.lib import PhotoshopTestClass
+log = logging.getLogger("test_publish_in_photoshop")
+
class TestPublishInPhotoshop(PhotoshopTestClass):
"""Basic test case for publishing in Photoshop
@@ -30,7 +35,7 @@ class TestPublishInPhotoshop(PhotoshopTestClass):
{OPENPYPE_ROOT}/.venv/Scripts/python.exe {OPENPYPE_ROOT}/start.py runtests ../tests/integration/hosts/photoshop # noqa: E501
"""
- PERSIST = False
+ PERSIST = True
TEST_FILES = [
("1zD2v5cBgkyOm_xIgKz3WKn8aFB_j8qC-", "test_photoshop_publish.zip", "")
@@ -44,33 +49,56 @@ class TestPublishInPhotoshop(PhotoshopTestClass):
TIMEOUT = 120 # publish timeout
-
def test_db_asserts(self, dbcon, publish_finished):
"""Host and input data dependent expected results in DB."""
print("test_db_asserts")
- assert 3 == dbcon.count_documents({"type": "version"}), \
- "Not expected no of versions"
+ failures = []
- assert 0 == dbcon.count_documents({"type": "version",
- "name": {"$ne": 1}}), \
- "Only versions with 1 expected"
+ failures.append(DBAssert.count_of_types(dbcon, "version", 4))
- assert 1 == dbcon.count_documents({"type": "subset",
- "name": "imageMainBackgroundcopy"}
- ), \
- "modelMain subset must be present"
+ failures.append(
+ DBAssert.count_of_types(dbcon, "version", 0, name={"$ne": 1}))
- assert 1 == dbcon.count_documents({"type": "subset",
- "name": "workfileTesttask"}), \
- "workfileTest_task subset must be present"
+ failures.append(
+ DBAssert.count_of_types(dbcon, "subset", 1,
+ name="imageMainForeground"))
- assert 6 == dbcon.count_documents({"type": "representation"}), \
- "Not expected no of representations"
+ failures.append(
+ DBAssert.count_of_types(dbcon, "subset", 1,
+ name="imageMainBackground"))
- assert 1 == dbcon.count_documents({"type": "representation",
- "context.subset": "imageMainBackgroundcopy", # noqa: E501
- "context.ext": "png"}), \
- "Not expected no of representations with ext 'png'"
+ failures.append(
+ DBAssert.count_of_types(dbcon, "subset", 1,
+ name="workfileTest_task"))
+
+ failures.append(
+ DBAssert.count_of_types(dbcon, "representation", 8))
+
+ additional_args = {"context.subset": "imageMainForeground",
+ "context.ext": "png"}
+ failures.append(
+ DBAssert.count_of_types(dbcon, "representation", 1,
+ additional_args=additional_args))
+
+ additional_args = {"context.subset": "imageMainForeground",
+ "context.ext": "jpg"}
+ failures.append(
+ DBAssert.count_of_types(dbcon, "representation", 1,
+ additional_args=additional_args))
+
+ additional_args = {"context.subset": "imageMainBackground",
+ "context.ext": "png"}
+ failures.append(
+ DBAssert.count_of_types(dbcon, "representation", 1,
+ additional_args=additional_args))
+
+ additional_args = {"context.subset": "imageMainBackground",
+ "context.ext": "jpg"}
+ failures.append(
+ DBAssert.count_of_types(dbcon, "representation", 1,
+ additional_args=additional_args))
+
+ assert not any(failures)
if __name__ == "__main__":
diff --git a/tests/lib/assert_classes.py b/tests/lib/assert_classes.py
index 7298853b67..98f758767d 100644
--- a/tests/lib/assert_classes.py
+++ b/tests/lib/assert_classes.py
@@ -1,5 +1,6 @@
"""Classed and methods for comparing expected and published items in DBs"""
+
class DBAssert:
@classmethod
@@ -41,5 +42,7 @@ class DBAssert:
print("Comparing count of {}{} {}".format(queried_type,
detail_str,
status))
+ if msg:
+ print(msg)
return msg
diff --git a/tests/lib/testing_classes.py b/tests/lib/testing_classes.py
index fa467acf9c..0a9da1aca8 100644
--- a/tests/lib/testing_classes.py
+++ b/tests/lib/testing_classes.py
@@ -293,13 +293,16 @@ class PublishTest(ModuleUnitTest):
yield app_process
@pytest.fixture(scope="module")
- def publish_finished(self, dbcon, launched_app, download_test_data):
+ def publish_finished(self, dbcon, launched_app, download_test_data,
+ timeout):
"""Dummy fixture waiting for publish to finish"""
import time
time_start = time.time()
+ timeout = timeout or self.TIMEOUT
+ timeout = float(timeout)
while launched_app.poll() is None:
time.sleep(0.5)
- if time.time() - time_start > self.TIMEOUT:
+ if time.time() - time_start > timeout:
launched_app.terminate()
raise ValueError("Timeout reached")
diff --git a/tests/openpype/modules/default_modules/royal_render/test_rr_job.py b/tests/unit/openpype/default_modules/royal_render/test_rr_job.py
similarity index 100%
rename from tests/openpype/modules/default_modules/royal_render/test_rr_job.py
rename to tests/unit/openpype/default_modules/royal_render/test_rr_job.py
diff --git a/tools/ci_tools.py b/tools/ci_tools.py
index e5ca0c2c28..aeb367af38 100644
--- a/tools/ci_tools.py
+++ b/tools/ci_tools.py
@@ -19,7 +19,10 @@ def get_release_type_github(Log, github_token):
match = re.search("pull request #(\d+)", line)
if match:
pr_number = match.group(1)
- pr = repo.get_pull(int(pr_number))
+ try:
+ pr = repo.get_pull(int(pr_number))
+ except:
+ continue
for label in pr.labels:
labels.add(label.name)
diff --git a/website/docs/admin_builds.md b/website/docs/admin_builds.md
new file mode 100644
index 0000000000..a4e0e77242
--- /dev/null
+++ b/website/docs/admin_builds.md
@@ -0,0 +1,20 @@
+---
+id: admin_builds
+title: Builds and Releases
+sidebar_label: Builds
+---
+
+import Tabs from '@theme/Tabs';
+import TabItem from '@theme/TabItem';
+
+Admins might find prepared builds on https://github.com/pypeclub/OpenPype/releases for all major platforms.
+
+### Currently built on OS versions
+- Windows 10
+- Ubuntu 20.04
+- Centos 7.6
+- MacOS Mohave (10.14.6)
+
+In case your studio requires build for different OS version, or any specific build, please take a look at
+[Requirements](dev_requirements.md) and [Build](dev_build.md) for more details how to create binaries to distribute.
+
\ No newline at end of file
diff --git a/website/docs/admin_settings.md b/website/docs/admin_settings.md
index ba4bb1a9be..9b00e6c612 100644
--- a/website/docs/admin_settings.md
+++ b/website/docs/admin_settings.md
@@ -22,7 +22,7 @@ We use simple colour coding to show you any changes to the settings:
- **Orange**: [Project Override](#project-overrides)
- **Blue**: Changed and unsaved value
-
+
You'll find that settings are split into categories:
diff --git a/website/docs/dev_introduction.md b/website/docs/dev_introduction.md
new file mode 100644
index 0000000000..5b48635a08
--- /dev/null
+++ b/website/docs/dev_introduction.md
@@ -0,0 +1,10 @@
+---
+id: dev_introduction
+title: Introduction
+sidebar_label: Introduction
+---
+
+
+Here you should find additional information targeted on developers who would like to contribute or dive deeper into OpenPype platform
+
+Currently there are details about automatic testing, in the future this should be location for API definition and documentation
diff --git a/website/docs/dev_testing.md b/website/docs/dev_testing.md
new file mode 100644
index 0000000000..cab298ae37
--- /dev/null
+++ b/website/docs/dev_testing.md
@@ -0,0 +1,143 @@
+---
+id: dev_testing
+title: Testing in OpenPype
+sidebar_label: Testing
+---
+
+## Introduction
+As OpenPype is growing there also grows need for automatic testing. There are already bunch of tests present in root folder of OpenPype directory.
+But many tests should yet be created!
+
+### How to run (integration) tests
+
+#### Requirements
+- installed DCC you want to test
+- `mongorestore` on a PATH
+
+If you would like just to experiment with provided integration tests, and have particular DCC installed on your machine, you could run test for this host by:
+
+- From source:
+```
+- use Openpype command 'runtests' from command line (`.venv` in ${OPENPYPE_ROOT} must be activated to use configured Python!)
+- `python ${OPENPYPE_ROOT}/start.py runtests ../tests/integration/hosts/nuke`
+```
+- From build:
+```
+- ${OPENPYPE_BUILD}/openpype_console run {ABSOLUTE_PATH_OPENPYPE_ROOT}/tests/integration/hosts/nuke`
+```
+Modify tests path argument to limit which tests should be run (`../tests/integration` will run all implemented integration tests).
+
+### Content of tests folder
+
+Main tests folder contains hierarchy of folders with tests and supporting lib files. It is intended that tests in each folder of the hierarchy could be run separately.
+
+Main folders in the structure:
+- `integration` - end to end tests in host applications, mimicking regular publishing process
+- `lib` - helper classes
+- `resources` - test data skeletons etc.
+- `unit` - unit test covering methods and functions in OP
+
+
+### lib folder
+
+This location should contain library of helpers and miscellaneous classes used for integration or unit tests.
+
+Content:
+- `assert_classes.py` - helpers for easier use of assert expressions
+- `db_handler.py` - class for creation of DB dumps/restore/purge
+- `file_hanlder.py` - class for preparation/cleanup of test data
+- `testing_classes.py` - base classes for testing of publish in various DCCs
+
+### integration folder
+
+Contains end to end testing in a DCC. Currently it is setup to start DCC application with prepared worfkile, run publish process and compare results in DB and file system automatically.
+This approach is implemented as it should work in any DCC application and should cover most common use cases. Not all hosts allow "real headless" publishing, but all hosts should allow to trigger
+publish process programatically when UI of host is actually running.
+
+There will be eventually also possibility to build workfile and publish it programmatically, this would work only in DCCs that support it (Maya, Nuke).
+
+It is expected that each test class should work with single worfkile with supporting resources (as a dump of project DB, all necessary environment variables, expected published files etc.)
+
+There are currently implemented basic publish tests for `Maya`, `Nuke`, `AfterEffects` and `Photoshop`. Additional hosts will be added.
+
+Each `test_` class should contain single test class based on `tests.lib.testing_classes.PublishTest`. This base class handles all necessary
+functionality for testing in a host application.
+
+#### Steps of publish test
+
+Each publish test consists of areas:
+- preparation
+- launch of host application
+- publish
+- comparison of results in DB and file system
+- cleanup
+
+##### Preparation
+
+Each test publish case expects zip file with this structure:
+- `expected` - published files after workfile is published (in same structure as in regular manual publish)
+- `input`
+ - `dumps` - database dumps (check `tests.lib.db_handler` for implemented functionality)
+ - `openpype` - settings
+ - `test_db` - skeleton of test project (contains project document, asset document etc.)
+ - `env_vars` - `env_var.json` file with a dictionary of all required environment variables
+ - `json` - json files with human readable content of databases
+ - `startup` - any required initialization scripts (for example Nuke requires one `init.py` file)
+ - `workfile` - contains single workfile
+
+These folders needs to be zipped (in zip's root must be this structure directly!), currently zip files for all prepared tests are stored in OpenPype GDrive folder.
+
+Each test then goes in steps (by default):
+- download test data zip
+- create temporary folder and unzip there data zip file
+- purge test DB if exists, import dump files from unzipped folder
+- sets environment variables from `env_vars` folder
+- launches host application and trigger publish process
+- waits until publish process finishes, application closes (or timeouts)
+- compares results in DB with expected values
+- compares published files structure with expected values
+- cleans up temporary test DB and folder
+
+##### Launch of application and publish
+
+Integration tests are using same approach as OpenPype process regarding launching of host applications (eg. `ApplicationManager().launch`).
+Each host application is in charge of triggering of publish process and closing itself. Different hosts handle this differently, Adobe products are handling this via injected "HEADLESS_PUBLISH" environment variable,
+Maya and Nuke must contain this in theirs startup files.
+
+Base `PublishTest` class contains configurable timeout in case of publish process is not working, or taking too long.
+
+##### Comparison of results
+
+Each test class requires re-iplemented `PublishTest.test_db_asserts` fixture. This method is triggered after publish is finished and should
+compare current results in DB (each test has its own database which gets filled with dump data first, cleaned up after test finishing) with expected results.
+
+`tests.lib.assert_classes.py` contains prepared method `count_of_types` which makes easier to write assert expression. This method also produces formatted error message.
+
+Basic use case:
+```DBAssert.count_of_types(dbcon, "version", 2)``` >> It is expected that DB contains only 2 documents of `type==version`
+
+If zip file contains file structure in `expected` folder, `PublishTest.test_folder_structure_same` implements comparison of expected and published file structure,
+eg. if test case published all expected files.
+
+##### Cleanup
+
+By default, each test case pulls data from GDrive, unzips them in temporary folder, runs publish, compares results and then
+purges created temporary test database and temporary folder. This could be changed by setting of `PublishTest.PERSIST`. If set to True, DB and published folder are kept intact
+until next run of any test.
+
+In case you want to modify test data, use `PublishTest.TEST_DATA_FOLDER` to point test to specific location where test folder is already unzipped.
+
+Both options are mostly useful for debugging during implementation of new test cases.
+
+#### Test configuration
+
+Each test case could be configured from command line with:
+- `test_data_folder` - use specific folder with extracted test zip file
+- `persist` - keep content of temporary folder and database after test finishes
+- `app_variant` - run test for specific version of host app, matches app variants in Settings, eg. `2021` for Photoshop, `12-2` for Nuke
+- `timeout` - override default time (in seconds)
+
+### unit folder
+
+Here should be located unit tests for classes, methods of OpenPype etc. As most classes expect to be triggered in OpenPype context, best option is to
+start these tests in similar fashion as `integration` tests (eg. via `runtests`).
\ No newline at end of file
diff --git a/website/docusaurus.config.js b/website/docusaurus.config.js
index 7edcda8532..131dc1b8ad 100644
--- a/website/docusaurus.config.js
+++ b/website/docusaurus.config.js
@@ -58,10 +58,16 @@ module.exports = {
to: 'docs/artist_getting_started',
label: 'User Docs',
position: 'left'
- }, {
+ },
+ {
to: 'docs/system_introduction',
label: 'Admin Docs',
position: 'left'
+ },
+ {
+ to: 'docs/dev_introduction',
+ label: 'Dev Docs',
+ position: 'left'
},
{
to: 'https://pype.club',
diff --git a/website/sidebars.js b/website/sidebars.js
index 38e4206b84..16af1e1151 100644
--- a/website/sidebars.js
+++ b/website/sidebars.js
@@ -46,11 +46,9 @@ module.exports = {
type: "category",
label: "Getting Started",
items: [
- "dev_requirements",
- "dev_build",
+ "admin_builds",
"admin_distribute",
"admin_use",
- "dev_contribute",
"admin_openpype_commands",
],
},
@@ -133,4 +131,11 @@ module.exports = {
],
},
],
+ Dev: [
+ "dev_introduction",
+ "dev_requirements",
+ "dev_build",
+ "dev_testing",
+ "dev_contribute"
+ ]
};