diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml
index f6d1a25dc2..35564c2bf0 100644
--- a/.github/ISSUE_TEMPLATE/bug_report.yml
+++ b/.github/ISSUE_TEMPLATE/bug_report.yml
@@ -35,6 +35,10 @@ body:
label: Version
description: What version are you running? Look to OpenPype Tray
options:
+ - 3.16.7-nightly.1
+ - 3.16.6
+ - 3.16.6-nightly.1
+ - 3.16.5
- 3.16.5-nightly.5
- 3.16.5-nightly.4
- 3.16.5-nightly.3
@@ -131,10 +135,6 @@ body:
- 3.14.9
- 3.14.9-nightly.5
- 3.14.9-nightly.4
- - 3.14.9-nightly.3
- - 3.14.9-nightly.2
- - 3.14.9-nightly.1
- - 3.14.8
validations:
required: true
- type: dropdown
diff --git a/CHANGELOG.md b/CHANGELOG.md
index f1948b1a3f..0d7620869b 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,6 +1,916 @@
# Changelog
+## [3.16.6](https://github.com/ynput/OpenPype/tree/3.16.6)
+
+
+[Full Changelog](https://github.com/ynput/OpenPype/compare/3.16.5...3.16.6)
+
+### **🆕 New features**
+
+
+
+Workfiles tool: Refactor workfiles tool (for AYON) #5550
+
+Refactored workfiles tool to new tool. Separated backend and frontend logic. Refactored logic is AYON-centric and is used only in AYON mode, so it does not affect OpenPype.
+
+
+___
+
+
+
+
+
+AfterEffects: added validator for missing files in FootageItems #5590
+
+Published composition in AE could contain multiple FootageItems as a layers. If FootageItem contains imported file and it doesn't exist, render triggered by Publish process will silently fail and no output is generated. This could cause failure later in the process with unclear reason. (In `ExtractReview`).This PR adds validation to protect from this.
+
+
+___
+
+
+
+### **🚀 Enhancements**
+
+
+
+Maya: Yeti Cache Include viewport preview settings from source #5561
+
+When publishing and loading yeti caches persist the display output and preview colors + settings to ensure consistency in the view
+
+
+___
+
+
+
+
+
+Houdini: validate colorspace in review rop #5322
+
+Adding a validator that checks if 'OCIO Colorspace' parameter on review rop was set to a valid value.It is a step towards managing colorspace in review ropvalid values are the ones in the dropdown menuthis validator also provides some helper actions This PR is related to #4836 and #4833
+
+
+___
+
+
+
+
+
+Colorspace: adding abstraction of publishing related functions #5497
+
+The functionality of Colorspace has been abstracted for greater usability.
+
+
+___
+
+
+
+
+
+Nuke: removing redundant workfile colorspace attributes #5580
+
+Nuke root workfile colorspace data type knobs are long time configured automatically via config roles or the default values are also working well. Therefore there is no need for pipeline managed knobs.
+
+
+___
+
+
+
+
+
+Ftrack: Less verbose logs for Ftrack integration in artist facing logs #5596
+
+- Reduce artist-facing logs for component integration for Ftrack
+- Avoid "Comment is not set" log in artist facing report for Kitsu and Ftrack
+- Remove info log about `ffprobe` inspecting a file (changed to debug log)
+- interesting to see however that it ffprobes the same jpeg twice - but maybe once for thumbnail?
+
+
+___
+
+
+
+### **🐛 Bug fixes**
+
+
+
+Maya: Fix rig validators for new out_SET and controls_SET names #5595
+
+Fix usage of `out_SET` and `controls_SET` since #5310 because they can now be prefixed by the subset name.
+
+
+___
+
+
+
+
+
+TrayPublisher: set default frame values to sequential data #5530
+
+We are inheriting default frame handles and fps data either from project or setting them to 0. This is just for case a production will decide not to injest the sequential representations with asset based metadata.
+
+
+___
+
+
+
+
+
+Publisher: Screenshot opacity value fix #5576
+
+Fix opacity value.
+
+
+___
+
+
+
+
+
+AfterEffects: fix imports of image sequences #5581
+
+#4602 broke imports of image sequences.
+
+
+___
+
+
+
+
+
+AYON: Fix representation context conversion #5591
+
+Do not fix `"folder"` key in representation context until it is needed.
+
+
+___
+
+
+
+
+
+ayon-nuke: default factory to lists #5594
+
+Default factory were missing in settings schemas for complicated objects like lists and it was causing settings to be failing saving.
+
+
+___
+
+
+
+
+
+Maya: Fix look assigner showing no asset if 'not found' representations are present #5597
+
+Fix Maya Look assigner failing to show any content if it finds an invalid container for which it can't find the asset in the current project. (This can happen when e.g. loading something from a library project).There was logic already to avoid this but there was a bug where it used variable `_id` which did not exist and likely had to be `asset_id`.I've fixed that and improved the logged message a bit, e.g.:
+```
+// Warning: openpype.hosts.maya.tools.mayalookassigner.commands : Id found on 22 nodes for which no asset is found database, skipping '641d78ec85c3c5b102e836b0'
+```
+Example not found representation in Loader:The issue isn't necessarily related to NOT FOUND representations but in essence boils down to finding nodes with asset ids that do not exist in the current project which could very well just be local meshes in your scene.**Note:**I've excluded logging the nodes themselves because that tends to be a very long list of nodes. Only downside to removing that is that it's unclear which nodes are related to that `id`. If there are any ideas on how to still provide a concise informational message about that that'd be great so I could add it. Things I had considered:
+- Report the containers, issue here is that it's about asset ids on nodes which don't HAVE to be in containers - it could be local geometry
+- Report the namespaces, issue here is that it could be nodes without namespaces (plus potentially not about ALL nodes in a namespace)
+- Report the short names of the nodes; it's shorter and readable but still likely a lot of nodes.@tokejepsen @LiborBatek any other ideas?
+
+
+___
+
+
+
+
+
+Photoshop: fixed blank Flatten image #5600
+
+Flatten image is simplified publishing approach where all visible layers are "flatten" and published together. This image could be used as a reference etc.This is implemented by auto creator which wasn't updated after first publish. This would result in missing newly created layers after `auto_image` instance was created.
+
+
+___
+
+
+
+
+
+Blender: Remove Hardcoded Subset Name for Reviews #5603
+
+Fixes hardcoded subset name for Reviews in Blender.
+
+
+___
+
+
+
+
+
+TVPaint: Fix tool callbacks #5608
+
+Do not wait for callback to finish.
+
+
+___
+
+
+
+### **🔀 Refactored code**
+
+
+
+Chore: Remove unused variables and cleanup #5588
+
+Removing some unused variables. In some cases the unused variables _seemed like they should've been used - maybe?_ so please **double check the code whether it doesn't hint to an already existing bug**.Also tweaked some other small bugs in code + tweaked logging levels.
+
+
+___
+
+
+
+### **Merged pull requests**
+
+
+
+Chore: Loader log deprecation warning for 'fname' attribute #5587
+
+Since https://github.com/ynput/OpenPype/pull/4602 the `fname` attribute on the `LoaderPlugin` should've been deprecated and set for removal over time. However, no deprecation warning was logged whatsoever and thus one usage appears to have sneaked in (fixed with this PR) and a new one tried to sneak in with a recent PR
+
+
+___
+
+
+
+
+
+
+## [3.16.5](https://github.com/ynput/OpenPype/tree/3.16.5)
+
+
+[Full Changelog](https://github.com/ynput/OpenPype/compare/3.16.4...3.16.5)
+
+### **🆕 New features**
+
+
+
+Attribute Definitions: Multiselection enum def #5547
+
+Added `multiselection` option to `EnumDef`.
+
+
+___
+
+
+
+### **🚀 Enhancements**
+
+
+
+Farm: adding target collector #5494
+
+Enhancing farm publishing workflow.
+
+
+___
+
+
+
+
+
+Maya: Optimize validate plug-in path attributes #5522
+
+- Optimize query (use `cmds.ls` once)
+- Add Select Invalid action
+- Improve validation report
+- Avoid "Unknown object type" errors
+
+
+___
+
+
+
+
+
+Maya: Remove Validate Instance Attributes plug-in #5525
+
+Remove Validate Instance Attributes plug-in.
+
+
+___
+
+
+
+
+
+Enhancement: Tweak logging for artist facing reports #5537
+
+Tweak the logging of publishing for global, deadline, maya and a fusion plugin to have a cleaner artist-facing report.
+- Fix context being reported correctly from CollectContext
+- Fix ValidateMeshArnoldAttributes: fix when arnold is not loaded, fix applying settings, fix for when ai attributes do not exist
+
+
+___
+
+
+
+
+
+AYON: Update settings #5544
+
+Updated settings in AYON addons and conversion of AYON settings in OpenPype.
+
+
+___
+
+
+
+
+
+Chore: Removed Ass export script #5560
+
+Removed Arnold render script, which was obsolete and unused.
+
+
+___
+
+
+
+
+
+Nuke: Allow for knob values to be validated against multiple values. #5042
+
+Knob values can now be validated against multiple values, so you can allow write nodes to be `exr` and `png`, or `16-bit` and `32-bit`.
+
+
+___
+
+
+
+
+
+Enhancement: Cosmetics for Higher version of publish already exists validation error #5190
+
+Fix double spaces in message.Example output **after** the PR:
+
+
+___
+
+
+
+
+
+Nuke: publish existing frames on farm #5409
+
+This PR proposes adding a fourth option in Nuke render publish called "Use Existing Frames - Farm". This would be useful when the farm is busy or when the artist lacks enough farm licenses. Additionally, some artists prefer rendering on the farm but still want to check frames before publishing.By adding the "Use Existing Frames - Farm" option, artists will have more flexibility and control over their render publishing process. This enhancement will streamline the workflow and improve efficiency for Nuke users.
+
+
+___
+
+
+
+
+
+Unreal: Create project in temp location and move to final when done #5476
+
+Create Unreal project in local temporary folder and when done, move it to final destination.
+
+
+___
+
+
+
+
+
+TrayPublisher: adding audio product type into default presets #5489
+
+Adding Audio product type into default presets so anybody can publish audio to their shots.
+
+
+___
+
+
+
+
+
+Global: avoiding cleanup of flagged representation #5502
+
+Publishing folder can be flagged as persistent at representation level.
+
+
+___
+
+
+
+
+
+General: missing tag could raise error #5511
+
+- avoiding potential situation where missing Tag key could raise error
+
+
+___
+
+
+
+
+
+Chore: Queued event system #5514
+
+Implemented event system with more expected behavior of event system. If an event is triggered during other event callback, it is not processed immediately but waits until all callbacks of previous events are done. The event system also allows to not trigger events directly once `emit_event` is called which gives option to process events in custom loops.
+
+
+___
+
+
+
+
+
+Publisher: Tweak log message to provide plugin name after "Plugin" #5521
+
+Fix logged message for settings automatically applied to plugin attributes
+
+
+___
+
+
+
+
+
+Houdini: Improve VDB Selection #5523
+
+Improves VDB selection if selection is `SopNode`: return the selected sop nodeif selection is `ObjNode`: get the output node with the minimum 'outputidx' or the node with display flag
+
+
+___
+
+
+
+
+
+Maya: Refactor/tweak Validate Instance In same Context plug-in #5526
+
+- Chore/Refactor: Re-use existing select invalid and repair actions
+- Enhancement: provide more elaborate PublishValidationError report
+- Bugfix: fix "optional" support by using `OptionalPyblishPluginMixin` base class.
+
+
+___
+
+
+
+
+
+Enhancement: Update houdini main menu #5527
+
+This PR adds two updates:
+- dynamic main menu
+- dynamic asset name and task
+
+
+___
+
+
+
+
+
+Houdini: Reset FPS when clicking Set Frame Range #5528
+
+_Similar to Maya,_ Make `Set Frame Range` resets FPS, issue https://github.com/ynput/OpenPype/issues/5516
+
+
+___
+
+
+
+
+
+Enhancement: Deadline plugins optimize, cleanup and fix optional support for validate deadline pools #5531
+
+- Fix optional support of validate deadline pools
+- Query deadline webservice only once per URL for verification, and once for available deadline pools instead of for every instance
+- Use `deadlineUrl` in `instance.data` when validating pools if it is set.
+- Code cleanup: Re-use existing `requests_get` implementation
+
+
+___
+
+
+
+
+
+Chore: PowerShell script for docker build #5535
+
+Added PowerShell script to run docker build.
+
+
+___
+
+
+
+
+
+AYON: Deadline expand userpaths in executables list #5540
+
+Expande `~` paths in executables list.
+
+
+___
+
+
+
+
+
+Chore: Use correct git url #5542
+
+Fixed github url in README.md.
+
+
+___
+
+
+
+
+
+Chore: Create plugin does not expect system settings #5553
+
+System settings are not passed to initialization of create plugin initialization (and `apply_settings`).
+
+
+___
+
+
+
+
+
+Chore: Allow custom Qt scale factor rounding policy #5555
+
+Do not force `PassThrough` rounding policy if different policy is defined via env variable.
+
+
+___
+
+
+
+
+
+Houdini: Fix outdated containers pop-up on opening last workfile on launch #5567
+
+Fix Houdini not showing outdated containers pop-up on scene open when launching with last workfile argument
+
+
+___
+
+
+
+
+
+Houdini: Improve errors e.g. raise PublishValidationError or cosmetics #5568
+
+Improve errors e.g. raise PublishValidationError or cosmeticsThis also fixes the Increment Current File plug-in since due to an invalid import it was previously broken
+
+
+___
+
+
+
+
+
+Fusion: Code updates #5569
+
+Update fusion code which contains obsolete code. Removed `switch_ui.py` script from fusion with related script in scripts.
+
+
+___
+
+
+
+### **🐛 Bug fixes**
+
+
+
+Maya: Validate Shape Zero fix repair action + provide informational artist-facing report #5524
+
+Refactor to PublishValidationError to allow the RepairAction to work + provide informational report message
+
+
+___
+
+
+
+
+
+Maya: Fix attribute definitions for `CreateYetiCache` #5574
+
+Fix attribute definitions for `CreateYetiCache`
+
+
+___
+
+
+
+
+
+Max: Optional Renderable Camera Validator for Render Instance #5286
+
+Optional validation to check on renderable camera being set up correctly for deadline submission.If not being set up correctly, it wont pass the validation and user can perform repair actions.
+
+
+___
+
+
+
+
+
+Max: Adding custom modifiers back to the loaded objects #5378
+
+The custom parameters OpenpypeData doesn't show in the loaded container when it is being loaded through the loader.
+
+
+___
+
+
+
+
+
+Houdini: Use default_variant to Houdini Node TAB Creator #5421
+
+Use the default variant of the creator plugins on the interactive creator from the TAB node search instead of hard-coding it to `Main`.
+
+
+___
+
+
+
+
+
+Nuke: adding inherited colorspace from instance #5454
+
+Thumbnails are extracted with inherited colorspace collected from rendering write node.
+
+
+___
+
+
+
+
+
+Add kitsu credentials to deadline publish job #5455
+
+This PR hopefully fixes this issue #5440
+
+
+___
+
+
+
+
+
+AYON: Fill entities during editorial #5475
+
+Fill entities and update template data on instances during extract AYON hierarchy.
+
+
+___
+
+
+
+
+
+Ftrack: Fix version 0 when integrating to Ftrack - OP-6595 #5477
+
+Fix publishing version 0 to Ftrack.
+
+
+___
+
+
+
+
+
+OCIO: windows unc path support in Nuke and Hiero #5479
+
+Hiero and Nuke is not supporting windows unc path formatting in OCIO environment variable.
+
+
+___
+
+
+
+
+
+Deadline: Added super call to init #5480
+
+DL 10.3 requires plugin inheriting from DeadlinePlugin to call super's **init** explicitly.
+
+
+___
+
+
+
+
+
+Nuke: fixing thumbnail and monitor out root attributes #5483
+
+Nuke Root Colorspace settings for Thumbnail and Monitor Out schema was gradually changed between version 12, 13, 14 and we needed to address those changes individually for particular version.
+
+
+___
+
+
+
+
+
+Nuke: fixing missing `instance_id` error #5484
+
+Workfiles with Instances created in old publisher workflow were rising error during converting method since they were missing `instance_id` key introduced in new publisher workflow.
+
+
+___
+
+
+
+
+
+Nuke: existing frames validator is repairing render target #5486
+
+Nuke is now correctly repairing render target after the existing frames validator finds missing frames and repair action is used.
+
+
+___
+
+
+
+
+
+added UE to extract burnins families #5487
+
+This PR fixes missing burnins in reviewables when rendering from UE.
+___
+
+
+
+
+
+Harmony: refresh code for current Deadline #5493
+
+- Added support in Deadline Plug-in for new versions of Harmony, in particular version 21 and 22.
+- Remove review=False flag on render instance
+- Add farm=True flag on render instance
+- Fix is_in_tests function call in Harmony Deadline submission plugin
+- Force HarmonyOpenPype.py Deadline Python plug-in to py3
+- Fix cosmetics/hound in HarmonyOpenPype.py Deadline Python plug-in
+
+
+___
+
+
+
+
+
+Publisher: Fix multiselection value #5505
+
+Selection of multiple instances in Publisher does not cause that all instances change all publish attributes to the same value.
+
+
+___
+
+
+
+
+
+Publisher: Avoid warnings on thumbnails if source image also has alpha channel #5510
+
+Avoids the following warning from `ExtractThumbnailFromSource`:
+```
+// pyblish.ExtractThumbnailFromSource : oiiotool WARNING: -o : Can't save 4 channels to jpeg... saving only R,G,B
+```
+
+
+
+___
+
+
+
+
+
+Update ayon-python-api #5512
+
+Update ayon python api and related callbacks.
+
+
+___
+
+
+
+
+
+Max: Fixing the bug of falling back to use workfile for Arnold or any renderers except Redshift #5520
+
+Fix the bug of falling back to use workfile for Arnold
+
+
+___
+
+
+
+
+
+General: Fix Validate Publish Dir Validator #5534
+
+Nonsensical "family" key was used instead of real value (as 'render' etc.) which would result in wrong translation of intermediate family names.Updated docstring.
+
+
+___
+
+
+
+
+
+have the addons loading respect a custom AYON_ADDONS_DIR #5539
+
+When using a custom AYON_ADDONS_DIR environment variable that variable is used in the launcher correctly and downloads and extracts addons to there, however when running Ayon does not respect this environment variable
+
+
+___
+
+
+
+
+
+Deadline: files on representation cannot be single item list #5545
+
+Further logic expects that single item files will be only 'string' not 'list' (eg. repre["files"] = "abc.exr" not repre["files"] = ["abc.exr"].This would cause an issue in ExtractReview later.This could happen if DL rendered single frame file with different frame value.
+
+
+___
+
+
+
+
+
+Webpublisher: better encode list values for click #5546
+
+Targets could be a list, original implementation pushed it as a separate items, it must be added as `--targets webpulish --targets filepublish`.`wepublish_routes` handles triggering from UI, changes in `publish_functions` handle triggering from cmd (for tests, api access).
+
+
+___
+
+
+
+
+
+Houdini: Introduce imprint function for correct version in hda loader #5548
+
+Resolve #5478
+
+
+___
+
+
+
+
+
+AYON: Fill entities during editorial (2) #5549
+
+Fix changes made in https://github.com/ynput/OpenPype/pull/5475.
+
+
+___
+
+
+
+
+
+Max: OP Data updates in Loaders #5563
+
+Fix the bug on the loaders not being able to load the objects when iterating key and values with the dict.Max prefers list over the list in dict.
+
+
+___
+
+
+
+
+
+Create Plugins: Better check of overriden '__init__' method #5571
+
+Create plugins do not log warning messages about each create plugin because of wrong `__init__` method check.
+
+
+___
+
+
+
+### **Merged pull requests**
+
+
+
+Tests: fix unit tests #5533
+
+Fixed failing tests.Updated Unreal's validator to match removed general one which had a couple of issues fixed.
+
+
+___
+
+
+
+
+
+
## [3.16.4](https://github.com/ynput/OpenPype/tree/3.16.4)
diff --git a/openpype/client/server/conversion_utils.py b/openpype/client/server/conversion_utils.py
index a6c190a0fc..f67a1ef9c4 100644
--- a/openpype/client/server/conversion_utils.py
+++ b/openpype/client/server/conversion_utils.py
@@ -663,10 +663,13 @@ def convert_v4_representation_to_v3(representation):
if isinstance(context, six.string_types):
context = json.loads(context)
- if "folder" in context:
- _c_folder = context.pop("folder")
+ if "asset" not in context and "folder" in context:
+ _c_folder = context["folder"]
context["asset"] = _c_folder["name"]
+ elif "asset" in context and "folder" not in context:
+ context["folder"] = {"name": context["asset"]}
+
if "product" in context:
_c_product = context.pop("product")
context["family"] = _c_product["type"]
@@ -959,9 +962,11 @@ def convert_create_representation_to_v4(representation, con):
converted_representation["files"] = new_files
context = representation["context"]
- context["folder"] = {
- "name": context.pop("asset", None)
- }
+ if "folder" not in context:
+ context["folder"] = {
+ "name": context.get("asset")
+ }
+
context["product"] = {
"type": context.pop("family", None),
"name": context.pop("subset", None),
@@ -1285,7 +1290,7 @@ def convert_update_representation_to_v4(
if "context" in update_data:
context = update_data["context"]
- if "asset" in context:
+ if "folder" not in context and "asset" in context:
context["folder"] = {"name": context.pop("asset")}
if "family" in context or "subset" in context:
diff --git a/openpype/hosts/aftereffects/api/extension.zxp b/openpype/hosts/aftereffects/api/extension.zxp
index 358e9740d3..933dc7dc6c 100644
Binary files a/openpype/hosts/aftereffects/api/extension.zxp and b/openpype/hosts/aftereffects/api/extension.zxp differ
diff --git a/openpype/hosts/aftereffects/api/extension/CSXS/manifest.xml b/openpype/hosts/aftereffects/api/extension/CSXS/manifest.xml
index 0057758320..7329a9e723 100644
--- a/openpype/hosts/aftereffects/api/extension/CSXS/manifest.xml
+++ b/openpype/hosts/aftereffects/api/extension/CSXS/manifest.xml
@@ -1,5 +1,5 @@
-
@@ -10,22 +10,22 @@
-
+
-
+
-
-
+
+
-
+
-
-
+
+
-
+
@@ -63,7 +63,7 @@
550
400
-->
-
+
./icons/iconNormal.png
@@ -71,9 +71,9 @@
./icons/iconDisabled.png
./icons/iconDarkNormal.png
./icons/iconDarkRollover.png
-
+
-
\ No newline at end of file
+
diff --git a/openpype/hosts/aftereffects/api/extension/jsx/hostscript.jsx b/openpype/hosts/aftereffects/api/extension/jsx/hostscript.jsx
index bc443930df..c00844e637 100644
--- a/openpype/hosts/aftereffects/api/extension/jsx/hostscript.jsx
+++ b/openpype/hosts/aftereffects/api/extension/jsx/hostscript.jsx
@@ -215,6 +215,8 @@ function _getItem(item, comps, folders, footages){
* Refactor
*/
var item_type = '';
+ var path = '';
+ var containing_comps = [];
if (item instanceof FolderItem){
item_type = 'folder';
if (!folders){
@@ -222,10 +224,18 @@ function _getItem(item, comps, folders, footages){
}
}
if (item instanceof FootageItem){
- item_type = 'footage';
if (!footages){
return "{}";
}
+ item_type = 'footage';
+ if (item.file){
+ path = item.file.fsName;
+ }
+ if (item.usedIn){
+ for (j = 0; j < item.usedIn.length; ++j){
+ containing_comps.push(item.usedIn[j].id);
+ }
+ }
}
if (item instanceof CompItem){
item_type = 'comp';
@@ -236,7 +246,9 @@ function _getItem(item, comps, folders, footages){
var item = {"name": item.name,
"id": item.id,
- "type": item_type};
+ "type": item_type,
+ "path": path,
+ "containing_comps": containing_comps};
return JSON.stringify(item);
}
diff --git a/openpype/hosts/aftereffects/api/ws_stub.py b/openpype/hosts/aftereffects/api/ws_stub.py
index f5b96fa63a..18f530e272 100644
--- a/openpype/hosts/aftereffects/api/ws_stub.py
+++ b/openpype/hosts/aftereffects/api/ws_stub.py
@@ -37,6 +37,9 @@ class AEItem(object):
height = attr.ib(default=None)
is_placeholder = attr.ib(default=False)
uuid = attr.ib(default=False)
+ path = attr.ib(default=False) # path to FootageItem to validate
+ # list of composition Footage is in
+ containing_comps = attr.ib(factory=list)
class AfterEffectsServerStub():
@@ -704,7 +707,10 @@ class AfterEffectsServerStub():
d.get("instance_id"),
d.get("width"),
d.get("height"),
- d.get("is_placeholder"))
+ d.get("is_placeholder"),
+ d.get("uuid"),
+ d.get("path"),
+ d.get("containing_comps"),)
ret.append(item)
return ret
diff --git a/openpype/hosts/aftereffects/plugins/load/load_file.py b/openpype/hosts/aftereffects/plugins/load/load_file.py
index def7c927ab..8d52aac546 100644
--- a/openpype/hosts/aftereffects/plugins/load/load_file.py
+++ b/openpype/hosts/aftereffects/plugins/load/load_file.py
@@ -31,13 +31,8 @@ class FileLoader(api.AfterEffectsLoader):
path = self.filepath_from_context(context)
- repr_cont = context["representation"]["context"]
- if "#" not in path:
- frame = repr_cont.get("frame")
- if frame:
- padding = len(frame)
- path = path.replace(frame, "#" * padding)
- import_options['sequence'] = True
+ if len(context["representation"]["files"]) > 1:
+ import_options['sequence'] = True
if not path:
repr_id = context["representation"]["_id"]
diff --git a/openpype/hosts/aftereffects/plugins/publish/help/validate_footage_items.xml b/openpype/hosts/aftereffects/plugins/publish/help/validate_footage_items.xml
new file mode 100644
index 0000000000..01c8966015
--- /dev/null
+++ b/openpype/hosts/aftereffects/plugins/publish/help/validate_footage_items.xml
@@ -0,0 +1,14 @@
+
+
+
+Footage item missing
+
+## Footage item missing
+
+ FootageItem `{name}` contains missing `{path}`. Render will not produce any frames and AE will stop react to any integration
+### How to repair?
+
+Remove `{name}` or provide missing file.
+
+
+
diff --git a/openpype/hosts/aftereffects/plugins/publish/validate_footage_items.py b/openpype/hosts/aftereffects/plugins/publish/validate_footage_items.py
new file mode 100644
index 0000000000..40a08a2c3f
--- /dev/null
+++ b/openpype/hosts/aftereffects/plugins/publish/validate_footage_items.py
@@ -0,0 +1,49 @@
+# -*- coding: utf-8 -*-
+"""Validate presence of footage items in composition
+Requires:
+"""
+import os
+
+import pyblish.api
+
+from openpype.pipeline import (
+ PublishXmlValidationError
+)
+from openpype.hosts.aftereffects.api import get_stub
+
+
+class ValidateFootageItems(pyblish.api.InstancePlugin):
+ """
+ Validates if FootageItems contained in composition exist.
+
+ AE fails silently and doesn't render anything if footage item file is
+ missing. This will result in nonresponsiveness of AE UI as it expects
+ reaction from user, but it will not provide dialog.
+ This validator tries to check existence of the files.
+ It will not protect from missing frame in multiframes though
+ (as AE api doesn't provide this information and it cannot be told how many
+ frames should be there easily). Missing frame is replaced by placeholder.
+ """
+
+ order = pyblish.api.ValidatorOrder
+ label = "Validate Footage Items"
+ families = ["render.farm", "render.local", "render"]
+ hosts = ["aftereffects"]
+ optional = True
+
+ def process(self, instance):
+ """Plugin entry point."""
+
+ comp_id = instance.data["comp_id"]
+ for footage_item in get_stub().get_items(comps=False, folders=False,
+ footages=True):
+ self.log.info(footage_item)
+ if comp_id not in footage_item.containing_comps:
+ continue
+
+ path = footage_item.path
+ if path and not os.path.exists(path):
+ msg = f"File {path} not found."
+ formatting = {"name": footage_item.name, "path": path}
+ raise PublishXmlValidationError(self, msg,
+ formatting_data=formatting)
diff --git a/openpype/hosts/blender/plugins/load/load_blend.py b/openpype/hosts/blender/plugins/load/load_blend.py
index 99f291a5a7..fa41f4374b 100644
--- a/openpype/hosts/blender/plugins/load/load_blend.py
+++ b/openpype/hosts/blender/plugins/load/load_blend.py
@@ -119,7 +119,7 @@ class BlendLoader(plugin.AssetLoader):
context: Full parenthood of representation to load
options: Additional settings dictionary
"""
- libpath = self.fname
+ libpath = self.filepath_from_context(context)
asset = context["asset"]["name"]
subset = context["subset"]["name"]
diff --git a/openpype/hosts/blender/plugins/load/load_camera_abc.py b/openpype/hosts/blender/plugins/load/load_camera_abc.py
index e5afecff66..05d3fb764d 100644
--- a/openpype/hosts/blender/plugins/load/load_camera_abc.py
+++ b/openpype/hosts/blender/plugins/load/load_camera_abc.py
@@ -100,7 +100,7 @@ class AbcCameraLoader(plugin.AssetLoader):
asset_group = bpy.data.objects.new(group_name, object_data=None)
avalon_container.objects.link(asset_group)
- objects = self._process(libpath, asset_group, group_name)
+ self._process(libpath, asset_group, group_name)
objects = []
nodes = list(asset_group.children)
diff --git a/openpype/hosts/blender/plugins/load/load_camera_fbx.py b/openpype/hosts/blender/plugins/load/load_camera_fbx.py
index b9d05dda0a..3cca6e7fd3 100644
--- a/openpype/hosts/blender/plugins/load/load_camera_fbx.py
+++ b/openpype/hosts/blender/plugins/load/load_camera_fbx.py
@@ -103,7 +103,7 @@ class FbxCameraLoader(plugin.AssetLoader):
asset_group = bpy.data.objects.new(group_name, object_data=None)
avalon_container.objects.link(asset_group)
- objects = self._process(libpath, asset_group, group_name)
+ self._process(libpath, asset_group, group_name)
objects = []
nodes = list(asset_group.children)
diff --git a/openpype/hosts/blender/plugins/publish/collect_review.py b/openpype/hosts/blender/plugins/publish/collect_review.py
index 6459927015..3bf2e39e24 100644
--- a/openpype/hosts/blender/plugins/publish/collect_review.py
+++ b/openpype/hosts/blender/plugins/publish/collect_review.py
@@ -39,15 +39,11 @@ class CollectReview(pyblish.api.InstancePlugin):
]
if not instance.data.get("remove"):
-
- task = instance.context.data["task"]
-
# Store focal length in `burninDataMembers`
burninData = instance.data.setdefault("burninDataMembers", {})
burninData["focalLength"] = focal_length
instance.data.update({
- "subset": f"{task}Review",
"review_camera": camera,
"frameStart": instance.context.data["frameStart"],
"frameEnd": instance.context.data["frameEnd"],
diff --git a/openpype/hosts/blender/plugins/publish/extract_abc.py b/openpype/hosts/blender/plugins/publish/extract_abc.py
index f4babc94d3..87159e53f0 100644
--- a/openpype/hosts/blender/plugins/publish/extract_abc.py
+++ b/openpype/hosts/blender/plugins/publish/extract_abc.py
@@ -21,8 +21,6 @@ class ExtractABC(publish.Extractor):
filename = f"{instance.name}.abc"
filepath = os.path.join(stagingdir, filename)
- context = bpy.context
-
# Perform extraction
self.log.info("Performing extraction..")
diff --git a/openpype/hosts/blender/plugins/publish/extract_abc_animation.py b/openpype/hosts/blender/plugins/publish/extract_abc_animation.py
index e141ccaa44..44b2ba3761 100644
--- a/openpype/hosts/blender/plugins/publish/extract_abc_animation.py
+++ b/openpype/hosts/blender/plugins/publish/extract_abc_animation.py
@@ -20,8 +20,6 @@ class ExtractAnimationABC(publish.Extractor):
filename = f"{instance.name}.abc"
filepath = os.path.join(stagingdir, filename)
- context = bpy.context
-
# Perform extraction
self.log.info("Performing extraction..")
diff --git a/openpype/hosts/blender/plugins/publish/extract_camera_abc.py b/openpype/hosts/blender/plugins/publish/extract_camera_abc.py
index a21a59b151..036be7bf3c 100644
--- a/openpype/hosts/blender/plugins/publish/extract_camera_abc.py
+++ b/openpype/hosts/blender/plugins/publish/extract_camera_abc.py
@@ -21,16 +21,11 @@ class ExtractCameraABC(publish.Extractor):
filename = f"{instance.name}.abc"
filepath = os.path.join(stagingdir, filename)
- context = bpy.context
-
# Perform extraction
self.log.info("Performing extraction..")
plugin.deselect_all()
- selected = []
- active = None
-
asset_group = None
for obj in instance:
if obj.get(AVALON_PROPERTY):
diff --git a/openpype/hosts/flame/plugins/load/load_clip.py b/openpype/hosts/flame/plugins/load/load_clip.py
index 338833b449..ca4eab0f63 100644
--- a/openpype/hosts/flame/plugins/load/load_clip.py
+++ b/openpype/hosts/flame/plugins/load/load_clip.py
@@ -48,7 +48,6 @@ class LoadClip(opfapi.ClipLoader):
self.fpd = fproject.current_workspace.desktop
# load clip to timeline and get main variables
- namespace = namespace
version = context['version']
version_data = version.get("data", {})
version_name = version.get("name", None)
diff --git a/openpype/hosts/flame/plugins/load/load_clip_batch.py b/openpype/hosts/flame/plugins/load/load_clip_batch.py
index ca43b94ee9..1f3a017d72 100644
--- a/openpype/hosts/flame/plugins/load/load_clip_batch.py
+++ b/openpype/hosts/flame/plugins/load/load_clip_batch.py
@@ -45,7 +45,6 @@ class LoadClipBatch(opfapi.ClipLoader):
self.batch = options.get("batch") or flame.batch
# load clip to timeline and get main variables
- namespace = namespace
version = context['version']
version_data = version.get("data", {})
version_name = version.get("name", None)
diff --git a/openpype/hosts/flame/plugins/publish/collect_timeline_instances.py b/openpype/hosts/flame/plugins/publish/collect_timeline_instances.py
index 23fdf5e785..e14f960a2b 100644
--- a/openpype/hosts/flame/plugins/publish/collect_timeline_instances.py
+++ b/openpype/hosts/flame/plugins/publish/collect_timeline_instances.py
@@ -325,7 +325,6 @@ class CollectTimelineInstances(pyblish.api.ContextPlugin):
def _create_shot_instance(self, context, clip_name, **data):
master_layer = data.get("heroTrack")
hierarchy_data = data.get("hierarchyData")
- asset = data.get("asset")
if not master_layer:
return
diff --git a/openpype/hosts/harmony/plugins/load/load_template.py b/openpype/hosts/harmony/plugins/load/load_template.py
index f3c69a9104..a78a1bf1ec 100644
--- a/openpype/hosts/harmony/plugins/load/load_template.py
+++ b/openpype/hosts/harmony/plugins/load/load_template.py
@@ -82,7 +82,6 @@ class TemplateLoader(load.LoaderPlugin):
node = harmony.find_node_by_name(node_name, "GROUP")
self_name = self.__class__.__name__
- update_and_replace = False
if is_representation_from_latest(representation):
self._set_green(node)
else:
diff --git a/openpype/hosts/hiero/api/plugin.py b/openpype/hosts/hiero/api/plugin.py
index 65a4009756..52f96261b2 100644
--- a/openpype/hosts/hiero/api/plugin.py
+++ b/openpype/hosts/hiero/api/plugin.py
@@ -317,20 +317,6 @@ class Spacer(QtWidgets.QWidget):
self.setLayout(layout)
-def get_reference_node_parents(ref):
- """Return all parent reference nodes of reference node
-
- Args:
- ref (str): reference node.
-
- Returns:
- list: The upstream parent reference nodes.
-
- """
- parents = []
- return parents
-
-
class SequenceLoader(LoaderPlugin):
"""A basic SequenceLoader for Resolve
diff --git a/openpype/hosts/hiero/plugins/publish/collect_clip_effects.py b/openpype/hosts/hiero/plugins/publish/collect_clip_effects.py
index d455ad4a4e..fcb1ab27a0 100644
--- a/openpype/hosts/hiero/plugins/publish/collect_clip_effects.py
+++ b/openpype/hosts/hiero/plugins/publish/collect_clip_effects.py
@@ -43,7 +43,6 @@ class CollectClipEffects(pyblish.api.InstancePlugin):
if review and review_track_index == _track_index:
continue
for sitem in sub_track_items:
- effect = None
# make sure this subtrack item is relative of track item
if ((track_item not in sitem.linkedItems())
and (len(sitem.linkedItems()) > 0)):
@@ -53,7 +52,6 @@ class CollectClipEffects(pyblish.api.InstancePlugin):
continue
effect = self.add_effect(_track_index, sitem)
-
if effect:
effects.update(effect)
diff --git a/openpype/hosts/houdini/api/colorspace.py b/openpype/hosts/houdini/api/colorspace.py
index 7047644225..cc40b9df1c 100644
--- a/openpype/hosts/houdini/api/colorspace.py
+++ b/openpype/hosts/houdini/api/colorspace.py
@@ -1,7 +1,7 @@
import attr
import hou
from openpype.hosts.houdini.api.lib import get_color_management_preferences
-
+from openpype.pipeline.colorspace import get_display_view_colorspace_name
@attr.s
class LayerMetadata(object):
@@ -54,3 +54,16 @@ class ARenderProduct(object):
)
]
return colorspace_data
+
+
+def get_default_display_view_colorspace():
+ """Returns the colorspace attribute of the default (display, view) pair.
+
+ It's used for 'ociocolorspace' parm in OpenGL Node."""
+
+ prefs = get_color_management_preferences()
+ return get_display_view_colorspace_name(
+ config_path=prefs["config"],
+ display=prefs["display"],
+ view=prefs["view"]
+ )
diff --git a/openpype/hosts/houdini/plugins/create/create_review.py b/openpype/hosts/houdini/plugins/create/create_review.py
index ab06b30c35..60c34a358b 100644
--- a/openpype/hosts/houdini/plugins/create/create_review.py
+++ b/openpype/hosts/houdini/plugins/create/create_review.py
@@ -3,6 +3,9 @@
from openpype.hosts.houdini.api import plugin
from openpype.lib import EnumDef, BoolDef, NumberDef
+import os
+import hou
+
class CreateReview(plugin.HoudiniCreator):
"""Review with OpenGL ROP"""
@@ -13,7 +16,6 @@ class CreateReview(plugin.HoudiniCreator):
icon = "video-camera"
def create(self, subset_name, instance_data, pre_create_data):
- import hou
instance_data.pop("active", None)
instance_data.update({"node_type": "opengl"})
@@ -82,6 +84,11 @@ class CreateReview(plugin.HoudiniCreator):
instance_node.setParms(parms)
+ # Set OCIO Colorspace to the default output colorspace
+ # if there's OCIO
+ if os.getenv("OCIO"):
+ self.set_colorcorrect_to_default_view_space(instance_node)
+
to_lock = ["id", "family"]
self.lock_parameters(instance_node, to_lock)
@@ -123,3 +130,23 @@ class CreateReview(plugin.HoudiniCreator):
minimum=0.0001,
decimals=3)
]
+
+ def set_colorcorrect_to_default_view_space(self,
+ instance_node):
+ """Set ociocolorspace to the default output space."""
+ from openpype.hosts.houdini.api.colorspace import get_default_display_view_colorspace # noqa
+
+ # set Color Correction parameter to OpenColorIO
+ instance_node.setParms({"colorcorrect": 2})
+
+ # Get default view space for ociocolorspace parm.
+ default_view_space = get_default_display_view_colorspace()
+ instance_node.setParms(
+ {"ociocolorspace": default_view_space}
+ )
+
+ self.log.debug(
+ "'OCIO Colorspace' parm on '{}' has been set to "
+ "the default view color space '{}'"
+ .format(instance_node, default_view_space)
+ )
diff --git a/openpype/hosts/houdini/plugins/load/load_bgeo.py b/openpype/hosts/houdini/plugins/load/load_bgeo.py
index 22680178c0..489bf944ed 100644
--- a/openpype/hosts/houdini/plugins/load/load_bgeo.py
+++ b/openpype/hosts/houdini/plugins/load/load_bgeo.py
@@ -34,7 +34,6 @@ class BgeoLoader(load.LoaderPlugin):
# Create a new geo node
container = obj.createNode("geo", node_name=node_name)
- is_sequence = bool(context["representation"]["context"].get("frame"))
# Remove the file node, it only loads static meshes
# Houdini 17 has removed the file node from the geo node
diff --git a/openpype/hosts/houdini/plugins/publish/collect_vray_rop.py b/openpype/hosts/houdini/plugins/publish/collect_vray_rop.py
index d4fe37f993..277f922ba4 100644
--- a/openpype/hosts/houdini/plugins/publish/collect_vray_rop.py
+++ b/openpype/hosts/houdini/plugins/publish/collect_vray_rop.py
@@ -80,14 +80,9 @@ class CollectVrayROPRenderProducts(pyblish.api.InstancePlugin):
def get_beauty_render_product(self, prefix, suffix=""):
"""Return the beauty output filename if render element enabled
"""
+ # Remove aov suffix from the product: `prefix.aov_suffix` -> `prefix`
aov_parm = ".{}".format(suffix)
- beauty_product = None
- if aov_parm in prefix:
- beauty_product = prefix.replace(aov_parm, "")
- else:
- beauty_product = prefix
-
- return beauty_product
+ return prefix.replace(aov_parm, "")
def get_render_element_name(self, node, prefix, suffix=""):
"""Return the output filename using the AOV prefix and suffix
diff --git a/openpype/hosts/houdini/plugins/publish/validate_review_colorspace.py b/openpype/hosts/houdini/plugins/publish/validate_review_colorspace.py
new file mode 100644
index 0000000000..03ecd1b052
--- /dev/null
+++ b/openpype/hosts/houdini/plugins/publish/validate_review_colorspace.py
@@ -0,0 +1,90 @@
+# -*- coding: utf-8 -*-
+import pyblish.api
+from openpype.pipeline import (
+ PublishValidationError,
+ OptionalPyblishPluginMixin
+)
+from openpype.pipeline.publish import RepairAction
+from openpype.hosts.houdini.api.action import SelectROPAction
+
+import os
+import hou
+
+
+class SetDefaultViewSpaceAction(RepairAction):
+ label = "Set default view colorspace"
+ icon = "mdi.monitor"
+
+
+class ValidateReviewColorspace(pyblish.api.InstancePlugin,
+ OptionalPyblishPluginMixin):
+ """Validate Review Colorspace parameters.
+
+ It checks if 'OCIO Colorspace' parameter was set to valid value.
+ """
+
+ order = pyblish.api.ValidatorOrder + 0.1
+ families = ["review"]
+ hosts = ["houdini"]
+ label = "Validate Review Colorspace"
+ actions = [SetDefaultViewSpaceAction, SelectROPAction]
+
+ optional = True
+
+ def process(self, instance):
+
+ if not self.is_active(instance.data):
+ return
+
+ if os.getenv("OCIO") is None:
+ self.log.debug(
+ "Using Houdini's Default Color Management, "
+ " skipping check.."
+ )
+ return
+
+ rop_node = hou.node(instance.data["instance_node"])
+ if rop_node.evalParm("colorcorrect") != 2:
+ # any colorspace settings other than default requires
+ # 'Color Correct' parm to be set to 'OpenColorIO'
+ raise PublishValidationError(
+ "'Color Correction' parm on '{}' ROP must be set to"
+ " 'OpenColorIO'".format(rop_node.path())
+ )
+
+ if rop_node.evalParm("ociocolorspace") not in \
+ hou.Color.ocio_spaces():
+
+ raise PublishValidationError(
+ "Invalid value: Colorspace name doesn't exist.\n"
+ "Check 'OCIO Colorspace' parameter on '{}' ROP"
+ .format(rop_node.path())
+ )
+
+ @classmethod
+ def repair(cls, instance):
+ """Set Default View Space Action.
+
+ It is a helper action more than a repair action,
+ used to set colorspace on opengl node to the default view.
+ """
+ from openpype.hosts.houdini.api.colorspace import get_default_display_view_colorspace # noqa
+
+ rop_node = hou.node(instance.data["instance_node"])
+
+ if rop_node.evalParm("colorcorrect") != 2:
+ rop_node.setParms({"colorcorrect": 2})
+ cls.log.debug(
+ "'Color Correction' parm on '{}' has been set to"
+ " 'OpenColorIO'".format(rop_node.path())
+ )
+
+ # Get default view colorspace name
+ default_view_space = get_default_display_view_colorspace()
+
+ rop_node.setParms({"ociocolorspace": default_view_space})
+ cls.log.info(
+ "'OCIO Colorspace' parm on '{}' has been set to "
+ "the default view color space '{}'"
+ .format(rop_node, default_view_space)
+ )
diff --git a/openpype/hosts/max/api/lib_rendersettings.py b/openpype/hosts/max/api/lib_rendersettings.py
index afde5008d5..26e176aa8d 100644
--- a/openpype/hosts/max/api/lib_rendersettings.py
+++ b/openpype/hosts/max/api/lib_rendersettings.py
@@ -37,13 +37,10 @@ class RenderSettings(object):
def set_render_camera(self, selection):
for sel in selection:
# to avoid Attribute Error from pymxs wrapper
- found = False
if rt.classOf(sel) in rt.Camera.classes:
- found = True
rt.viewport.setCamera(sel)
- break
- if not found:
- raise RuntimeError("Active Camera not found")
+ return
+ raise RuntimeError("Active Camera not found")
def render_output(self, container):
folder = rt.maxFilePath
diff --git a/openpype/hosts/max/plugins/create/create_render.py b/openpype/hosts/max/plugins/create/create_render.py
index 235046684e..9cc3c8da8a 100644
--- a/openpype/hosts/max/plugins/create/create_render.py
+++ b/openpype/hosts/max/plugins/create/create_render.py
@@ -14,7 +14,6 @@ class CreateRender(plugin.MaxCreator):
def create(self, subset_name, instance_data, pre_create_data):
from pymxs import runtime as rt
- sel_obj = list(rt.selection)
file = rt.maxFileName
filename, _ = os.path.splitext(file)
instance_data["AssetName"] = filename
diff --git a/openpype/hosts/max/plugins/publish/collect_render.py b/openpype/hosts/max/plugins/publish/collect_render.py
index 8ee2f43103..2dfa1520a9 100644
--- a/openpype/hosts/max/plugins/publish/collect_render.py
+++ b/openpype/hosts/max/plugins/publish/collect_render.py
@@ -30,7 +30,6 @@ class CollectRender(pyblish.api.InstancePlugin):
asset = get_current_asset_name()
files_by_aov = RenderProducts().get_beauty(instance.name)
- folder = folder.replace("\\", "/")
aovs = RenderProducts().get_aovs(instance.name)
files_by_aov.update(aovs)
diff --git a/openpype/hosts/max/plugins/publish/extract_camera_abc.py b/openpype/hosts/max/plugins/publish/extract_camera_abc.py
index b42732e70d..b1918c53e0 100644
--- a/openpype/hosts/max/plugins/publish/extract_camera_abc.py
+++ b/openpype/hosts/max/plugins/publish/extract_camera_abc.py
@@ -22,8 +22,6 @@ class ExtractCameraAlembic(publish.Extractor, OptionalPyblishPluginMixin):
start = float(instance.data.get("frameStartHandle", 1))
end = float(instance.data.get("frameEndHandle", 1))
- container = instance.data["instance_node"]
-
self.log.info("Extracting Camera ...")
stagingdir = self.staging_dir(instance)
diff --git a/openpype/hosts/max/plugins/publish/extract_camera_fbx.py b/openpype/hosts/max/plugins/publish/extract_camera_fbx.py
index 06ac3da093..537c88eb4d 100644
--- a/openpype/hosts/max/plugins/publish/extract_camera_fbx.py
+++ b/openpype/hosts/max/plugins/publish/extract_camera_fbx.py
@@ -19,9 +19,8 @@ class ExtractCameraFbx(publish.Extractor, OptionalPyblishPluginMixin):
def process(self, instance):
if not self.is_active(instance.data):
return
- container = instance.data["instance_node"]
- self.log.info("Extracting Camera ...")
+ self.log.debug("Extracting Camera ...")
stagingdir = self.staging_dir(instance)
filename = "{name}.fbx".format(**instance.data)
diff --git a/openpype/hosts/max/plugins/publish/extract_max_scene_raw.py b/openpype/hosts/max/plugins/publish/extract_max_scene_raw.py
index de5db9ab56..a7a889c587 100644
--- a/openpype/hosts/max/plugins/publish/extract_max_scene_raw.py
+++ b/openpype/hosts/max/plugins/publish/extract_max_scene_raw.py
@@ -18,10 +18,9 @@ class ExtractMaxSceneRaw(publish.Extractor, OptionalPyblishPluginMixin):
def process(self, instance):
if not self.is_active(instance.data):
return
- container = instance.data["instance_node"]
# publish the raw scene for camera
- self.log.info("Extracting Raw Max Scene ...")
+ self.log.debug("Extracting Raw Max Scene ...")
stagingdir = self.staging_dir(instance)
filename = "{name}.max".format(**instance.data)
diff --git a/openpype/hosts/max/plugins/publish/extract_model.py b/openpype/hosts/max/plugins/publish/extract_model.py
index c7ecf7efc9..38f4848c5e 100644
--- a/openpype/hosts/max/plugins/publish/extract_model.py
+++ b/openpype/hosts/max/plugins/publish/extract_model.py
@@ -20,9 +20,7 @@ class ExtractModel(publish.Extractor, OptionalPyblishPluginMixin):
if not self.is_active(instance.data):
return
- container = instance.data["instance_node"]
-
- self.log.info("Extracting Geometry ...")
+ self.log.debug("Extracting Geometry ...")
stagingdir = self.staging_dir(instance)
filename = "{name}.abc".format(**instance.data)
diff --git a/openpype/hosts/max/plugins/publish/extract_model_fbx.py b/openpype/hosts/max/plugins/publish/extract_model_fbx.py
index 56c2cadd94..fd48ed5007 100644
--- a/openpype/hosts/max/plugins/publish/extract_model_fbx.py
+++ b/openpype/hosts/max/plugins/publish/extract_model_fbx.py
@@ -20,10 +20,7 @@ class ExtractModelFbx(publish.Extractor, OptionalPyblishPluginMixin):
if not self.is_active(instance.data):
return
- container = instance.data["instance_node"]
-
-
- self.log.info("Extracting Geometry ...")
+ self.log.debug("Extracting Geometry ...")
stagingdir = self.staging_dir(instance)
filename = "{name}.fbx".format(**instance.data)
diff --git a/openpype/hosts/max/plugins/publish/extract_model_obj.py b/openpype/hosts/max/plugins/publish/extract_model_obj.py
index 4fde65cf22..e522b1e7a1 100644
--- a/openpype/hosts/max/plugins/publish/extract_model_obj.py
+++ b/openpype/hosts/max/plugins/publish/extract_model_obj.py
@@ -20,9 +20,7 @@ class ExtractModelObj(publish.Extractor, OptionalPyblishPluginMixin):
if not self.is_active(instance.data):
return
- container = instance.data["instance_node"]
-
- self.log.info("Extracting Geometry ...")
+ self.log.debug("Extracting Geometry ...")
stagingdir = self.staging_dir(instance)
filename = "{name}.obj".format(**instance.data)
diff --git a/openpype/hosts/max/plugins/publish/extract_pointcache.py b/openpype/hosts/max/plugins/publish/extract_pointcache.py
index 5a99a8b845..c3de623bc0 100644
--- a/openpype/hosts/max/plugins/publish/extract_pointcache.py
+++ b/openpype/hosts/max/plugins/publish/extract_pointcache.py
@@ -54,8 +54,6 @@ class ExtractAlembic(publish.Extractor):
start = float(instance.data.get("frameStartHandle", 1))
end = float(instance.data.get("frameEndHandle", 1))
- container = instance.data["instance_node"]
-
self.log.debug("Extracting pointcache ...")
parent_dir = self.staging_dir(instance)
diff --git a/openpype/hosts/max/plugins/publish/extract_redshift_proxy.py b/openpype/hosts/max/plugins/publish/extract_redshift_proxy.py
index ab569ecbcb..f67ed30c6b 100644
--- a/openpype/hosts/max/plugins/publish/extract_redshift_proxy.py
+++ b/openpype/hosts/max/plugins/publish/extract_redshift_proxy.py
@@ -16,11 +16,10 @@ class ExtractRedshiftProxy(publish.Extractor):
families = ["redshiftproxy"]
def process(self, instance):
- container = instance.data["instance_node"]
start = int(instance.context.data.get("frameStart"))
end = int(instance.context.data.get("frameEnd"))
- self.log.info("Extracting Redshift Proxy...")
+ self.log.debug("Extracting Redshift Proxy...")
stagingdir = self.staging_dir(instance)
rs_filename = "{name}.rs".format(**instance.data)
rs_filepath = os.path.join(stagingdir, rs_filename)
diff --git a/openpype/hosts/max/plugins/publish/validate_resolution_setting.py b/openpype/hosts/max/plugins/publish/validate_resolution_setting.py
index 5fcb843b20..5ac41b10a0 100644
--- a/openpype/hosts/max/plugins/publish/validate_resolution_setting.py
+++ b/openpype/hosts/max/plugins/publish/validate_resolution_setting.py
@@ -6,11 +6,6 @@ from openpype.pipeline import (
from pymxs import runtime as rt
from openpype.hosts.max.api.lib import reset_scene_resolution
-from openpype.pipeline.context_tools import (
- get_current_project_asset,
- get_current_project
-)
-
class ValidateResolutionSetting(pyblish.api.InstancePlugin,
OptionalPyblishPluginMixin):
@@ -43,22 +38,16 @@ class ValidateResolutionSetting(pyblish.api.InstancePlugin,
"on asset or shot.")
def get_db_resolution(self, instance):
- data = ["data.resolutionWidth", "data.resolutionHeight"]
- project_resolution = get_current_project(fields=data)
- project_resolution_data = project_resolution["data"]
- asset_resolution = get_current_project_asset(fields=data)
- asset_resolution_data = asset_resolution["data"]
- # Set project resolution
- project_width = int(
- project_resolution_data.get("resolutionWidth", 1920))
- project_height = int(
- project_resolution_data.get("resolutionHeight", 1080))
- width = int(
- asset_resolution_data.get("resolutionWidth", project_width))
- height = int(
- asset_resolution_data.get("resolutionHeight", project_height))
+ asset_doc = instance.data["assetEntity"]
+ project_doc = instance.context.data["projectEntity"]
+ for data in [asset_doc["data"], project_doc["data"]]:
+ if "resolutionWidth" in data and "resolutionHeight" in data:
+ width = data["resolutionWidth"]
+ height = data["resolutionHeight"]
+ return int(width), int(height)
- return width, height
+ # Defaults if not found in asset document or project document
+ return 1920, 1080
@classmethod
def repair(cls, instance):
diff --git a/openpype/hosts/maya/api/lib_rendersettings.py b/openpype/hosts/maya/api/lib_rendersettings.py
index f54633c04d..42cf29d0a7 100644
--- a/openpype/hosts/maya/api/lib_rendersettings.py
+++ b/openpype/hosts/maya/api/lib_rendersettings.py
@@ -177,12 +177,7 @@ class RenderSettings(object):
# list all the aovs
all_rs_aovs = cmds.ls(type='RedshiftAOV')
for rs_aov in redshift_aovs:
- rs_layername = rs_aov
- if " " in rs_aov:
- rs_renderlayer = rs_aov.replace(" ", "")
- rs_layername = "rsAov_{}".format(rs_renderlayer)
- else:
- rs_layername = "rsAov_{}".format(rs_aov)
+ rs_layername = "rsAov_{}".format(rs_aov.replace(" ", ""))
if rs_layername in all_rs_aovs:
continue
cmds.rsCreateAov(type=rs_aov)
@@ -317,7 +312,7 @@ class RenderSettings(object):
separators = [cmds.menuItem(i, query=True, label=True) for i in items] # noqa: E501
try:
sep_idx = separators.index(aov_separator)
- except ValueError as e:
+ except ValueError:
six.reraise(
CreatorError,
CreatorError(
diff --git a/openpype/hosts/maya/api/plugin.py b/openpype/hosts/maya/api/plugin.py
index 3f383fafb8..4032618afb 100644
--- a/openpype/hosts/maya/api/plugin.py
+++ b/openpype/hosts/maya/api/plugin.py
@@ -683,7 +683,6 @@ class ReferenceLoader(Loader):
loaded_containers.append(container)
self._organize_containers(nodes, container)
c += 1
- namespace = None
return loaded_containers
diff --git a/openpype/hosts/maya/plugins/inventory/import_reference.py b/openpype/hosts/maya/plugins/inventory/import_reference.py
index ecc424209d..3f3b85ba6c 100644
--- a/openpype/hosts/maya/plugins/inventory/import_reference.py
+++ b/openpype/hosts/maya/plugins/inventory/import_reference.py
@@ -12,7 +12,6 @@ class ImportReference(InventoryAction):
color = "#d8d8d8"
def process(self, containers):
- references = cmds.ls(type="reference")
for container in containers:
if container["loader"] != "ReferenceLoader":
print("Not a reference, skipping")
diff --git a/openpype/hosts/maya/plugins/load/load_multiverse_usd.py b/openpype/hosts/maya/plugins/load/load_multiverse_usd.py
index d08fcd904e..cad42b55f9 100644
--- a/openpype/hosts/maya/plugins/load/load_multiverse_usd.py
+++ b/openpype/hosts/maya/plugins/load/load_multiverse_usd.py
@@ -43,8 +43,6 @@ class MultiverseUsdLoader(load.LoaderPlugin):
import multiverse
# Create the shape
- shape = None
- transform = None
with maintained_selection():
cmds.namespace(addNamespace=namespace)
with namespaced(namespace, new=False):
diff --git a/openpype/hosts/maya/plugins/load/load_reference.py b/openpype/hosts/maya/plugins/load/load_reference.py
index 91767249e0..61f337f501 100644
--- a/openpype/hosts/maya/plugins/load/load_reference.py
+++ b/openpype/hosts/maya/plugins/load/load_reference.py
@@ -205,7 +205,7 @@ class ReferenceLoader(openpype.hosts.maya.api.plugin.ReferenceLoader):
cmds.setAttr("{}.selectHandleZ".format(group_name), cz)
if family == "rig":
- self._post_process_rig(name, namespace, context, options)
+ self._post_process_rig(namespace, context, options)
else:
if "translate" in options:
if not attach_to_root and new_nodes:
@@ -229,7 +229,7 @@ class ReferenceLoader(openpype.hosts.maya.api.plugin.ReferenceLoader):
members = get_container_members(container)
self._lock_camera_transforms(members)
- def _post_process_rig(self, name, namespace, context, options):
+ def _post_process_rig(self, namespace, context, options):
nodes = self[:]
create_rig_animation_instance(
diff --git a/openpype/hosts/maya/plugins/load/load_xgen.py b/openpype/hosts/maya/plugins/load/load_xgen.py
index 323f8d7eda..2ad6ad55bc 100644
--- a/openpype/hosts/maya/plugins/load/load_xgen.py
+++ b/openpype/hosts/maya/plugins/load/load_xgen.py
@@ -53,8 +53,6 @@ class XgenLoader(openpype.hosts.maya.api.plugin.ReferenceLoader):
)
# Reference xgen. Xgen does not like being referenced in under a group.
- new_nodes = []
-
with maintained_selection():
nodes = cmds.file(
maya_filepath,
diff --git a/openpype/hosts/maya/plugins/load/load_yeti_cache.py b/openpype/hosts/maya/plugins/load/load_yeti_cache.py
index 5cded13d4e..4a11ea9a2c 100644
--- a/openpype/hosts/maya/plugins/load/load_yeti_cache.py
+++ b/openpype/hosts/maya/plugins/load/load_yeti_cache.py
@@ -15,6 +15,16 @@ from openpype.hosts.maya.api import lib
from openpype.hosts.maya.api.pipeline import containerise
+# Do not reset these values on update but only apply on first load
+# to preserve any potential local overrides
+SKIP_UPDATE_ATTRS = {
+ "displayOutput",
+ "viewportDensity",
+ "viewportWidth",
+ "viewportLength",
+}
+
+
def set_attribute(node, attr, value):
"""Wrapper of set attribute which ignores None values"""
if value is None:
@@ -205,6 +215,8 @@ class YetiCacheLoader(load.LoaderPlugin):
yeti_node = yeti_nodes[0]
for attr, value in node_settings["attrs"].items():
+ if attr in SKIP_UPDATE_ATTRS:
+ continue
set_attribute(attr, value, yeti_node)
cmds.setAttr("{}.representation".format(container_node),
@@ -311,7 +323,6 @@ class YetiCacheLoader(load.LoaderPlugin):
# Update attributes with defaults
attributes = node_settings["attrs"]
attributes.update({
- "viewportDensity": 0.1,
"verbosity": 2,
"fileMode": 1,
@@ -321,6 +332,9 @@ class YetiCacheLoader(load.LoaderPlugin):
"visibleInRefractions": True
})
+ if "viewportDensity" not in attributes:
+ attributes["viewportDensity"] = 0.1
+
# Apply attributes to pgYetiMaya node
for attr, value in attributes.items():
set_attribute(attr, value, yeti_node)
diff --git a/openpype/hosts/maya/plugins/publish/collect_multiverse_look.py b/openpype/hosts/maya/plugins/publish/collect_multiverse_look.py
index f05fb76d48..bcb979edfc 100644
--- a/openpype/hosts/maya/plugins/publish/collect_multiverse_look.py
+++ b/openpype/hosts/maya/plugins/publish/collect_multiverse_look.py
@@ -281,7 +281,6 @@ class CollectMultiverseLookData(pyblish.api.InstancePlugin):
long=True)
nodes.update(nodes_of_interest)
- files = []
sets = {}
instance.data["resources"] = []
publishMipMap = instance.data["publishMipMap"]
diff --git a/openpype/hosts/maya/plugins/publish/collect_rig_sets.py b/openpype/hosts/maya/plugins/publish/collect_rig_sets.py
new file mode 100644
index 0000000000..36a4211af1
--- /dev/null
+++ b/openpype/hosts/maya/plugins/publish/collect_rig_sets.py
@@ -0,0 +1,39 @@
+import pyblish.api
+from maya import cmds
+
+
+class CollectRigSets(pyblish.api.InstancePlugin):
+ """Ensure rig contains pipeline-critical content
+
+ Every rig must contain at least two object sets:
+ "controls_SET" - Set of all animatable controls
+ "out_SET" - Set of all cacheable meshes
+
+ """
+
+ order = pyblish.api.CollectorOrder + 0.05
+ label = "Collect Rig Sets"
+ hosts = ["maya"]
+ families = ["rig"]
+
+ accepted_output = ["mesh", "transform"]
+ accepted_controllers = ["transform"]
+
+ def process(self, instance):
+
+ # Find required sets by suffix
+ searching = {"controls_SET", "out_SET"}
+ found = {}
+ for node in cmds.ls(instance, exactType="objectSet"):
+ for suffix in searching:
+ if node.endswith(suffix):
+ found[suffix] = node
+ searching.remove(suffix)
+ break
+ if not searching:
+ break
+
+ self.log.debug("Found sets: {}".format(found))
+ rig_sets = instance.data.setdefault("rig_sets", {})
+ for name, objset in found.items():
+ rig_sets[name] = objset
diff --git a/openpype/hosts/maya/plugins/publish/collect_yeti_cache.py b/openpype/hosts/maya/plugins/publish/collect_yeti_cache.py
index e6b5ca4260..4dcda29050 100644
--- a/openpype/hosts/maya/plugins/publish/collect_yeti_cache.py
+++ b/openpype/hosts/maya/plugins/publish/collect_yeti_cache.py
@@ -4,12 +4,23 @@ import pyblish.api
from openpype.hosts.maya.api import lib
-SETTINGS = {"renderDensity",
- "renderWidth",
- "renderLength",
- "increaseRenderBounds",
- "imageSearchPath",
- "cbId"}
+
+SETTINGS = {
+ # Preview
+ "displayOutput",
+ "colorR", "colorG", "colorB",
+ "viewportDensity",
+ "viewportWidth",
+ "viewportLength",
+ # Render attributes
+ "renderDensity",
+ "renderWidth",
+ "renderLength",
+ "increaseRenderBounds",
+ "imageSearchPath",
+ # Pipeline specific
+ "cbId"
+}
class CollectYetiCache(pyblish.api.InstancePlugin):
@@ -39,10 +50,6 @@ class CollectYetiCache(pyblish.api.InstancePlugin):
# Get yeti nodes and their transforms
yeti_shapes = cmds.ls(instance, type="pgYetiMaya")
for shape in yeti_shapes:
- shape_data = {"transform": None,
- "name": shape,
- "cbId": lib.get_id(shape),
- "attrs": None}
# Get specific node attributes
attr_data = {}
@@ -58,9 +65,12 @@ class CollectYetiCache(pyblish.api.InstancePlugin):
parent = cmds.listRelatives(shape, parent=True)[0]
transform_data = {"name": parent, "cbId": lib.get_id(parent)}
- # Store collected data
- shape_data["attrs"] = attr_data
- shape_data["transform"] = transform_data
+ shape_data = {
+ "transform": transform_data,
+ "name": shape,
+ "cbId": lib.get_id(shape),
+ "attrs": attr_data,
+ }
settings["nodes"].append(shape_data)
diff --git a/openpype/hosts/maya/plugins/publish/extract_import_reference.py b/openpype/hosts/maya/plugins/publish/extract_import_reference.py
index 9d2ff1a3eb..1fdee28d0c 100644
--- a/openpype/hosts/maya/plugins/publish/extract_import_reference.py
+++ b/openpype/hosts/maya/plugins/publish/extract_import_reference.py
@@ -30,8 +30,8 @@ class ExtractImportReference(publish.Extractor,
tmp_format = "_tmp"
@classmethod
- def apply_settings(cls, project_setting, system_settings):
- cls.active = project_setting["deadline"]["publish"]["MayaSubmitDeadline"]["import_reference"] # noqa
+ def apply_settings(cls, project_settings):
+ cls.active = project_settings["deadline"]["publish"]["MayaSubmitDeadline"]["import_reference"] # noqa
def process(self, instance):
if not self.is_active(instance.data):
diff --git a/openpype/hosts/maya/plugins/publish/validate_maya_units.py b/openpype/hosts/maya/plugins/publish/validate_maya_units.py
index 1d5619795f..ae6dc093a9 100644
--- a/openpype/hosts/maya/plugins/publish/validate_maya_units.py
+++ b/openpype/hosts/maya/plugins/publish/validate_maya_units.py
@@ -37,7 +37,7 @@ class ValidateMayaUnits(pyblish.api.ContextPlugin):
)
@classmethod
- def apply_settings(cls, project_settings, system_settings):
+ def apply_settings(cls, project_settings):
"""Apply project settings to creator"""
settings = (
project_settings["maya"]["publish"]["ValidateMayaUnits"]
diff --git a/openpype/hosts/maya/plugins/publish/validate_rig_contents.py b/openpype/hosts/maya/plugins/publish/validate_rig_contents.py
index 7b5392f8f9..23f031a5db 100644
--- a/openpype/hosts/maya/plugins/publish/validate_rig_contents.py
+++ b/openpype/hosts/maya/plugins/publish/validate_rig_contents.py
@@ -2,7 +2,9 @@ import pyblish.api
from maya import cmds
from openpype.pipeline.publish import (
- PublishValidationError, ValidateContentsOrder)
+ PublishValidationError,
+ ValidateContentsOrder
+)
class ValidateRigContents(pyblish.api.InstancePlugin):
@@ -24,31 +26,45 @@ class ValidateRigContents(pyblish.api.InstancePlugin):
def process(self, instance):
- objectsets = ("controls_SET", "out_SET")
- missing = [obj for obj in objectsets if obj not in instance]
- assert not missing, ("%s is missing %s" % (instance, missing))
+ # Find required sets by suffix
+ required = ["controls_SET", "out_SET"]
+ missing = [
+ key for key in required if key not in instance.data["rig_sets"]
+ ]
+ if missing:
+ raise PublishValidationError(
+ "%s is missing sets: %s" % (instance, ", ".join(missing))
+ )
+
+ controls_set = instance.data["rig_sets"]["controls_SET"]
+ out_set = instance.data["rig_sets"]["out_SET"]
# Ensure there are at least some transforms or dag nodes
# in the rig instance
set_members = instance.data['setMembers']
if not cmds.ls(set_members, type="dagNode", long=True):
raise PublishValidationError(
- ("No dag nodes in the pointcache instance. "
- "(Empty instance?)"))
+ "No dag nodes in the pointcache instance. "
+ "(Empty instance?)"
+ )
# Ensure contents in sets and retrieve long path for all objects
- output_content = cmds.sets("out_SET", query=True) or []
- assert output_content, "Must have members in rig out_SET"
+ output_content = cmds.sets(out_set, query=True) or []
+ if not output_content:
+ raise PublishValidationError("Must have members in rig out_SET")
output_content = cmds.ls(output_content, long=True)
- controls_content = cmds.sets("controls_SET", query=True) or []
- assert controls_content, "Must have members in rig controls_SET"
+ controls_content = cmds.sets(controls_set, query=True) or []
+ if not controls_content:
+ raise PublishValidationError(
+ "Must have members in rig controls_SET"
+ )
controls_content = cmds.ls(controls_content, long=True)
# Validate members are inside the hierarchy from root node
- root_node = cmds.ls(set_members, assemblies=True)
- hierarchy = cmds.listRelatives(root_node, allDescendents=True,
- fullPath=True)
+ root_nodes = cmds.ls(set_members, assemblies=True, long=True)
+ hierarchy = cmds.listRelatives(root_nodes, allDescendents=True,
+ fullPath=True) + root_nodes
hierarchy = set(hierarchy)
invalid_hierarchy = []
diff --git a/openpype/hosts/maya/plugins/publish/validate_rig_controllers.py b/openpype/hosts/maya/plugins/publish/validate_rig_controllers.py
index 7bbf4257ab..a3828f871b 100644
--- a/openpype/hosts/maya/plugins/publish/validate_rig_controllers.py
+++ b/openpype/hosts/maya/plugins/publish/validate_rig_controllers.py
@@ -52,22 +52,30 @@ class ValidateRigControllers(pyblish.api.InstancePlugin):
def process(self, instance):
invalid = self.get_invalid(instance)
if invalid:
- raise PublishValidationError('{} failed, see log '
- 'information'.format(self.label))
+ raise PublishValidationError(
+ '{} failed, see log information'.format(self.label)
+ )
@classmethod
def get_invalid(cls, instance):
- controllers_sets = [i for i in instance if i == "controls_SET"]
- controls = cmds.sets(controllers_sets, query=True)
- assert controls, "Must have 'controls_SET' in rig instance"
+ controls_set = instance.data["rig_sets"].get("controls_SET")
+ if not controls_set:
+ cls.log.error(
+ "Must have 'controls_SET' in rig instance"
+ )
+ return [instance.data["instance_node"]]
+
+ controls = cmds.sets(controls_set, query=True)
# Ensure all controls are within the top group
lookup = set(instance[:])
- assert all(control in lookup for control in cmds.ls(controls,
- long=True)), (
- "All controls must be inside the rig's group."
- )
+ if not all(control in lookup for control in cmds.ls(controls,
+ long=True)):
+ cls.log.error(
+ "All controls must be inside the rig's group."
+ )
+ return [controls_set]
# Validate all controls
has_connections = list()
@@ -181,9 +189,17 @@ class ValidateRigControllers(pyblish.api.InstancePlugin):
@classmethod
def repair(cls, instance):
+ controls_set = instance.data["rig_sets"].get("controls_SET")
+ if not controls_set:
+ cls.log.error(
+ "Unable to repair because no 'controls_SET' found in rig "
+ "instance: {}".format(instance)
+ )
+ return
+
# Use a single undo chunk
with undo_chunk():
- controls = cmds.sets("controls_SET", query=True)
+ controls = cmds.sets(controls_set, query=True)
for control in controls:
# Lock visibility
diff --git a/openpype/hosts/maya/plugins/publish/validate_rig_controllers_arnold_attributes.py b/openpype/hosts/maya/plugins/publish/validate_rig_controllers_arnold_attributes.py
index 842c1de01b..03f6a5f1ab 100644
--- a/openpype/hosts/maya/plugins/publish/validate_rig_controllers_arnold_attributes.py
+++ b/openpype/hosts/maya/plugins/publish/validate_rig_controllers_arnold_attributes.py
@@ -56,11 +56,11 @@ class ValidateRigControllersArnoldAttributes(pyblish.api.InstancePlugin):
@classmethod
def get_invalid(cls, instance):
- controllers_sets = [i for i in instance if i == "controls_SET"]
- if not controllers_sets:
+ controls_set = instance.data["rig_sets"].get("controls_SET")
+ if not controls_set:
return []
- controls = cmds.sets(controllers_sets, query=True) or []
+ controls = cmds.sets(controls_set, query=True) or []
if not controls:
return []
diff --git a/openpype/hosts/maya/plugins/publish/validate_rig_out_set_node_ids.py b/openpype/hosts/maya/plugins/publish/validate_rig_out_set_node_ids.py
index 39f0941faa..fbd510c683 100644
--- a/openpype/hosts/maya/plugins/publish/validate_rig_out_set_node_ids.py
+++ b/openpype/hosts/maya/plugins/publish/validate_rig_out_set_node_ids.py
@@ -38,16 +38,19 @@ class ValidateRigOutSetNodeIds(pyblish.api.InstancePlugin):
# if a deformer has been created on the shape
invalid = self.get_invalid(instance)
if invalid:
- raise PublishValidationError("Nodes found with mismatching "
- "IDs: {0}".format(invalid))
+ raise PublishValidationError(
+ "Nodes found with mismatching IDs: {0}".format(invalid)
+ )
@classmethod
def get_invalid(cls, instance):
"""Get all nodes which do not match the criteria"""
- invalid = []
+ out_set = instance.data["rig_sets"].get("out_SET")
+ if not out_set:
+ return []
- out_set = next(x for x in instance if x.endswith("out_SET"))
+ invalid = []
members = cmds.sets(out_set, query=True)
shapes = cmds.ls(members,
dag=True,
diff --git a/openpype/hosts/maya/plugins/publish/validate_rig_output_ids.py b/openpype/hosts/maya/plugins/publish/validate_rig_output_ids.py
index cbc750bace..24fb36eb8b 100644
--- a/openpype/hosts/maya/plugins/publish/validate_rig_output_ids.py
+++ b/openpype/hosts/maya/plugins/publish/validate_rig_output_ids.py
@@ -47,7 +47,10 @@ class ValidateRigOutputIds(pyblish.api.InstancePlugin):
invalid = {}
if compute:
- out_set = next(x for x in instance if "out_SET" in x)
+ out_set = instance.data["rig_sets"].get("out_SET")
+ if not out_set:
+ instance.data["mismatched_output_ids"] = invalid
+ return invalid
instance_nodes = cmds.sets(out_set, query=True, nodesOnly=True)
instance_nodes = cmds.ls(instance_nodes, long=True)
diff --git a/openpype/hosts/maya/tools/mayalookassigner/commands.py b/openpype/hosts/maya/tools/mayalookassigner/commands.py
index a1290aa68d..5cc4f84931 100644
--- a/openpype/hosts/maya/tools/mayalookassigner/commands.py
+++ b/openpype/hosts/maya/tools/mayalookassigner/commands.py
@@ -138,8 +138,13 @@ def create_items_from_nodes(nodes):
asset_doc = asset_docs_by_id.get(asset_id)
# Skip if asset id is not found
if not asset_doc:
- log.warning("Id not found in the database, skipping '%s'." % _id)
- log.warning("Nodes: %s" % id_nodes)
+ log.warning(
+ "Id found on {num} nodes for which no asset is found database,"
+ " skipping '{asset_id}'".format(
+ num=len(nodes),
+ asset_id=asset_id
+ )
+ )
continue
# Collect available look subsets for this asset
diff --git a/openpype/hosts/maya/tools/mayalookassigner/widgets.py b/openpype/hosts/maya/tools/mayalookassigner/widgets.py
index f2df17e68c..82c37e2104 100644
--- a/openpype/hosts/maya/tools/mayalookassigner/widgets.py
+++ b/openpype/hosts/maya/tools/mayalookassigner/widgets.py
@@ -90,15 +90,13 @@ class AssetOutliner(QtWidgets.QWidget):
def get_all_assets(self):
"""Add all items from the current scene"""
- items = []
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)
self.add_items(items)
-
- return len(items) > 0
+ return len(items) > 0
def get_selected_assets(self):
"""Add all selected items from the current scene"""
diff --git a/openpype/hosts/nuke/plugins/load/load_camera_abc.py b/openpype/hosts/nuke/plugins/load/load_camera_abc.py
index fec4ee556e..2939ceebae 100644
--- a/openpype/hosts/nuke/plugins/load/load_camera_abc.py
+++ b/openpype/hosts/nuke/plugins/load/load_camera_abc.py
@@ -112,8 +112,6 @@ class AlembicCameraLoader(load.LoaderPlugin):
version_doc = get_version_by_id(project_name, representation["parent"])
object_name = container['objectName']
- # get corresponding node
- camera_node = nuke.toNode(object_name)
# get main variables
version_data = version_doc.get("data", {})
diff --git a/openpype/hosts/nuke/plugins/publish/extract_review_data_lut.py b/openpype/hosts/nuke/plugins/publish/extract_review_data_lut.py
index e4b7b155cd..2a26ed82fb 100644
--- a/openpype/hosts/nuke/plugins/publish/extract_review_data_lut.py
+++ b/openpype/hosts/nuke/plugins/publish/extract_review_data_lut.py
@@ -20,7 +20,6 @@ class ExtractReviewDataLut(publish.Extractor):
hosts = ["nuke"]
def process(self, instance):
- families = instance.data["families"]
self.log.info("Creating staging dir...")
if "representations" in instance.data:
staging_dir = instance.data[
diff --git a/openpype/hosts/nuke/plugins/publish/extract_thumbnail.py b/openpype/hosts/nuke/plugins/publish/extract_thumbnail.py
index d57d55f85d..b20df4ffe2 100644
--- a/openpype/hosts/nuke/plugins/publish/extract_thumbnail.py
+++ b/openpype/hosts/nuke/plugins/publish/extract_thumbnail.py
@@ -91,8 +91,6 @@ class ExtractThumbnail(publish.Extractor):
if collection:
# get path
- fname = os.path.basename(collection.format(
- "{head}{padding}{tail}"))
fhead = collection.format("{head}")
thumb_fname = list(collection)[mid_frame]
diff --git a/openpype/hosts/photoshop/plugins/create/create_flatten_image.py b/openpype/hosts/photoshop/plugins/create/create_flatten_image.py
index 9d4189a1a3..e4229788bd 100644
--- a/openpype/hosts/photoshop/plugins/create/create_flatten_image.py
+++ b/openpype/hosts/photoshop/plugins/create/create_flatten_image.py
@@ -4,6 +4,7 @@ from openpype.lib import BoolDef
import openpype.hosts.photoshop.api as api
from openpype.hosts.photoshop.lib import PSAutoCreator
from openpype.pipeline.create import get_subset_name
+from openpype.lib import prepare_template_data
from openpype.client import get_asset_by_name
@@ -37,19 +38,14 @@ class AutoImageCreator(PSAutoCreator):
asset_doc = get_asset_by_name(project_name, asset_name)
if existing_instance is None:
- subset_name = get_subset_name(
- self.family, self.default_variant, task_name, asset_doc,
+ subset_name = self.get_subset_name(
+ self.default_variant, task_name, asset_doc,
project_name, host_name
)
- publishable_ids = [layer.id for layer in api.stub().get_layers()
- if layer.visible]
data = {
"asset": asset_name,
"task": task_name,
- # ids are "virtual" layers, won't get grouped as 'members' do
- # same difference in color coded layers in WP
- "ids": publishable_ids
}
if not self.active_on_create:
@@ -69,8 +65,8 @@ class AutoImageCreator(PSAutoCreator):
existing_instance["asset"] != asset_name
or existing_instance["task"] != task_name
):
- subset_name = get_subset_name(
- self.family, self.default_variant, task_name, asset_doc,
+ subset_name = self.get_subset_name(
+ self.default_variant, task_name, asset_doc,
project_name, host_name
)
@@ -118,3 +114,29 @@ class AutoImageCreator(PSAutoCreator):
Artist might disable this instance from publishing or from creating
review for it though.
"""
+
+ def get_subset_name(
+ self,
+ variant,
+ task_name,
+ asset_doc,
+ project_name,
+ host_name=None,
+ instance=None
+ ):
+ dynamic_data = prepare_template_data({"layer": "{layer}"})
+ subset_name = get_subset_name(
+ self.family, variant, task_name, asset_doc,
+ project_name, host_name, dynamic_data=dynamic_data
+ )
+ return self._clean_subset_name(subset_name)
+
+ def _clean_subset_name(self, subset_name):
+ """Clean all variants leftover {layer} from subset name."""
+ dynamic_data = prepare_template_data({"layer": "{layer}"})
+ for value in dynamic_data.values():
+ if value in subset_name:
+ return (subset_name.replace(value, "")
+ .replace("__", "_")
+ .replace("..", "."))
+ return subset_name
diff --git a/openpype/hosts/photoshop/plugins/create/create_image.py b/openpype/hosts/photoshop/plugins/create/create_image.py
index 8d3ac9f459..af20d456e0 100644
--- a/openpype/hosts/photoshop/plugins/create/create_image.py
+++ b/openpype/hosts/photoshop/plugins/create/create_image.py
@@ -94,12 +94,17 @@ class ImageCreator(Creator):
name = self._clean_highlights(stub, directory)
layer_names_in_hierarchy.append(name)
- data.update({"subset": subset_name})
- data.update({"members": [str(group.id)]})
- data.update({"layer_name": layer_name})
- data.update({"long_name": "_".join(layer_names_in_hierarchy)})
+ data_update = {
+ "subset": subset_name,
+ "members": [str(group.id)],
+ "layer_name": layer_name,
+ "long_name": "_".join(layer_names_in_hierarchy)
+ }
+ data.update(data_update)
- creator_attributes = {"mark_for_review": self.mark_for_review}
+ mark_for_review = (pre_create_data.get("mark_for_review") or
+ self.mark_for_review)
+ creator_attributes = {"mark_for_review": mark_for_review}
data.update({"creator_attributes": creator_attributes})
if not self.active_on_create:
@@ -124,8 +129,6 @@ class ImageCreator(Creator):
if creator_id == self.identifier:
instance_data = self._handle_legacy(instance_data)
- layer = api.stub().get_layer(instance_data["members"][0])
- instance_data["layer"] = layer
instance = CreatedInstance.from_existing(
instance_data, self
)
diff --git a/openpype/hosts/photoshop/plugins/publish/collect_auto_image.py b/openpype/hosts/photoshop/plugins/publish/collect_auto_image.py
index f1d8419608..77f1a3e91f 100644
--- a/openpype/hosts/photoshop/plugins/publish/collect_auto_image.py
+++ b/openpype/hosts/photoshop/plugins/publish/collect_auto_image.py
@@ -16,7 +16,6 @@ class CollectAutoImage(pyblish.api.ContextPlugin):
targets = ["automated"]
def process(self, context):
- family = "image"
for instance in context:
creator_identifier = instance.data.get("creator_identifier")
if creator_identifier and creator_identifier == "auto_image":
diff --git a/openpype/hosts/photoshop/plugins/publish/collect_auto_image_refresh.py b/openpype/hosts/photoshop/plugins/publish/collect_auto_image_refresh.py
new file mode 100644
index 0000000000..741fb0e9cd
--- /dev/null
+++ b/openpype/hosts/photoshop/plugins/publish/collect_auto_image_refresh.py
@@ -0,0 +1,24 @@
+import pyblish.api
+
+from openpype.hosts.photoshop import api as photoshop
+
+
+class CollectAutoImageRefresh(pyblish.api.ContextPlugin):
+ """Refreshes auto_image instance with currently visible layers..
+ """
+
+ label = "Collect Auto Image Refresh"
+ order = pyblish.api.CollectorOrder
+ hosts = ["photoshop"]
+ order = pyblish.api.CollectorOrder + 0.2
+
+ def process(self, context):
+ for instance in context:
+ creator_identifier = instance.data.get("creator_identifier")
+ if creator_identifier and creator_identifier == "auto_image":
+ self.log.debug("Auto image instance found, won't create new")
+ # refresh existing auto image instance with current visible
+ publishable_ids = [layer.id for layer in photoshop.stub().get_layers() # noqa
+ if layer.visible]
+ instance.data["ids"] = publishable_ids
+ return
diff --git a/openpype/hosts/photoshop/plugins/publish/collect_image.py b/openpype/hosts/photoshop/plugins/publish/collect_image.py
new file mode 100644
index 0000000000..64727cef33
--- /dev/null
+++ b/openpype/hosts/photoshop/plugins/publish/collect_image.py
@@ -0,0 +1,20 @@
+import pyblish.api
+
+from openpype.hosts.photoshop import api
+
+
+class CollectImage(pyblish.api.InstancePlugin):
+ """Collect layer metadata into a instance.
+
+ Used later in validation
+ """
+ order = pyblish.api.CollectorOrder + 0.200
+ label = 'Collect Image'
+
+ hosts = ["photoshop"]
+ families = ["image"]
+
+ def process(self, instance):
+ if instance.data.get("members"):
+ layer = api.stub().get_layer(instance.data["members"][0])
+ instance.data["layer"] = layer
diff --git a/openpype/hosts/photoshop/plugins/publish/extract_image.py b/openpype/hosts/photoshop/plugins/publish/extract_image.py
index cdb28c742d..680f580cc0 100644
--- a/openpype/hosts/photoshop/plugins/publish/extract_image.py
+++ b/openpype/hosts/photoshop/plugins/publish/extract_image.py
@@ -45,9 +45,11 @@ class ExtractImage(pyblish.api.ContextPlugin):
# Perform extraction
files = {}
ids = set()
- layer = instance.data.get("layer")
- if layer:
- ids.add(layer.id)
+ # real layers and groups
+ members = instance.data("members")
+ if members:
+ ids.update(set([int(member) for member in members]))
+ # virtual groups collected by color coding or auto_image
add_ids = instance.data.pop("ids", None)
if add_ids:
ids.update(set(add_ids))
diff --git a/openpype/hosts/photoshop/plugins/publish/extract_review.py b/openpype/hosts/photoshop/plugins/publish/extract_review.py
index 4aa7a05bd1..afddbdba31 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
from PIL import Image
from openpype.lib import (
@@ -55,6 +56,7 @@ class ExtractReview(publish.Extractor):
}
if instance.data["family"] != "review":
+ self.log.debug("Existing extracted file from image family used.")
# enable creation of review, without this jpg review would clash
# with jpg of the image family
output_name = repre_name
@@ -62,8 +64,15 @@ class ExtractReview(publish.Extractor):
repre_skeleton.update({"name": repre_name,
"outputName": output_name})
- if self.make_image_sequence and len(layers) > 1:
- self.log.info("Extract layers to image sequence.")
+ img_file = self.output_seq_filename % 0
+ self._prepare_file_for_image_family(img_file, instance,
+ staging_dir)
+ repre_skeleton.update({
+ "files": img_file,
+ })
+ processed_img_names = [img_file]
+ elif self.make_image_sequence and len(layers) > 1:
+ self.log.debug("Extract layers to image sequence.")
img_list = self._save_sequence_images(staging_dir, layers)
repre_skeleton.update({
@@ -72,17 +81,17 @@ class ExtractReview(publish.Extractor):
"fps": fps,
"files": img_list,
})
- instance.data["representations"].append(repre_skeleton)
processed_img_names = img_list
else:
- self.log.info("Extract layers to flatten image.")
- img_list = self._save_flatten_image(staging_dir, layers)
+ self.log.debug("Extract layers to flatten image.")
+ img_file = self._save_flatten_image(staging_dir, layers)
repre_skeleton.update({
- "files": img_list,
+ "files": img_file,
})
- instance.data["representations"].append(repre_skeleton)
- processed_img_names = [img_list]
+ processed_img_names = [img_file]
+
+ instance.data["representations"].append(repre_skeleton)
ffmpeg_args = get_ffmpeg_tool_args("ffmpeg")
@@ -111,6 +120,35 @@ class ExtractReview(publish.Extractor):
self.log.info(f"Extracted {instance} to {staging_dir}")
+ def _prepare_file_for_image_family(self, img_file, instance, staging_dir):
+ """Converts existing file for image family to .jpg
+
+ Image instance could have its own separate review (instance per layer
+ for example). This uses extracted file instead of extracting again.
+ Args:
+ img_file (str): name of output file (with 0000 value for ffmpeg
+ later)
+ instance:
+ staging_dir (str): temporary folder where extracted file is located
+ """
+ repre_file = instance.data["representations"][0]
+ source_file_path = os.path.join(repre_file["stagingDir"],
+ repre_file["files"])
+ if not os.path.exists(source_file_path):
+ raise RuntimeError(f"{source_file_path} doesn't exist for "
+ "review to create from")
+ _, ext = os.path.splitext(repre_file["files"])
+ if ext != ".jpg":
+ im = Image.open(source_file_path)
+ # without this it produces messy low quality jpg
+ rgb_im = Image.new("RGBA", (im.width, im.height), "#ffffff")
+ rgb_im.alpha_composite(im)
+ rgb_im.convert("RGB").save(os.path.join(staging_dir, img_file))
+ else:
+ # handles already .jpg
+ shutil.copy(source_file_path,
+ os.path.join(staging_dir, img_file))
+
def _generate_mov(self, ffmpeg_path, instance, fps, no_of_frames,
source_files_pattern, staging_dir):
"""Generates .mov to upload to Ftrack.
@@ -218,6 +256,11 @@ class ExtractReview(publish.Extractor):
(list) of PSItem
"""
layers = []
+ # creating review for existing 'image' instance
+ if instance.data["family"] == "image" and instance.data.get("layer"):
+ layers.append(instance.data["layer"])
+ return layers
+
for image_instance in instance.context:
if image_instance.data["family"] != "image":
continue
diff --git a/openpype/hosts/resolve/api/plugin.py b/openpype/hosts/resolve/api/plugin.py
index 59c27f29da..e2bd76ffa2 100644
--- a/openpype/hosts/resolve/api/plugin.py
+++ b/openpype/hosts/resolve/api/plugin.py
@@ -413,8 +413,6 @@ class ClipLoader:
if self.with_handles:
source_in -= handle_start
source_out += handle_end
- handle_start = 0
- handle_end = 0
# make track item from source in bin as item
timeline_item = lib.create_timeline_item(
@@ -433,14 +431,6 @@ class ClipLoader:
self.data["path"], self.active_bin)
_clip_property = media_pool_item.GetClipProperty
- # get handles
- handle_start = self.data["versionData"].get("handleStart")
- handle_end = self.data["versionData"].get("handleEnd")
- if handle_start is None:
- handle_start = int(self.data["assetData"]["handleStart"])
- if handle_end is None:
- handle_end = int(self.data["assetData"]["handleEnd"])
-
source_in = int(_clip_property("Start"))
source_out = int(_clip_property("End"))
diff --git a/openpype/hosts/standalonepublisher/plugins/publish/extract_thumbnail.py b/openpype/hosts/standalonepublisher/plugins/publish/extract_thumbnail.py
index b99503b3c8..a2afd160fa 100644
--- a/openpype/hosts/standalonepublisher/plugins/publish/extract_thumbnail.py
+++ b/openpype/hosts/standalonepublisher/plugins/publish/extract_thumbnail.py
@@ -49,8 +49,6 @@ class ExtractThumbnailSP(pyblish.api.InstancePlugin):
else:
first_filename = files
- staging_dir = None
-
# Convert to jpeg if not yet
full_input_path = os.path.join(
thumbnail_repre["stagingDir"], first_filename
diff --git a/openpype/hosts/traypublisher/plugins/publish/collect_frame_range_asset_entity.py b/openpype/hosts/traypublisher/plugins/publish/collect_missing_frame_range_asset_entity.py
similarity index 83%
rename from openpype/hosts/traypublisher/plugins/publish/collect_frame_range_asset_entity.py
rename to openpype/hosts/traypublisher/plugins/publish/collect_missing_frame_range_asset_entity.py
index c18e10e438..72379ea4e1 100644
--- a/openpype/hosts/traypublisher/plugins/publish/collect_frame_range_asset_entity.py
+++ b/openpype/hosts/traypublisher/plugins/publish/collect_missing_frame_range_asset_entity.py
@@ -2,16 +2,18 @@ import pyblish.api
from openpype.pipeline import OptionalPyblishPluginMixin
-class CollectFrameDataFromAssetEntity(pyblish.api.InstancePlugin,
- OptionalPyblishPluginMixin):
- """Collect Frame Range data From Asset Entity
+class CollectMissingFrameDataFromAssetEntity(
+ pyblish.api.InstancePlugin,
+ OptionalPyblishPluginMixin
+):
+ """Collect Missing Frame Range data From Asset Entity
Frame range data will only be collected if the keys
are not yet collected for the instance.
"""
order = pyblish.api.CollectorOrder + 0.491
- label = "Collect Frame Data From Asset Entity"
+ label = "Collect Missing Frame Data From Asset Entity"
families = ["plate", "pointcache",
"vdbcache", "online",
"render"]
diff --git a/openpype/hosts/traypublisher/plugins/publish/validate_frame_ranges.py b/openpype/hosts/traypublisher/plugins/publish/validate_frame_ranges.py
index b962ea464a..09de2d8db2 100644
--- a/openpype/hosts/traypublisher/plugins/publish/validate_frame_ranges.py
+++ b/openpype/hosts/traypublisher/plugins/publish/validate_frame_ranges.py
@@ -15,7 +15,7 @@ class ValidateFrameRange(OptionalPyblishPluginMixin,
label = "Validate Frame Range"
hosts = ["traypublisher"]
- families = ["render"]
+ families = ["render", "plate"]
order = ValidateContentsOrder
optional = True
diff --git a/openpype/hosts/tvpaint/api/communication_server.py b/openpype/hosts/tvpaint/api/communication_server.py
index 6f76c25e0c..d67ef8f798 100644
--- a/openpype/hosts/tvpaint/api/communication_server.py
+++ b/openpype/hosts/tvpaint/api/communication_server.py
@@ -11,7 +11,7 @@ import filecmp
import tempfile
import threading
import shutil
-from queue import Queue
+
from contextlib import closing
from aiohttp import web
@@ -319,19 +319,19 @@ class QtTVPaintRpc(BaseTVPaintRpc):
async def workfiles_tool(self):
log.info("Triggering Workfile tool")
item = MainThreadItem(self.tools_helper.show_workfiles)
- self._execute_in_main_thread(item)
+ self._execute_in_main_thread(item, wait=False)
return
async def loader_tool(self):
log.info("Triggering Loader tool")
item = MainThreadItem(self.tools_helper.show_loader)
- self._execute_in_main_thread(item)
+ self._execute_in_main_thread(item, wait=False)
return
async def publish_tool(self):
log.info("Triggering Publish tool")
item = MainThreadItem(self.tools_helper.show_publisher_tool)
- self._execute_in_main_thread(item)
+ self._execute_in_main_thread(item, wait=False)
return
async def scene_inventory_tool(self):
@@ -350,13 +350,13 @@ class QtTVPaintRpc(BaseTVPaintRpc):
async def library_loader_tool(self):
log.info("Triggering Library loader tool")
item = MainThreadItem(self.tools_helper.show_library_loader)
- self._execute_in_main_thread(item)
+ self._execute_in_main_thread(item, wait=False)
return
async def experimental_tools(self):
log.info("Triggering Library loader tool")
item = MainThreadItem(self.tools_helper.show_experimental_tools_dialog)
- self._execute_in_main_thread(item)
+ self._execute_in_main_thread(item, wait=False)
return
async def _async_execute_in_main_thread(self, item, **kwargs):
@@ -867,7 +867,7 @@ class QtCommunicator(BaseCommunicator):
def __init__(self, qt_app):
super().__init__()
- self.callback_queue = Queue()
+ self.callback_queue = collections.deque()
self.qt_app = qt_app
def _create_routes(self):
@@ -880,14 +880,14 @@ class QtCommunicator(BaseCommunicator):
def execute_in_main_thread(self, main_thread_item, wait=True):
"""Add `MainThreadItem` to callback queue and wait for result."""
- self.callback_queue.put(main_thread_item)
+ self.callback_queue.append(main_thread_item)
if wait:
return main_thread_item.wait()
return
async def async_execute_in_main_thread(self, main_thread_item, wait=True):
"""Add `MainThreadItem` to callback queue and wait for result."""
- self.callback_queue.put(main_thread_item)
+ self.callback_queue.append(main_thread_item)
if wait:
return await main_thread_item.async_wait()
@@ -904,9 +904,9 @@ class QtCommunicator(BaseCommunicator):
self._exit()
return None
- if self.callback_queue.empty():
- return None
- return self.callback_queue.get()
+ if self.callback_queue:
+ return self.callback_queue.popleft()
+ return None
def _on_client_connect(self):
super()._on_client_connect()
diff --git a/openpype/hosts/tvpaint/plugins/load/load_reference_image.py b/openpype/hosts/tvpaint/plugins/load/load_reference_image.py
index edc116a8e4..3707ef97aa 100644
--- a/openpype/hosts/tvpaint/plugins/load/load_reference_image.py
+++ b/openpype/hosts/tvpaint/plugins/load/load_reference_image.py
@@ -171,7 +171,7 @@ class LoadImage(plugin.Loader):
george_script = "\n".join(george_script_lines)
execute_george_through_file(george_script)
- def _remove_container(self, container, members=None):
+ def _remove_container(self, container):
if not container:
return
representation = container["representation"]
diff --git a/openpype/hosts/tvpaint/plugins/publish/extract_sequence.py b/openpype/hosts/tvpaint/plugins/publish/extract_sequence.py
index 8a610cf388..a13a91de46 100644
--- a/openpype/hosts/tvpaint/plugins/publish/extract_sequence.py
+++ b/openpype/hosts/tvpaint/plugins/publish/extract_sequence.py
@@ -63,7 +63,6 @@ class ExtractSequence(pyblish.api.Extractor):
"ignoreLayersTransparency", False
)
- family_lowered = instance.data["family"].lower()
mark_in = instance.context.data["sceneMarkIn"]
mark_out = instance.context.data["sceneMarkOut"]
@@ -76,11 +75,9 @@ class ExtractSequence(pyblish.api.Extractor):
# Frame start/end may be stored as float
frame_start = int(instance.data["frameStart"])
- frame_end = int(instance.data["frameEnd"])
# Handles are not stored per instance but on Context
handle_start = instance.context.data["handleStart"]
- handle_end = instance.context.data["handleEnd"]
scene_bg_color = instance.context.data["sceneBgColor"]
diff --git a/openpype/hosts/tvpaint/tvpaint_plugin/plugin_code/library.cpp b/openpype/hosts/tvpaint/tvpaint_plugin/plugin_code/library.cpp
index 88106bc770..ec45a45123 100644
--- a/openpype/hosts/tvpaint/tvpaint_plugin/plugin_code/library.cpp
+++ b/openpype/hosts/tvpaint/tvpaint_plugin/plugin_code/library.cpp
@@ -573,56 +573,6 @@ void FAR PASCAL PI_Close( PIFilter* iFilter )
}
-/**************************************************************************************/
-// we have something to do !
-
-int FAR PASCAL PI_Parameters( PIFilter* iFilter, char* iArg )
-{
- if( !iArg )
- {
-
- // If the requester is not open, we open it.
- if( Data.mReq == 0)
- {
- // Create empty requester because menu items are defined with
- // `define_menu` callback
- DWORD req = TVOpenFilterReqEx(
- iFilter,
- 185,
- 20,
- NULL,
- NULL,
- PIRF_STANDARD_REQ | PIRF_COLLAPSABLE_REQ,
- FILTERREQ_NO_TBAR
- );
- if( req == 0 )
- {
- TVWarning( iFilter, TXT_REQUESTER_ERROR );
- return 0;
- }
-
-
- Data.mReq = req;
- // This is a very simple requester, so we create it's content right here instead
- // of waiting for the PICBREQ_OPEN message...
- // Not recommended for more complex requesters. (see the other examples)
-
- // Sets the title of the requester.
- TVSetReqTitle( iFilter, Data.mReq, TXT_REQUESTER );
- // Request to listen to ticks
- TVGrabTicks(iFilter, req, PITICKS_FLAG_ON);
- }
- else
- {
- // If it is already open, we just put it on front of all other requesters.
- TVReqToFront( iFilter, Data.mReq );
- }
- }
-
- return 1;
-}
-
-
int newMenuItemsProcess(PIFilter* iFilter) {
// Menu items defined with `define_menu` should be propagated.
@@ -702,6 +652,62 @@ int newMenuItemsProcess(PIFilter* iFilter) {
return 1;
}
+
+/**************************************************************************************/
+// we have something to do !
+
+int FAR PASCAL PI_Parameters( PIFilter* iFilter, char* iArg )
+{
+ if( !iArg )
+ {
+
+ // If the requester is not open, we open it.
+ if( Data.mReq == 0)
+ {
+ // Create empty requester because menu items are defined with
+ // `define_menu` callback
+ DWORD req = TVOpenFilterReqEx(
+ iFilter,
+ 185,
+ 20,
+ NULL,
+ NULL,
+ PIRF_STANDARD_REQ | PIRF_COLLAPSABLE_REQ,
+ FILTERREQ_NO_TBAR
+ );
+ if( req == 0 )
+ {
+ TVWarning( iFilter, TXT_REQUESTER_ERROR );
+ return 0;
+ }
+
+ Data.mReq = req;
+
+ // This is a very simple requester, so we create it's content right here instead
+ // of waiting for the PICBREQ_OPEN message...
+ // Not recommended for more complex requesters. (see the other examples)
+
+ // Sets the title of the requester.
+ TVSetReqTitle( iFilter, Data.mReq, TXT_REQUESTER );
+ // Request to listen to ticks
+ TVGrabTicks(iFilter, req, PITICKS_FLAG_ON);
+
+ if ( Data.firstParams == true ) {
+ Data.firstParams = false;
+ } else {
+ newMenuItemsProcess(iFilter);
+ }
+ }
+ else
+ {
+ // If it is already open, we just put it on front of all other requesters.
+ TVReqToFront( iFilter, Data.mReq );
+ }
+ }
+
+ return 1;
+}
+
/**************************************************************************************/
// something happened that needs our attention.
// Global variable where current button up data are stored
diff --git a/openpype/hosts/tvpaint/tvpaint_plugin/plugin_files/windows_x64/plugin/OpenPypePlugin.dll b/openpype/hosts/tvpaint/tvpaint_plugin/plugin_files/windows_x64/plugin/OpenPypePlugin.dll
index 7081778bee..9c6e969e24 100644
Binary files a/openpype/hosts/tvpaint/tvpaint_plugin/plugin_files/windows_x64/plugin/OpenPypePlugin.dll and b/openpype/hosts/tvpaint/tvpaint_plugin/plugin_files/windows_x64/plugin/OpenPypePlugin.dll differ
diff --git a/openpype/hosts/tvpaint/tvpaint_plugin/plugin_files/windows_x86/plugin/OpenPypePlugin.dll b/openpype/hosts/tvpaint/tvpaint_plugin/plugin_files/windows_x86/plugin/OpenPypePlugin.dll
index 0f2afec245..b573476a21 100644
Binary files a/openpype/hosts/tvpaint/tvpaint_plugin/plugin_files/windows_x86/plugin/OpenPypePlugin.dll and b/openpype/hosts/tvpaint/tvpaint_plugin/plugin_files/windows_x86/plugin/OpenPypePlugin.dll differ
diff --git a/openpype/hosts/unreal/plugins/publish/extract_uasset.py b/openpype/hosts/unreal/plugins/publish/extract_uasset.py
index 48b62faa97..0dd7ff4a0d 100644
--- a/openpype/hosts/unreal/plugins/publish/extract_uasset.py
+++ b/openpype/hosts/unreal/plugins/publish/extract_uasset.py
@@ -19,9 +19,8 @@ class ExtractUAsset(publish.Extractor):
"umap" if "umap" in instance.data.get("families") else "uasset")
ar = unreal.AssetRegistryHelpers.get_asset_registry()
- self.log.info("Performing extraction..")
+ self.log.debug("Performing extraction..")
staging_dir = self.staging_dir(instance)
- filename = f"{instance.name}.{extension}"
members = instance.data.get("members", [])
diff --git a/openpype/lib/transcoding.py b/openpype/lib/transcoding.py
index 2bae28786e..6e323f55c1 100644
--- a/openpype/lib/transcoding.py
+++ b/openpype/lib/transcoding.py
@@ -724,7 +724,7 @@ def get_ffprobe_data(path_to_file, logger=None):
"""
if not logger:
logger = logging.getLogger(__name__)
- logger.info(
+ logger.debug(
"Getting information about input \"{}\".".format(path_to_file)
)
ffprobe_args = get_ffmpeg_tool_args("ffprobe")
diff --git a/openpype/modules/ftrack/plugins/publish/integrate_ftrack_api.py b/openpype/modules/ftrack/plugins/publish/integrate_ftrack_api.py
index 4d474fab10..858c0bb2d6 100644
--- a/openpype/modules/ftrack/plugins/publish/integrate_ftrack_api.py
+++ b/openpype/modules/ftrack/plugins/publish/integrate_ftrack_api.py
@@ -27,8 +27,8 @@ class IntegrateFtrackApi(pyblish.api.InstancePlugin):
def process(self, instance):
component_list = instance.data.get("ftrackComponentsList")
if not component_list:
- self.log.info(
- "Instance don't have components to integrate to Ftrack."
+ self.log.debug(
+ "Instance doesn't have components to integrate to Ftrack."
" Skipping."
)
return
@@ -37,7 +37,7 @@ class IntegrateFtrackApi(pyblish.api.InstancePlugin):
task_entity, parent_entity = self.get_instance_entities(
instance, context)
if parent_entity is None:
- self.log.info((
+ self.log.debug((
"Skipping ftrack integration. Instance \"{}\" does not"
" have specified ftrack entities."
).format(str(instance)))
@@ -323,7 +323,7 @@ class IntegrateFtrackApi(pyblish.api.InstancePlugin):
"type_id": asset_type_id,
"context_id": parent_id
}
- self.log.info("Created new Asset with data: {}.".format(asset_data))
+ self.log.debug("Created new Asset with data: {}.".format(asset_data))
session.create("Asset", asset_data)
session.commit()
return self._query_asset(session, asset_name, asset_type_id, parent_id)
@@ -384,7 +384,7 @@ class IntegrateFtrackApi(pyblish.api.InstancePlugin):
if comment:
new_asset_version_data["comment"] = comment
- self.log.info("Created new AssetVersion with data {}".format(
+ self.log.debug("Created new AssetVersion with data {}".format(
new_asset_version_data
))
session.create("AssetVersion", new_asset_version_data)
@@ -555,7 +555,7 @@ class IntegrateFtrackApi(pyblish.api.InstancePlugin):
location=location
)
data["component"] = component_entity
- self.log.info(
+ self.log.debug(
(
"Created new Component with path: {0}, data: {1},"
" metadata: {2}, location: {3}"
diff --git a/openpype/modules/ftrack/plugins/publish/integrate_ftrack_description.py b/openpype/modules/ftrack/plugins/publish/integrate_ftrack_description.py
index 6ed02bc8b6..ceaff8ff54 100644
--- a/openpype/modules/ftrack/plugins/publish/integrate_ftrack_description.py
+++ b/openpype/modules/ftrack/plugins/publish/integrate_ftrack_description.py
@@ -40,7 +40,7 @@ class IntegrateFtrackDescription(pyblish.api.InstancePlugin):
comment = instance.data["comment"]
if not comment:
- self.log.info("Comment is not set.")
+ self.log.debug("Comment is not set.")
else:
self.log.debug("Comment is set to `{}`".format(comment))
diff --git a/openpype/modules/ftrack/plugins/publish/integrate_ftrack_note.py b/openpype/modules/ftrack/plugins/publish/integrate_ftrack_note.py
index 6e82897d89..10b7932cdf 100644
--- a/openpype/modules/ftrack/plugins/publish/integrate_ftrack_note.py
+++ b/openpype/modules/ftrack/plugins/publish/integrate_ftrack_note.py
@@ -47,7 +47,7 @@ class IntegrateFtrackNote(pyblish.api.InstancePlugin):
app_label = context.data["appLabel"]
comment = instance.data["comment"]
if not comment:
- self.log.info("Comment is not set.")
+ self.log.debug("Comment is not set.")
else:
self.log.debug("Comment is set to `{}`".format(comment))
@@ -127,14 +127,14 @@ class IntegrateFtrackNote(pyblish.api.InstancePlugin):
note_text = StringTemplate.format_template(template, format_data)
if not note_text.solved:
- self.log.warning((
+ self.log.debug((
"Note template require more keys then can be provided."
"\nTemplate: {}\nMissing values for keys:{}\nData: {}"
).format(template, note_text.missing_keys, format_data))
continue
if not note_text:
- self.log.info((
+ self.log.debug((
"Note for AssetVersion {} would be empty. Skipping."
"\nTemplate: {}\nData: {}"
).format(asset_version["id"], template, format_data))
diff --git a/openpype/modules/kitsu/plugins/publish/integrate_kitsu_note.py b/openpype/modules/kitsu/plugins/publish/integrate_kitsu_note.py
index 6e5dd056f3..b66e1f01e0 100644
--- a/openpype/modules/kitsu/plugins/publish/integrate_kitsu_note.py
+++ b/openpype/modules/kitsu/plugins/publish/integrate_kitsu_note.py
@@ -121,7 +121,7 @@ class IntegrateKitsuNote(pyblish.api.ContextPlugin):
publish_comment = self.format_publish_comment(instance)
if not publish_comment:
- self.log.info("Comment is not set.")
+ self.log.debug("Comment is not set.")
else:
self.log.debug("Comment is `{}`".format(publish_comment))
diff --git a/openpype/pipeline/colorspace.py b/openpype/pipeline/colorspace.py
index 731132911a..44e5cb6c47 100644
--- a/openpype/pipeline/colorspace.py
+++ b/openpype/pipeline/colorspace.py
@@ -13,12 +13,17 @@ from openpype.lib import (
Logger
)
from openpype.pipeline import Anatomy
+from openpype.lib.transcoding import VIDEO_EXTENSIONS, IMAGE_EXTENSIONS
+
log = Logger.get_logger(__name__)
-class CashedData:
- remapping = None
+class CachedData:
+ remapping = {}
+ allowed_exts = {
+ ext.lstrip(".") for ext in IMAGE_EXTENSIONS.union(VIDEO_EXTENSIONS)
+ }
@contextlib.contextmanager
@@ -546,15 +551,15 @@ def get_remapped_colorspace_to_native(
Union[str, None]: native colorspace name defined in remapping or None
"""
- CashedData.remapping.setdefault(host_name, {})
- if CashedData.remapping[host_name].get("to_native") is None:
+ CachedData.remapping.setdefault(host_name, {})
+ if CachedData.remapping[host_name].get("to_native") is None:
remapping_rules = imageio_host_settings["remapping"]["rules"]
- CashedData.remapping[host_name]["to_native"] = {
+ CachedData.remapping[host_name]["to_native"] = {
rule["ocio_name"]: rule["host_native_name"]
for rule in remapping_rules
}
- return CashedData.remapping[host_name]["to_native"].get(
+ return CachedData.remapping[host_name]["to_native"].get(
ocio_colorspace_name)
@@ -572,15 +577,15 @@ def get_remapped_colorspace_from_native(
Union[str, None]: Ocio colorspace name defined in remapping or None.
"""
- CashedData.remapping.setdefault(host_name, {})
- if CashedData.remapping[host_name].get("from_native") is None:
+ CachedData.remapping.setdefault(host_name, {})
+ if CachedData.remapping[host_name].get("from_native") is None:
remapping_rules = imageio_host_settings["remapping"]["rules"]
- CashedData.remapping[host_name]["from_native"] = {
+ CachedData.remapping[host_name]["from_native"] = {
rule["host_native_name"]: rule["ocio_name"]
for rule in remapping_rules
}
- return CashedData.remapping[host_name]["from_native"].get(
+ return CachedData.remapping[host_name]["from_native"].get(
host_native_colorspace_name)
@@ -601,3 +606,173 @@ def _get_imageio_settings(project_settings, host_name):
imageio_host = project_settings.get(host_name, {}).get("imageio", {})
return imageio_global, imageio_host
+
+
+def get_colorspace_settings_from_publish_context(context_data):
+ """Returns solved settings for the host context.
+
+ Args:
+ context_data (publish.Context.data): publishing context data
+
+ Returns:
+ tuple | bool: config, file rules or None
+ """
+ if "imageioSettings" in context_data and context_data["imageioSettings"]:
+ return context_data["imageioSettings"]
+
+ project_name = context_data["projectName"]
+ host_name = context_data["hostName"]
+ anatomy_data = context_data["anatomyData"]
+ project_settings_ = context_data["project_settings"]
+
+ config_data = get_imageio_config(
+ project_name, host_name,
+ project_settings=project_settings_,
+ anatomy_data=anatomy_data
+ )
+
+ # caching invalid state, so it's not recalculated all the time
+ file_rules = None
+ if config_data:
+ file_rules = get_imageio_file_rules(
+ project_name, host_name,
+ project_settings=project_settings_
+ )
+
+ # caching settings for future instance processing
+ context_data["imageioSettings"] = (config_data, file_rules)
+
+ return config_data, file_rules
+
+
+def set_colorspace_data_to_representation(
+ representation, context_data,
+ colorspace=None,
+ log=None
+):
+ """Sets colorspace data to representation.
+
+ Args:
+ representation (dict): publishing representation
+ context_data (publish.Context.data): publishing context data
+ colorspace (str, optional): colorspace name. Defaults to None.
+ log (logging.Logger, optional): logger instance. Defaults to None.
+
+ Example:
+ ```
+ {
+ # for other publish plugins and loaders
+ "colorspace": "linear",
+ "config": {
+ # for future references in case need
+ "path": "/abs/path/to/config.ocio",
+ # for other plugins within remote publish cases
+ "template": "{project[root]}/path/to/config.ocio"
+ }
+ }
+ ```
+
+ """
+ log = log or Logger.get_logger(__name__)
+
+ file_ext = representation["ext"]
+
+ # check if `file_ext` in lower case is in CachedData.allowed_exts
+ if file_ext.lstrip(".").lower() not in CachedData.allowed_exts:
+ log.debug(
+ "Extension '{}' is not in allowed extensions.".format(file_ext)
+ )
+ return
+
+ # get colorspace settings
+ config_data, file_rules = get_colorspace_settings_from_publish_context(
+ context_data)
+
+ # in case host color management is not enabled
+ if not config_data:
+ log.warning("Host's colorspace management is disabled.")
+ return
+
+ log.debug("Config data is: `{}`".format(config_data))
+
+ project_name = context_data["projectName"]
+ host_name = context_data["hostName"]
+ project_settings = context_data["project_settings"]
+
+ # get one filename
+ filename = representation["files"]
+ if isinstance(filename, list):
+ filename = filename[0]
+
+ # get matching colorspace from rules
+ colorspace = colorspace or get_imageio_colorspace_from_filepath(
+ filename, host_name, project_name,
+ config_data=config_data,
+ file_rules=file_rules,
+ project_settings=project_settings
+ )
+
+ # infuse data to representation
+ if colorspace:
+ colorspace_data = {
+ "colorspace": colorspace,
+ "config": config_data
+ }
+
+ # update data key
+ representation["colorspaceData"] = colorspace_data
+
+
+def get_display_view_colorspace_name(config_path, display, view):
+ """Returns the colorspace attribute of the (display, view) pair.
+
+ Args:
+ config_path (str): path string leading to config.ocio
+ display (str): display name e.g. "ACES"
+ view (str): view name e.g. "sRGB"
+
+ Returns:
+ view color space name (str) e.g. "Output - sRGB"
+ """
+
+ if not compatibility_check():
+ # python environment is not compatible with PyOpenColorIO
+ # needs to be run in subprocess
+ return get_display_view_colorspace_subprocess(config_path,
+ display, view)
+
+ from openpype.scripts.ocio_wrapper import _get_display_view_colorspace_name # noqa
+
+ return _get_display_view_colorspace_name(config_path, display, view)
+
+
+def get_display_view_colorspace_subprocess(config_path, display, view):
+ """Returns the colorspace attribute of the (display, view) pair
+ via subprocess.
+
+ Args:
+ config_path (str): path string leading to config.ocio
+ display (str): display name e.g. "ACES"
+ view (str): view name e.g. "sRGB"
+
+ Returns:
+ view color space name (str) e.g. "Output - sRGB"
+ """
+
+ with _make_temp_json_file() as tmp_json_path:
+ # Prepare subprocess arguments
+ args = [
+ "run", get_ocio_config_script_path(),
+ "config", "get_display_view_colorspace_name",
+ "--in_path", config_path,
+ "--out_path", tmp_json_path,
+ "--display", display,
+ "--view", view
+ ]
+ log.debug("Executing: {}".format(" ".join(args)))
+
+ run_openpype_process(*args, logger=log)
+
+ # return default view colorspace name
+ with open(tmp_json_path, "r") as f:
+ return json.load(f)
diff --git a/openpype/pipeline/load/plugins.py b/openpype/pipeline/load/plugins.py
index f87fb3312d..8acfcfdb6c 100644
--- a/openpype/pipeline/load/plugins.py
+++ b/openpype/pipeline/load/plugins.py
@@ -234,6 +234,19 @@ class LoaderPlugin(list):
"""
return cls.options or []
+ @property
+ def fname(self):
+ """Backwards compatibility with deprecation warning"""
+
+ self.log.warning((
+ "DEPRECATION WARNING: Source - Loader plugin {}."
+ " The 'fname' property on the Loader plugin will be removed in"
+ " future versions of OpenPype. Planned version to drop the support"
+ " is 3.16.6 or 3.17.0."
+ ).format(self.__class__.__name__))
+ if hasattr(self, "_fname"):
+ return self._fname
+
class SubsetLoaderPlugin(LoaderPlugin):
"""Load subset into host application
diff --git a/openpype/pipeline/load/utils.py b/openpype/pipeline/load/utils.py
index 42418be40e..b10d6032b3 100644
--- a/openpype/pipeline/load/utils.py
+++ b/openpype/pipeline/load/utils.py
@@ -318,7 +318,8 @@ def load_with_repre_context(
# Backwards compatibility: Originally the loader's __init__ required the
# representation context to set `fname` attribute to the filename to load
- loader.fname = get_representation_path_from_context(repre_context)
+ # Deprecated - to be removed in OpenPype 3.16.6 or 3.17.0.
+ loader._fname = get_representation_path_from_context(repre_context)
return loader.load(repre_context, name, namespace, options)
diff --git a/openpype/pipeline/publish/publish_plugins.py b/openpype/pipeline/publish/publish_plugins.py
index ba3be6397e..ae6cbc42d1 100644
--- a/openpype/pipeline/publish/publish_plugins.py
+++ b/openpype/pipeline/publish/publish_plugins.py
@@ -1,6 +1,5 @@
import inspect
from abc import ABCMeta
-from pprint import pformat
import pyblish.api
from pyblish.plugin import MetaPlugin, ExplicitMetaPlugin
from openpype.lib.transcoding import VIDEO_EXTENSIONS, IMAGE_EXTENSIONS
@@ -14,9 +13,8 @@ from .lib import (
)
from openpype.pipeline.colorspace import (
- get_imageio_colorspace_from_filepath,
- get_imageio_config,
- get_imageio_file_rules
+ get_colorspace_settings_from_publish_context,
+ set_colorspace_data_to_representation
)
@@ -306,12 +304,8 @@ class ColormanagedPyblishPluginMixin(object):
matching colorspace from rules. Finally, it infuses this
data into the representation.
"""
- allowed_ext = set(
- ext.lstrip(".") for ext in IMAGE_EXTENSIONS.union(VIDEO_EXTENSIONS)
- )
- @staticmethod
- def get_colorspace_settings(context):
+ def get_colorspace_settings(self, context):
"""Returns solved settings for the host context.
Args:
@@ -320,50 +314,18 @@ class ColormanagedPyblishPluginMixin(object):
Returns:
tuple | bool: config, file rules or None
"""
- if "imageioSettings" in context.data:
- return context.data["imageioSettings"]
-
- project_name = context.data["projectName"]
- host_name = context.data["hostName"]
- anatomy_data = context.data["anatomyData"]
- project_settings_ = context.data["project_settings"]
-
- config_data = get_imageio_config(
- project_name, host_name,
- project_settings=project_settings_,
- anatomy_data=anatomy_data
- )
-
- # in case host color management is not enabled
- if not config_data:
- return None
-
- file_rules = get_imageio_file_rules(
- project_name, host_name,
- project_settings=project_settings_
- )
-
- # caching settings for future instance processing
- context.data["imageioSettings"] = (config_data, file_rules)
-
- return config_data, file_rules
+ return get_colorspace_settings_from_publish_context(context.data)
def set_representation_colorspace(
self, representation, context,
colorspace=None,
- colorspace_settings=None
):
"""Sets colorspace data to representation.
Args:
representation (dict): publishing representation
context (publish.Context): publishing context
- config_data (dict): host resolved config data
- file_rules (dict): host resolved file rules data
colorspace (str, optional): colorspace name. Defaults to None.
- colorspace_settings (tuple[dict, dict], optional):
- Settings for config_data and file_rules.
- Defaults to None.
Example:
```
@@ -380,64 +342,10 @@ class ColormanagedPyblishPluginMixin(object):
```
"""
- ext = representation["ext"]
- # check extension
- self.log.debug("__ ext: `{}`".format(ext))
- # check if ext in lower case is in self.allowed_ext
- if ext.lstrip(".").lower() not in self.allowed_ext:
- self.log.debug(
- "Extension '{}' is not in allowed extensions.".format(ext)
- )
- return
-
- if colorspace_settings is None:
- colorspace_settings = self.get_colorspace_settings(context)
-
- # in case host color management is not enabled
- if not colorspace_settings:
- self.log.warning("Host's colorspace management is disabled.")
- return
-
- # unpack colorspace settings
- config_data, file_rules = colorspace_settings
-
- if not config_data:
- # warn in case no colorspace path was defined
- self.log.warning("No colorspace management was defined")
- return
-
- self.log.debug("Config data is: `{}`".format(config_data))
-
- project_name = context.data["projectName"]
- host_name = context.data["hostName"]
- project_settings = context.data["project_settings"]
-
- # get one filename
- filename = representation["files"]
- if isinstance(filename, list):
- filename = filename[0]
-
- self.log.debug("__ filename: `{}`".format(filename))
-
- # get matching colorspace from rules
- colorspace = colorspace or get_imageio_colorspace_from_filepath(
- filename, host_name, project_name,
- config_data=config_data,
- file_rules=file_rules,
- project_settings=project_settings
+ # using cached settings if available
+ set_colorspace_data_to_representation(
+ representation, context.data,
+ colorspace,
+ log=self.log
)
- self.log.debug("__ colorspace: `{}`".format(colorspace))
-
- # infuse data to representation
- if colorspace:
- colorspace_data = {
- "colorspace": colorspace,
- "config": config_data
- }
-
- # update data key
- representation["colorspaceData"] = colorspace_data
-
- self.log.debug("__ colorspace_data: `{}`".format(
- pformat(colorspace_data)))
diff --git a/openpype/plugins/publish/collect_sequence_frame_data.py b/openpype/plugins/publish/collect_sequence_frame_data.py
index c200b245e9..6c2bfbf358 100644
--- a/openpype/plugins/publish/collect_sequence_frame_data.py
+++ b/openpype/plugins/publish/collect_sequence_frame_data.py
@@ -50,4 +50,7 @@ class CollectSequenceFrameData(pyblish.api.InstancePlugin):
return {
"frameStart": repres_frames[0],
"frameEnd": repres_frames[-1],
+ "handleStart": 0,
+ "handleEnd": 0,
+ "fps": instance.context.data["assetEntity"]["data"]["fps"]
}
diff --git a/openpype/scripts/ocio_wrapper.py b/openpype/scripts/ocio_wrapper.py
index 16558642c6..40553d30f2 100644
--- a/openpype/scripts/ocio_wrapper.py
+++ b/openpype/scripts/ocio_wrapper.py
@@ -174,5 +174,79 @@ def _get_views_data(config_path):
return data
+def _get_display_view_colorspace_name(config_path, display, view):
+ """Returns the colorspace attribute of the (display, view) pair.
+
+ Args:
+ config_path (str): path string leading to config.ocio
+ display (str): display name e.g. "ACES"
+ view (str): view name e.g. "sRGB"
+
+
+ Raises:
+ IOError: Input config does not exist.
+
+ Returns:
+ view color space name (str) e.g. "Output - sRGB"
+ """
+
+ config_path = Path(config_path)
+
+ if not config_path.is_file():
+ raise IOError("Input path should be `config.ocio` file")
+
+ config = ocio.Config.CreateFromFile(str(config_path))
+ colorspace = config.getDisplayViewColorSpaceName(display, view)
+
+ return colorspace
+
+
+@config.command(
+ name="get_display_view_colorspace_name",
+ help=(
+ "return default view colorspace name "
+ "for the given display and view "
+ "--path input arg is required"
+ )
+)
+@click.option("--in_path", required=True,
+ help="path where to read ocio config file",
+ type=click.Path(exists=True))
+@click.option("--out_path", required=True,
+ help="path where to write output json file",
+ type=click.Path())
+@click.option("--display", required=True,
+ help="display name",
+ type=click.STRING)
+@click.option("--view", required=True,
+ help="view name",
+ type=click.STRING)
+def get_display_view_colorspace_name(in_path, out_path,
+ display, view):
+ """Aggregate view colorspace name to file.
+
+ Wrapper command for processes without access to OpenColorIO
+
+ Args:
+ in_path (str): config file path string
+ out_path (str): temp json file path string
+ display (str): display name e.g. "ACES"
+ view (str): view name e.g. "sRGB"
+
+ Example of use:
+ > pyton.exe ./ocio_wrapper.py config \
+ get_display_view_colorspace_name --in_path= \
+ --out_path= --display= --view=
+ """
+
+ out_data = _get_display_view_colorspace_name(in_path,
+ display,
+ view)
+
+ with open(out_path, "w") as f:
+ json.dump(out_data, f)
+
+ print(f"Display view colorspace saved to '{out_path}'")
+
if __name__ == '__main__':
main()
diff --git a/openpype/settings/defaults/project_settings/houdini.json b/openpype/settings/defaults/project_settings/houdini.json
index 9d047c28bd..93d5c50d5e 100644
--- a/openpype/settings/defaults/project_settings/houdini.json
+++ b/openpype/settings/defaults/project_settings/houdini.json
@@ -93,6 +93,11 @@
"$JOB"
]
},
+ "ValidateReviewColorspace": {
+ "enabled": true,
+ "optional": true,
+ "active": true
+ },
"ValidateContainers": {
"enabled": true,
"optional": true,
diff --git a/openpype/settings/defaults/project_settings/nuke.json b/openpype/settings/defaults/project_settings/nuke.json
index b736c462ff..7961e77113 100644
--- a/openpype/settings/defaults/project_settings/nuke.json
+++ b/openpype/settings/defaults/project_settings/nuke.json
@@ -28,11 +28,7 @@
"colorManagement": "Nuke",
"OCIO_config": "nuke-default",
"workingSpaceLUT": "linear",
- "monitorLut": "sRGB",
- "int8Lut": "sRGB",
- "int16Lut": "sRGB",
- "logLut": "Cineon",
- "floatLut": "linear"
+ "monitorLut": "sRGB"
},
"nodes": {
"requiredNodes": [
diff --git a/openpype/settings/entities/schemas/projects_schema/schemas/schema_houdini_publish.json b/openpype/settings/entities/schemas/projects_schema/schemas/schema_houdini_publish.json
index aa6eaf5164..b57089007e 100644
--- a/openpype/settings/entities/schemas/projects_schema/schemas/schema_houdini_publish.json
+++ b/openpype/settings/entities/schemas/projects_schema/schemas/schema_houdini_publish.json
@@ -40,6 +40,10 @@
"type": "schema_template",
"name": "template_publish_plugin",
"template_data": [
+ {
+ "key": "ValidateReviewColorspace",
+ "label": "Validate Review Colorspace"
+ },
{
"key": "ValidateContainers",
"label": "ValidateContainers"
@@ -47,4 +51,4 @@
]
}
]
-}
\ No newline at end of file
+}
diff --git a/openpype/settings/entities/schemas/projects_schema/schemas/schema_nuke_imageio.json b/openpype/settings/entities/schemas/projects_schema/schemas/schema_nuke_imageio.json
index d4cd332ef8..af826fcf46 100644
--- a/openpype/settings/entities/schemas/projects_schema/schemas/schema_nuke_imageio.json
+++ b/openpype/settings/entities/schemas/projects_schema/schemas/schema_nuke_imageio.json
@@ -106,26 +106,6 @@
"type": "text",
"key": "monitorLut",
"label": "monitor"
- },
- {
- "type": "text",
- "key": "int8Lut",
- "label": "8-bit files"
- },
- {
- "type": "text",
- "key": "int16Lut",
- "label": "16-bit files"
- },
- {
- "type": "text",
- "key": "logLut",
- "label": "log files"
- },
- {
- "type": "text",
- "key": "floatLut",
- "label": "float files"
}
]
}
diff --git a/openpype/style/style.css b/openpype/style/style.css
index 5ce55aa658..ca368f84f8 100644
--- a/openpype/style/style.css
+++ b/openpype/style/style.css
@@ -1427,6 +1427,10 @@ CreateNextPageOverlay {
background: rgba(0, 0, 0, 127);
}
+#OverlayFrameLabel {
+ font-size: 15pt;
+}
+
#BreadcrumbsPathInput {
padding: 2px;
font-size: 9pt;
diff --git a/openpype/tools/ayon_workfiles/__init__.py b/openpype/tools/ayon_workfiles/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/openpype/tools/ayon_workfiles/abstract.py b/openpype/tools/ayon_workfiles/abstract.py
new file mode 100644
index 0000000000..e30a2c2499
--- /dev/null
+++ b/openpype/tools/ayon_workfiles/abstract.py
@@ -0,0 +1,984 @@
+import os
+from abc import ABCMeta, abstractmethod
+
+import six
+from openpype.style import get_default_entity_icon_color
+
+
+class WorkfileInfo:
+ """Information about workarea file with possible additional from database.
+
+ Args:
+ folder_id (str): Folder id.
+ task_id (str): Task id.
+ filepath (str): Filepath.
+ filesize (int): File size.
+ creation_time (int): Creation time (timestamp).
+ modification_time (int): Modification time (timestamp).
+ note (str): Note.
+ """
+
+ def __init__(
+ self,
+ folder_id,
+ task_id,
+ filepath,
+ filesize,
+ creation_time,
+ modification_time,
+ note,
+ ):
+ self.folder_id = folder_id
+ self.task_id = task_id
+ self.filepath = filepath
+ self.filesize = filesize
+ self.creation_time = creation_time
+ self.modification_time = modification_time
+ self.note = note
+
+ def to_data(self):
+ """Converts WorkfileInfo item to data.
+
+ Returns:
+ dict[str, Any]: Folder item data.
+ """
+
+ return {
+ "folder_id": self.folder_id,
+ "task_id": self.task_id,
+ "filepath": self.filepath,
+ "filesize": self.filesize,
+ "creation_time": self.creation_time,
+ "modification_time": self.modification_time,
+ "note": self.note,
+ }
+
+ @classmethod
+ def from_data(cls, data):
+ """Re-creates WorkfileInfo item from data.
+
+ Args:
+ data (dict[str, Any]): Workfile info item data.
+
+ Returns:
+ WorkfileInfo: Workfile info item.
+ """
+
+ return cls(**data)
+
+
+class FolderItem:
+ """Item representing folder entity on a server.
+
+ Folder can be a child of another folder or a project.
+
+ Args:
+ entity_id (str): Folder id.
+ parent_id (Union[str, None]): Parent folder id. If 'None' then project
+ is parent.
+ name (str): Name of folder.
+ label (str): Folder label.
+ icon_name (str): Name of icon from font awesome.
+ icon_color (str): Hex color string that will be used for icon.
+ """
+
+ def __init__(
+ self, entity_id, parent_id, name, label, icon_name, icon_color
+ ):
+ self.entity_id = entity_id
+ self.parent_id = parent_id
+ self.name = name
+ self.icon_name = icon_name or "fa.folder"
+ self.icon_color = icon_color or get_default_entity_icon_color()
+ self.label = label or name
+
+ def to_data(self):
+ """Converts folder item to data.
+
+ Returns:
+ dict[str, Any]: Folder item data.
+ """
+
+ return {
+ "entity_id": self.entity_id,
+ "parent_id": self.parent_id,
+ "name": self.name,
+ "label": self.label,
+ "icon_name": self.icon_name,
+ "icon_color": self.icon_color,
+ }
+
+ @classmethod
+ def from_data(cls, data):
+ """Re-creates folder item from data.
+
+ Args:
+ data (dict[str, Any]): Folder item data.
+
+ Returns:
+ FolderItem: Folder item.
+ """
+
+ return cls(**data)
+
+
+class TaskItem:
+ """Task item representing task entity on a server.
+
+ Task is child of a folder.
+
+ Task item has label that is used for display in UI. The label is by
+ default using task name and type.
+
+ Args:
+ task_id (str): Task id.
+ name (str): Name of task.
+ task_type (str): Type of task.
+ parent_id (str): Parent folder id.
+ icon_name (str): Name of icon from font awesome.
+ icon_color (str): Hex color string that will be used for icon.
+ """
+
+ def __init__(
+ self, task_id, name, task_type, parent_id, icon_name, icon_color
+ ):
+ self.task_id = task_id
+ self.name = name
+ self.task_type = task_type
+ self.parent_id = parent_id
+ self.icon_name = icon_name or "fa.male"
+ self.icon_color = icon_color or get_default_entity_icon_color()
+ self._label = None
+
+ @property
+ def id(self):
+ """Alias for task_id.
+
+ Returns:
+ str: Task id.
+ """
+
+ return self.task_id
+
+ @property
+ def label(self):
+ """Label of task item for UI.
+
+ Returns:
+ str: Label of task item.
+ """
+
+ if self._label is None:
+ self._label = "{} ({})".format(self.name, self.task_type)
+ return self._label
+
+ def to_data(self):
+ """Converts task item to data.
+
+ Returns:
+ dict[str, Any]: Task item data.
+ """
+
+ return {
+ "task_id": self.task_id,
+ "name": self.name,
+ "parent_id": self.parent_id,
+ "task_type": self.task_type,
+ "icon_name": self.icon_name,
+ "icon_color": self.icon_color,
+ }
+
+ @classmethod
+ def from_data(cls, data):
+ """Re-create task item from data.
+
+ Args:
+ data (dict[str, Any]): Task item data.
+
+ Returns:
+ TaskItem: Task item.
+ """
+
+ return cls(**data)
+
+
+class FileItem:
+ """File item that represents a file.
+
+ Can be used for both Workarea and Published workfile. Workarea file
+ will always exist on disk which is not the case for Published workfile.
+
+ Args:
+ dirpath (str): Directory path of file.
+ filename (str): Filename.
+ modified (float): Modified timestamp.
+ representation_id (Optional[str]): Representation id of published
+ workfile.
+ filepath (Optional[str]): Prepared filepath.
+ exists (Optional[bool]): If file exists on disk.
+ """
+
+ def __init__(
+ self,
+ dirpath,
+ filename,
+ modified,
+ representation_id=None,
+ filepath=None,
+ exists=None
+ ):
+ self.filename = filename
+ self.dirpath = dirpath
+ self.modified = modified
+ self.representation_id = representation_id
+ self._filepath = filepath
+ self._exists = exists
+
+ @property
+ def filepath(self):
+ """Filepath of file.
+
+ Returns:
+ str: Full path to a file.
+ """
+
+ if self._filepath is None:
+ self._filepath = os.path.join(self.dirpath, self.filename)
+ return self._filepath
+
+ @property
+ def exists(self):
+ """File is available.
+
+ Returns:
+ bool: If file exists on disk.
+ """
+
+ if self._exists is None:
+ self._exists = os.path.exists(self.filepath)
+ return self._exists
+
+ def to_data(self):
+ """Converts file item to data.
+
+ Returns:
+ dict[str, Any]: File item data.
+ """
+
+ return {
+ "filename": self.filename,
+ "dirpath": self.dirpath,
+ "modified": self.modified,
+ "representation_id": self.representation_id,
+ "filepath": self.filepath,
+ "exists": self.exists,
+ }
+
+ @classmethod
+ def from_data(cls, data):
+ """Re-creates file item from data.
+
+ Args:
+ data (dict[str, Any]): File item data.
+
+ Returns:
+ FileItem: File item.
+ """
+
+ required_keys = {
+ "filename",
+ "dirpath",
+ "modified",
+ "representation_id"
+ }
+ missing_keys = required_keys - set(data.keys())
+ if missing_keys:
+ raise KeyError("Missing keys: {}".format(missing_keys))
+
+ return cls(**{
+ key: data[key]
+ for key in required_keys
+ })
+
+
+class WorkareaFilepathResult:
+ """Result of workarea file formatting.
+
+ Args:
+ root (str): Root path of workarea.
+ filename (str): Filename.
+ exists (bool): True if file exists.
+ filepath (str): Filepath. If not provided it will be constructed
+ from root and filename.
+ """
+
+ def __init__(self, root, filename, exists, filepath=None):
+ if not filepath and root and filename:
+ filepath = os.path.join(root, filename)
+ self.root = root
+ self.filename = filename
+ self.exists = exists
+ self.filepath = filepath
+
+
+@six.add_metaclass(ABCMeta)
+class AbstractWorkfilesCommon(object):
+ @abstractmethod
+ def is_host_valid(self):
+ """Host is valid for workfiles tool work.
+
+ Returns:
+ bool: True if host is valid.
+ """
+
+ pass
+
+ @abstractmethod
+ def get_workfile_extensions(self):
+ """Get possible workfile extensions.
+
+ Defined by host implementation.
+
+ Returns:
+ Iterable[str]: List of extensions.
+ """
+
+ pass
+
+ @abstractmethod
+ def is_save_enabled(self):
+ """Is workfile save enabled.
+
+ Returns:
+ bool: True if save is enabled.
+ """
+
+ pass
+
+ @abstractmethod
+ def set_save_enabled(self, enabled):
+ """Enable or disabled workfile save.
+
+ Args:
+ enabled (bool): Enable save workfile when True.
+ """
+
+ pass
+
+
+class AbstractWorkfilesBackend(AbstractWorkfilesCommon):
+ # Current context
+ @abstractmethod
+ def get_host_name(self):
+ """Name of host.
+
+ Returns:
+ str: Name of host.
+ """
+ pass
+
+ @abstractmethod
+ def get_current_project_name(self):
+ """Project name from current context of host.
+
+ Returns:
+ str: Name of project.
+ """
+
+ pass
+
+ @abstractmethod
+ def get_current_folder_id(self):
+ """Folder id from current context of host.
+
+ Returns:
+ Union[str, None]: Folder id or None if host does not have
+ any context.
+ """
+
+ pass
+
+ @abstractmethod
+ def get_current_task_name(self):
+ """Task name from current context of host.
+
+ Returns:
+ Union[str, None]: Task name or None if host does not have
+ any context.
+ """
+
+ pass
+
+ @abstractmethod
+ def get_current_workfile(self):
+ """Current workfile from current context of host.
+
+ Returns:
+ Union[str, None]: Path to workfile or None if host does
+ not have opened specific file.
+ """
+
+ pass
+
+ @property
+ @abstractmethod
+ def project_anatomy(self):
+ """Project anatomy for current project.
+
+ Returns:
+ Anatomy: Project anatomy.
+ """
+
+ pass
+
+ @property
+ @abstractmethod
+ def project_settings(self):
+ """Project settings for current project.
+
+ Returns:
+ dict[str, Any]: Project settings.
+ """
+
+ pass
+
+ @abstractmethod
+ def get_folder_entity(self, folder_id):
+ """Get folder entity by id.
+
+ Args:
+ folder_id (str): Folder id.
+
+ Returns:
+ dict[str, Any]: Folder entity data.
+ """
+
+ pass
+
+ @abstractmethod
+ def get_task_entity(self, task_id):
+ """Get task entity by id.
+
+ Args:
+ task_id (str): Task id.
+
+ Returns:
+ dict[str, Any]: Task entity data.
+ """
+
+ pass
+
+ def emit_event(self, topic, data=None, source=None):
+ """Emit event.
+
+ Args:
+ topic (str): Event topic used for callbacks filtering.
+ data (Optional[dict[str, Any]]): Event data.
+ source (Optional[str]): Event source.
+ """
+
+ pass
+
+
+class AbstractWorkfilesFrontend(AbstractWorkfilesCommon):
+ """UI controller abstraction that is used for workfiles tool frontend.
+
+ Abstraction to provide data for UI and to handle UI events.
+
+ Provide access to abstract backend data, like folders and tasks. Cares
+ about handling of selection, keep information about current UI selection
+ and have ability to tell what selection should UI show.
+
+ Selection is separated into 2 parts, first is what UI elements tell
+ about selection, and second is what UI should show as selected.
+ """
+
+ @abstractmethod
+ def register_event_callback(self, topic, callback):
+ """Register event callback.
+
+ Listen for events with given topic.
+
+ Args:
+ topic (str): Name of topic.
+ callback (Callable): Callback that will be called when event
+ is triggered.
+ """
+
+ pass
+
+ # Host information
+ @abstractmethod
+ def get_workfile_extensions(self):
+ """Each host can define extensions that can be used for workfile.
+
+ Returns:
+ List[str]: File extensions that can be used as workfile for
+ current host.
+ """
+
+ pass
+
+ # Selection information
+ @abstractmethod
+ def get_selected_folder_id(self):
+ """Currently selected folder id.
+
+ Returns:
+ Union[str, None]: Folder id or None if no folder is selected.
+ """
+
+ pass
+
+ @abstractmethod
+ def set_selected_folder(self, folder_id):
+ """Change selected folder.
+
+ This deselects currently selected task.
+
+ Args:
+ folder_id (Union[str, None]): Folder id or None if no folder
+ is selected.
+ """
+
+ pass
+
+ @abstractmethod
+ def get_selected_task_id(self):
+ """Currently selected task id.
+
+ Returns:
+ Union[str, None]: Task id or None if no folder is selected.
+ """
+
+ pass
+
+ @abstractmethod
+ def get_selected_task_name(self):
+ """Currently selected task name.
+
+ Returns:
+ Union[str, None]: Task name or None if no folder is selected.
+ """
+
+ pass
+
+ @abstractmethod
+ def set_selected_task(self, folder_id, task_id, task_name):
+ """Change selected task.
+
+ Args:
+ folder_id (Union[str, None]): Folder id or None if no folder
+ is selected.
+ task_id (Union[str, None]): Task id or None if no task
+ is selected.
+ task_name (Union[str, None]): Task name or None if no task
+ is selected.
+ """
+
+ pass
+
+ @abstractmethod
+ def get_selected_workfile_path(self):
+ """Currently selected workarea workile.
+
+ Returns:
+ Union[str, None]: Selected workfile path.
+ """
+
+ pass
+
+ @abstractmethod
+ def set_selected_workfile_path(self, path):
+ """Change selected workfile path.
+
+ Args:
+ path (Union[str, None]): Selected workfile path.
+ """
+
+ pass
+
+ @abstractmethod
+ def get_selected_representation_id(self):
+ """Currently selected workfile representation id.
+
+ Returns:
+ Union[str, None]: Representation id or None if no representation
+ is selected.
+ """
+
+ pass
+
+ @abstractmethod
+ def set_selected_representation_id(self, representation_id):
+ """Change selected representation.
+
+ Args:
+ representation_id (Union[str, None]): Selected workfile
+ representation id.
+ """
+
+ pass
+
+ def get_selected_context(self):
+ """Obtain selected context.
+
+ Returns:
+ dict[str, Union[str, None]]: Selected context.
+ """
+
+ return {
+ "folder_id": self.get_selected_folder_id(),
+ "task_id": self.get_selected_task_id(),
+ "task_name": self.get_selected_task_name(),
+ "workfile_path": self.get_selected_workfile_path(),
+ "representation_id": self.get_selected_representation_id(),
+ }
+
+ # Expected selection
+ # - expected selection is used to restore selection after refresh
+ # or when current context should be used
+ @abstractmethod
+ def set_expected_selection(
+ self,
+ folder_id,
+ task_name,
+ workfile_name=None,
+ representation_id=None
+ ):
+ """Define what should be selected in UI.
+
+ Expected selection provide a way to define/change selection of
+ sequential UI elements. For example, if folder and task should be
+ selected a task element should wait until folder element has selected
+ folder.
+
+ Triggers 'expected_selection.changed' event.
+
+ Args:
+ folder_id (str): Folder id.
+ task_name (str): Task name.
+ workfile_name (Optional[str]): Workfile name. Used for workarea
+ files UI element.
+ representation_id (Optional[str]): Representation id. Used for
+ published filed UI element.
+ """
+
+ pass
+
+ @abstractmethod
+ def get_expected_selection_data(self):
+ """Data of expected selection.
+
+ TODOs:
+ Return defined object instead of dict.
+
+ Returns:
+ dict[str, Any]: Expected selection data.
+ """
+
+ pass
+
+ @abstractmethod
+ def expected_folder_selected(self, folder_id):
+ """Expected folder was selected in UI.
+
+ Args:
+ folder_id (str): Folder id which was selected.
+ """
+
+ pass
+
+ @abstractmethod
+ def expected_task_selected(self, folder_id, task_name):
+ """Expected task was selected in UI.
+
+ Args:
+ folder_id (str): Folder id under which task is.
+ task_name (str): Task name which was selected.
+ """
+
+ pass
+
+ @abstractmethod
+ def expected_representation_selected(self, representation_id):
+ """Expected representation was selected in UI.
+
+ Args:
+ representation_id (str): Representation id which was selected.
+ """
+
+ pass
+
+ @abstractmethod
+ def expected_workfile_selected(self, workfile_path):
+ """Expected workfile was selected in UI.
+
+ Args:
+ workfile_path (str): Workfile path which was selected.
+ """
+
+ pass
+
+ @abstractmethod
+ def go_to_current_context(self):
+ """Set expected selection to current context."""
+
+ pass
+
+ # Model functions
+ @abstractmethod
+ def get_folder_items(self, sender):
+ """Folder items to visualize project hierarchy.
+
+ This function may trigger events 'folders.refresh.started' and
+ 'folders.refresh.finished' which will contain 'sender' value in data.
+ That may help to avoid re-refresh of folder items in UI elements.
+
+ Args:
+ sender (str): Who requested folder items.
+
+ Returns:
+ list[FolderItem]: Minimum possible information needed
+ for visualisation of folder hierarchy.
+ """
+
+ pass
+
+ @abstractmethod
+ def get_task_items(self, folder_id, sender):
+ """Task items.
+
+ This function may trigger events 'tasks.refresh.started' and
+ 'tasks.refresh.finished' which will contain 'sender' value in data.
+ That may help to avoid re-refresh of task items in UI elements.
+
+ Args:
+ folder_id (str): Folder ID for which are tasks requested.
+ sender (str): Who requested folder items.
+
+ Returns:
+ list[TaskItem]: Minimum possible information needed
+ for visualisation of tasks.
+ """
+
+ pass
+
+ @abstractmethod
+ def has_unsaved_changes(self):
+ """Has host unsaved change in currently running session.
+
+ Returns:
+ bool: Has unsaved changes.
+ """
+
+ pass
+
+ @abstractmethod
+ def get_workarea_dir_by_context(self, folder_id, task_id):
+ """Get workarea directory by context.
+
+ Args:
+ folder_id (str): Folder id.
+ task_id (str): Task id.
+
+ Returns:
+ str: Workarea directory.
+ """
+
+ pass
+
+ @abstractmethod
+ def get_workarea_file_items(self, folder_id, task_id):
+ """Get workarea file items.
+
+ Args:
+ folder_id (str): Folder id.
+ task_id (str): Task id.
+
+ Returns:
+ list[FileItem]: List of workarea file items.
+ """
+
+ pass
+
+ @abstractmethod
+ def get_workarea_save_as_data(self, folder_id, task_id):
+ """Prepare data for Save As operation.
+
+ Todos:
+ Return defined object instead of dict.
+
+ Args:
+ folder_id (str): Folder id.
+ task_id (str): Task id.
+
+ Returns:
+ dict[str, Any]: Data for Save As operation.
+ """
+
+ pass
+
+ @abstractmethod
+ def fill_workarea_filepath(
+ self,
+ folder_id,
+ task_id,
+ extension,
+ use_last_version,
+ version,
+ comment,
+ ):
+ """Calculate workfile path for passed context.
+
+ Args:
+ folder_id (str): Folder id.
+ task_id (str): Task id.
+ extension (str): File extension.
+ use_last_version (bool): Use last version.
+ version (int): Version used if 'use_last_version' if 'False'.
+ comment (str): User's comment (subversion).
+
+ Returns:
+ WorkareaFilepathResult: Result of the operation.
+ """
+
+ pass
+
+ @abstractmethod
+ def get_published_file_items(self, folder_id, task_id):
+ """Get published file items.
+
+ Args:
+ folder_id (str): Folder id.
+ task_id (Union[str, None]): Task id.
+
+ Returns:
+ list[FileItem]: List of published file items.
+ """
+
+ pass
+
+ @abstractmethod
+ def get_workfile_info(self, folder_id, task_id, filepath):
+ """Workfile info from database.
+
+ Args:
+ folder_id (str): Folder id.
+ task_id (str): Task id.
+ filepath (str): Workfile path.
+
+ Returns:
+ Union[WorkfileInfo, None]: Workfile info or None if was passed
+ invalid context.
+ """
+
+ pass
+
+ @abstractmethod
+ def save_workfile_info(self, folder_id, task_id, filepath, note):
+ """Save workfile info to database.
+
+ At this moment the only information which can be saved about
+ workfile is 'note'.
+
+ Args:
+ folder_id (str): Folder id.
+ task_id (str): Task id.
+ filepath (str): Workfile path.
+ note (str): Note.
+ """
+
+ pass
+
+ # General commands
+ @abstractmethod
+ def refresh(self):
+ """Refresh everything, models, ui etc.
+
+ Triggers 'controller.refresh.started' event at the beginning and
+ 'controller.refresh.finished' at the end.
+ """
+
+ pass
+
+ # Controller actions
+ @abstractmethod
+ def open_workfile(self, filepath):
+ """Open a workfile.
+
+ Args:
+ filepath (str): Workfile path.
+ """
+
+ pass
+
+ @abstractmethod
+ def save_current_workfile(self):
+ """Save state of current workfile."""
+
+ pass
+
+ @abstractmethod
+ def save_as_workfile(
+ self,
+ folder_id,
+ task_id,
+ workdir,
+ filename,
+ template_key,
+ ):
+ """Save current state of workfile to workarea.
+
+ Args:
+ folder_id (str): Folder id.
+ task_id (str): Task id.
+ workdir (str): Workarea directory.
+ filename (str): Workarea filename.
+ template_key (str): Template key used to get the workdir
+ and filename.
+ """
+
+ pass
+
+ @abstractmethod
+ def copy_workfile_representation(
+ self,
+ representation_id,
+ representation_filepath,
+ folder_id,
+ task_id,
+ workdir,
+ filename,
+ template_key,
+ ):
+ """Action to copy published workfile representation to workarea.
+
+ Triggers 'copy_representation.started' event on start and
+ 'copy_representation.finished' event with '{"failed": bool}'.
+
+ Args:
+ representation_id (str): Representation id.
+ representation_filepath (str): Path to representation file.
+ folder_id (str): Folder id.
+ task_id (str): Task id.
+ workdir (str): Workarea directory.
+ filename (str): Workarea filename.
+ template_key (str): Template key.
+ """
+
+ pass
+
+ @abstractmethod
+ def duplicate_workfile(self, src_filepath, workdir, filename):
+ """Duplicate workfile.
+
+ Workfiles is not opened when done.
+
+ Args:
+ src_filepath (str): Source workfile path.
+ workdir (str): Destination workdir.
+ filename (str): Destination filename.
+ """
+
+ pass
diff --git a/openpype/tools/ayon_workfiles/control.py b/openpype/tools/ayon_workfiles/control.py
new file mode 100644
index 0000000000..fc8819bff3
--- /dev/null
+++ b/openpype/tools/ayon_workfiles/control.py
@@ -0,0 +1,642 @@
+import os
+import shutil
+
+import ayon_api
+
+from openpype.client import get_asset_by_id
+from openpype.host import IWorkfileHost
+from openpype.lib import Logger, emit_event
+from openpype.lib.events import QueuedEventSystem
+from openpype.settings import get_project_settings
+from openpype.pipeline import Anatomy, registered_host
+from openpype.pipeline.context_tools import (
+ change_current_context,
+ get_current_host_name,
+ get_global_context,
+)
+from openpype.pipeline.workfile import create_workdir_extra_folders
+
+from .abstract import (
+ AbstractWorkfilesFrontend,
+ AbstractWorkfilesBackend,
+)
+from .models import SelectionModel, EntitiesModel, WorkfilesModel
+
+
+class ExpectedSelection:
+ def __init__(self):
+ self._folder_id = None
+ self._task_name = None
+ self._workfile_name = None
+ self._representation_id = None
+ self._folder_selected = True
+ self._task_selected = True
+ self._workfile_name_selected = True
+ self._representation_id_selected = True
+
+ def set_expected_selection(
+ self,
+ folder_id,
+ task_name,
+ workfile_name=None,
+ representation_id=None
+ ):
+ self._folder_id = folder_id
+ self._task_name = task_name
+ self._workfile_name = workfile_name
+ self._representation_id = representation_id
+ self._folder_selected = False
+ self._task_selected = False
+ self._workfile_name_selected = workfile_name is None
+ self._representation_id_selected = representation_id is None
+
+ def get_expected_selection_data(self):
+ return {
+ "folder_id": self._folder_id,
+ "task_name": self._task_name,
+ "workfile_name": self._workfile_name,
+ "representation_id": self._representation_id,
+ "folder_selected": self._folder_selected,
+ "task_selected": self._task_selected,
+ "workfile_name_selected": self._workfile_name_selected,
+ "representation_id_selected": self._representation_id_selected,
+ }
+
+ def is_expected_folder_selected(self, folder_id):
+ return folder_id == self._folder_id and self._folder_selected
+
+ def is_expected_task_selected(self, folder_id, task_name):
+ if not self.is_expected_folder_selected(folder_id):
+ return False
+ return task_name == self._task_name and self._task_selected
+
+ def expected_folder_selected(self, folder_id):
+ if folder_id != self._folder_id:
+ return False
+ self._folder_selected = True
+ return True
+
+ def expected_task_selected(self, folder_id, task_name):
+ if not self.is_expected_folder_selected(folder_id):
+ return False
+
+ if task_name != self._task_name:
+ return False
+
+ self._task_selected = True
+ return True
+
+ def expected_workfile_selected(self, folder_id, task_name, workfile_name):
+ if not self.is_expected_task_selected(folder_id, task_name):
+ return False
+
+ if workfile_name != self._workfile_name:
+ return False
+ self._workfile_name_selected = True
+ return True
+
+ def expected_representation_selected(
+ self, folder_id, task_name, representation_id
+ ):
+ if not self.is_expected_task_selected(folder_id, task_name):
+ return False
+ if representation_id != self._representation_id:
+ return False
+ self._representation_id_selected = True
+ return True
+
+
+class BaseWorkfileController(
+ AbstractWorkfilesFrontend, AbstractWorkfilesBackend
+):
+ def __init__(self, host=None):
+ if host is None:
+ host = registered_host()
+
+ host_is_valid = False
+ if host is not None:
+ missing_methods = (
+ IWorkfileHost.get_missing_workfile_methods(host)
+ )
+ host_is_valid = len(missing_methods) == 0
+
+ self._host = host
+ self._host_is_valid = host_is_valid
+
+ self._project_anatomy = None
+ self._project_settings = None
+ self._event_system = None
+ self._log = None
+
+ self._current_project_name = None
+ self._current_folder_name = None
+ self._current_folder_id = None
+ self._current_task_name = None
+ self._save_is_enabled = True
+
+ # Expected selected folder and task
+ self._expected_selection = self._create_expected_selection_obj()
+
+ self._selection_model = self._create_selection_model()
+ self._entities_model = self._create_entities_model()
+ self._workfiles_model = self._create_workfiles_model()
+
+ @property
+ def log(self):
+ if self._log is None:
+ self._log = Logger.get_logger("WorkfilesUI")
+ return self._log
+
+ def is_host_valid(self):
+ return self._host_is_valid
+
+ def _create_expected_selection_obj(self):
+ return ExpectedSelection()
+
+ def _create_selection_model(self):
+ return SelectionModel(self)
+
+ def _create_entities_model(self):
+ return EntitiesModel(self)
+
+ def _create_workfiles_model(self):
+ return WorkfilesModel(self)
+
+ @property
+ def event_system(self):
+ """Inner event system for workfiles tool controller.
+
+ Is used for communication with UI. Event system is created on demand.
+
+ Returns:
+ QueuedEventSystem: Event system which can trigger callbacks
+ for topics.
+ """
+
+ if self._event_system is None:
+ self._event_system = QueuedEventSystem()
+ return self._event_system
+
+ # ----------------------------------------------------
+ # Implementation of methods required for backend logic
+ # ----------------------------------------------------
+ @property
+ def project_settings(self):
+ if self._project_settings is None:
+ self._project_settings = get_project_settings(
+ self.get_current_project_name())
+ return self._project_settings
+
+ @property
+ def project_anatomy(self):
+ if self._project_anatomy is None:
+ self._project_anatomy = Anatomy(self.get_current_project_name())
+ return self._project_anatomy
+
+ def get_folder_entity(self, folder_id):
+ return self._entities_model.get_folder_entity(folder_id)
+
+ def get_task_entity(self, task_id):
+ return self._entities_model.get_task_entity(task_id)
+
+ # ---------------------------------
+ # Implementation of abstract methods
+ # ---------------------------------
+ def emit_event(self, topic, data=None, source=None):
+ """Use implemented event system to trigger event."""
+
+ if data is None:
+ data = {}
+ self.event_system.emit(topic, data, source)
+
+ def register_event_callback(self, topic, callback):
+ self.event_system.add_callback(topic, callback)
+
+ def is_save_enabled(self):
+ """Is workfile save enabled.
+
+ Returns:
+ bool: True if save is enabled.
+ """
+
+ return self._save_is_enabled
+
+ def set_save_enabled(self, enabled):
+ """Enable or disabled workfile save.
+
+ Args:
+ enabled (bool): Enable save workfile when True.
+ """
+
+ if self._save_is_enabled == enabled:
+ return
+
+ self._save_is_enabled = enabled
+ self._emit_event(
+ "workfile_save_enable.changed",
+ {"enabled": enabled}
+ )
+
+ # Host information
+ def get_workfile_extensions(self):
+ host = self._host
+ if isinstance(host, IWorkfileHost):
+ return host.get_workfile_extensions()
+ return host.file_extensions()
+
+ def has_unsaved_changes(self):
+ host = self._host
+ if isinstance(host, IWorkfileHost):
+ return host.workfile_has_unsaved_changes()
+ return host.has_unsaved_changes()
+
+ # Current context
+ def get_host_name(self):
+ host = self._host
+ if isinstance(host, IWorkfileHost):
+ return host.name
+ return get_current_host_name()
+
+ def _get_host_current_context(self):
+ if hasattr(self._host, "get_current_context"):
+ return self._host.get_current_context()
+ return get_global_context()
+
+ def get_current_project_name(self):
+ return self._current_project_name
+
+ def get_current_folder_id(self):
+ return self._current_folder_id
+
+ def get_current_task_name(self):
+ return self._current_task_name
+
+ def get_current_workfile(self):
+ host = self._host
+ if isinstance(host, IWorkfileHost):
+ return host.get_current_workfile()
+ return host.current_file()
+
+ # Selection information
+ def get_selected_folder_id(self):
+ return self._selection_model.get_selected_folder_id()
+
+ def set_selected_folder(self, folder_id):
+ self._selection_model.set_selected_folder(folder_id)
+
+ def get_selected_task_id(self):
+ return self._selection_model.get_selected_task_id()
+
+ def get_selected_task_name(self):
+ return self._selection_model.get_selected_task_name()
+
+ def set_selected_task(self, folder_id, task_id, task_name):
+ return self._selection_model.set_selected_task(
+ folder_id, task_id, task_name)
+
+ def get_selected_workfile_path(self):
+ return self._selection_model.get_selected_workfile_path()
+
+ def set_selected_workfile_path(self, path):
+ self._selection_model.set_selected_workfile_path(path)
+
+ def get_selected_representation_id(self):
+ return self._selection_model.get_selected_representation_id()
+
+ def set_selected_representation_id(self, representation_id):
+ self._selection_model.set_selected_representation_id(
+ representation_id)
+
+ def set_expected_selection(
+ self,
+ folder_id,
+ task_name,
+ workfile_name=None,
+ representation_id=None
+ ):
+ self._expected_selection.set_expected_selection(
+ folder_id, task_name, workfile_name, representation_id
+ )
+ self._trigger_expected_selection_changed()
+
+ def expected_folder_selected(self, folder_id):
+ if self._expected_selection.expected_folder_selected(folder_id):
+ self._trigger_expected_selection_changed()
+
+ def expected_task_selected(self, folder_id, task_name):
+ if self._expected_selection.expected_task_selected(
+ folder_id, task_name
+ ):
+ self._trigger_expected_selection_changed()
+
+ def expected_workfile_selected(self, folder_id, task_name, workfile_name):
+ if self._expected_selection.expected_workfile_selected(
+ folder_id, task_name, workfile_name
+ ):
+ self._trigger_expected_selection_changed()
+
+ def expected_representation_selected(
+ self, folder_id, task_name, representation_id
+ ):
+ if self._expected_selection.expected_representation_selected(
+ folder_id, task_name, representation_id
+ ):
+ self._trigger_expected_selection_changed()
+
+ def get_expected_selection_data(self):
+ return self._expected_selection.get_expected_selection_data()
+
+ def go_to_current_context(self):
+ self.set_expected_selection(
+ self._current_folder_id, self._current_task_name
+ )
+
+ # Model functions
+ def get_folder_items(self, sender):
+ return self._entities_model.get_folder_items(sender)
+
+ def get_task_items(self, folder_id, sender):
+ return self._entities_model.get_tasks_items(folder_id, sender)
+
+ def get_workarea_dir_by_context(self, folder_id, task_id):
+ return self._workfiles_model.get_workarea_dir_by_context(
+ folder_id, task_id)
+
+ def get_workarea_file_items(self, folder_id, task_id):
+ return self._workfiles_model.get_workarea_file_items(
+ folder_id, task_id)
+
+ def get_workarea_save_as_data(self, folder_id, task_id):
+ return self._workfiles_model.get_workarea_save_as_data(
+ folder_id, task_id)
+
+ def fill_workarea_filepath(
+ self,
+ folder_id,
+ task_id,
+ extension,
+ use_last_version,
+ version,
+ comment,
+ ):
+ return self._workfiles_model.fill_workarea_filepath(
+ folder_id,
+ task_id,
+ extension,
+ use_last_version,
+ version,
+ comment,
+ )
+
+ def get_published_file_items(self, folder_id, task_id):
+ task_name = None
+ if task_id:
+ task = self.get_task_entity(task_id)
+ task_name = task.get("name")
+
+ return self._workfiles_model.get_published_file_items(
+ folder_id, task_name)
+
+ def get_workfile_info(self, folder_id, task_id, filepath):
+ return self._workfiles_model.get_workfile_info(
+ folder_id, task_id, filepath
+ )
+
+ def save_workfile_info(self, folder_id, task_id, filepath, note):
+ self._workfiles_model.save_workfile_info(
+ folder_id, task_id, filepath, note
+ )
+
+ def refresh(self):
+ if not self._host_is_valid:
+ self._emit_event("controller.refresh.started")
+ self._emit_event("controller.refresh.finished")
+ return
+ expected_folder_id = self.get_selected_folder_id()
+ expected_task_name = self.get_selected_task_name()
+
+ self._emit_event("controller.refresh.started")
+
+ context = self._get_host_current_context()
+
+ project_name = context["project_name"]
+ folder_name = context["asset_name"]
+ task_name = context["task_name"]
+ folder_id = None
+ if folder_name:
+ folder = ayon_api.get_folder_by_name(project_name, folder_name)
+ if folder:
+ folder_id = folder["id"]
+
+ self._project_settings = None
+ self._project_anatomy = None
+
+ self._current_project_name = project_name
+ self._current_folder_name = folder_name
+ self._current_folder_id = folder_id
+ self._current_task_name = task_name
+
+ if not expected_folder_id:
+ expected_folder_id = folder_id
+ expected_task_name = task_name
+
+ self._expected_selection.set_expected_selection(
+ expected_folder_id, expected_task_name
+ )
+
+ self._entities_model.refresh()
+
+ self._emit_event("controller.refresh.finished")
+
+ # Controller actions
+ def open_workfile(self, filepath):
+ self._emit_event("open_workfile.started")
+
+ failed = False
+ try:
+ self._host_open_workfile(filepath)
+
+ except Exception:
+ failed = True
+ self.log.warning("Open of workfile failed", exc_info=True)
+
+ self._emit_event(
+ "open_workfile.finished",
+ {"failed": failed},
+ )
+
+ def save_current_workfile(self):
+ current_file = self.get_current_workfile()
+ self._host_save_workfile(current_file)
+
+ def save_as_workfile(
+ self,
+ folder_id,
+ task_id,
+ workdir,
+ filename,
+ template_key,
+ ):
+ self._emit_event("save_as.started")
+
+ failed = False
+ try:
+ self._save_as_workfile(
+ folder_id,
+ task_id,
+ workdir,
+ filename,
+ template_key,
+ )
+ except Exception:
+ failed = True
+ self.log.warning("Save as failed", exc_info=True)
+
+ self._emit_event(
+ "save_as.finished",
+ {"failed": failed},
+ )
+
+ def copy_workfile_representation(
+ self,
+ representation_id,
+ representation_filepath,
+ folder_id,
+ task_id,
+ workdir,
+ filename,
+ template_key,
+ ):
+ self._emit_event("copy_representation.started")
+
+ failed = False
+ try:
+ self._save_as_workfile(
+ folder_id,
+ task_id,
+ workdir,
+ filename,
+ template_key,
+ )
+ except Exception:
+ failed = True
+ self.log.warning(
+ "Copy of workfile representation failed", exc_info=True
+ )
+
+ self._emit_event(
+ "copy_representation.finished",
+ {"failed": failed},
+ )
+
+ def duplicate_workfile(self, src_filepath, workdir, filename):
+ self._emit_event("workfile_duplicate.started")
+
+ failed = False
+ try:
+ dst_filepath = os.path.join(workdir, filename)
+ shutil.copy(src_filepath, dst_filepath)
+ except Exception:
+ failed = True
+ self.log.warning("Duplication of workfile failed", exc_info=True)
+
+ self._emit_event(
+ "workfile_duplicate.finished",
+ {"failed": failed},
+ )
+
+ # Helper host methods that resolve 'IWorkfileHost' interface
+ def _host_open_workfile(self, filepath):
+ host = self._host
+ if isinstance(host, IWorkfileHost):
+ host.open_workfile(filepath)
+ else:
+ host.open_file(filepath)
+
+ def _host_save_workfile(self, filepath):
+ host = self._host
+ if isinstance(host, IWorkfileHost):
+ host.save_workfile(filepath)
+ else:
+ host.save_file(filepath)
+
+ def _emit_event(self, topic, data=None):
+ self.emit_event(topic, data, "controller")
+
+ # Expected selection
+ # - expected selection is used to restore selection after refresh
+ # or when current context should be used
+ def _trigger_expected_selection_changed(self):
+ self._emit_event(
+ "expected_selection_changed",
+ self._expected_selection.get_expected_selection_data(),
+ )
+
+ def _save_as_workfile(
+ self,
+ folder_id,
+ task_id,
+ workdir,
+ filename,
+ template_key,
+ src_filepath=None,
+ ):
+ # Trigger before save event
+ project_name = self.get_current_project_name()
+ folder = self.get_folder_entity(folder_id)
+ task = self.get_task_entity(task_id)
+ task_name = task["name"]
+
+ # QUESTION should the data be different for 'before' and 'after'?
+ # NOTE keys should be OpenPype compatible
+ event_data = {
+ "project_name": project_name,
+ "folder_id": folder_id,
+ "asset_id": folder_id,
+ "asset_name": folder["name"],
+ "task_id": task_id,
+ "task_name": task_name,
+ "host_name": self.get_host_name(),
+ "filename": filename,
+ "workdir_path": workdir,
+ }
+ emit_event("workfile.save.before", event_data, source="workfiles.tool")
+
+ # Create workfiles root folder
+ if not os.path.exists(workdir):
+ self.log.debug("Initializing work directory: %s", workdir)
+ os.makedirs(workdir)
+
+ # Change context
+ if (
+ folder_id != self.get_current_folder_id()
+ or task_name != self.get_current_task_name()
+ ):
+ # Use OpenPype asset-like object
+ asset_doc = get_asset_by_id(project_name, folder["id"])
+ change_current_context(
+ asset_doc,
+ task["name"],
+ template_key=template_key
+ )
+
+ # Save workfile
+ dst_filepath = os.path.join(workdir, filename)
+ if src_filepath:
+ shutil.copyfile(src_filepath, dst_filepath)
+ self._host_open_workfile(dst_filepath)
+ else:
+ self._host_save_workfile(dst_filepath)
+
+ # Create extra folders
+ create_workdir_extra_folders(
+ workdir,
+ self.get_host_name(),
+ task["taskType"],
+ task_name,
+ project_name
+ )
+
+ # Trigger after save events
+ emit_event("workfile.save.after", event_data, source="workfiles.tool")
+ self.refresh()
diff --git a/openpype/tools/ayon_workfiles/models/__init__.py b/openpype/tools/ayon_workfiles/models/__init__.py
new file mode 100644
index 0000000000..d906b9e7bd
--- /dev/null
+++ b/openpype/tools/ayon_workfiles/models/__init__.py
@@ -0,0 +1,10 @@
+from .hierarchy import EntitiesModel
+from .selection import SelectionModel
+from .workfiles import WorkfilesModel
+
+
+__all__ = (
+ "SelectionModel",
+ "EntitiesModel",
+ "WorkfilesModel",
+)
diff --git a/openpype/tools/ayon_workfiles/models/hierarchy.py b/openpype/tools/ayon_workfiles/models/hierarchy.py
new file mode 100644
index 0000000000..948c0b8a17
--- /dev/null
+++ b/openpype/tools/ayon_workfiles/models/hierarchy.py
@@ -0,0 +1,225 @@
+"""Hierarchy model that handles folders and tasks.
+
+The model can be extracted for common usage. In that case it will be required
+to add more handling of project name changes.
+"""
+
+import time
+import collections
+import contextlib
+
+import ayon_api
+
+from openpype.tools.ayon_workfiles.abstract import (
+ FolderItem,
+ TaskItem,
+)
+
+
+def _get_task_items_from_tasks(tasks):
+ """
+
+ Returns:
+ TaskItem: Task item.
+ """
+
+ output = []
+ for task in tasks:
+ folder_id = task["folderId"]
+ output.append(TaskItem(
+ task["id"],
+ task["name"],
+ task["type"],
+ folder_id,
+ None,
+ None
+ ))
+ return output
+
+
+def _get_folder_item_from_hierarchy_item(item):
+ return FolderItem(
+ item["id"],
+ item["parentId"],
+ item["name"],
+ item["label"],
+ None,
+ None,
+ )
+
+
+class CacheItem:
+ def __init__(self, lifetime=120):
+ self._lifetime = lifetime
+ self._last_update = None
+ self._data = None
+
+ @property
+ def is_valid(self):
+ if self._last_update is None:
+ return False
+
+ return (time.time() - self._last_update) < self._lifetime
+
+ def set_invalid(self, data=None):
+ self._last_update = None
+ self._data = data
+
+ def get_data(self):
+ return self._data
+
+ def update_data(self, data):
+ self._data = data
+ self._last_update = time.time()
+
+
+class EntitiesModel(object):
+ event_source = "entities.model"
+
+ def __init__(self, controller):
+ folders_cache = CacheItem()
+ folders_cache.set_invalid({})
+ self._folders_cache = folders_cache
+ self._tasks_cache = {}
+
+ self._folders_by_id = {}
+ self._tasks_by_id = {}
+
+ self._folders_refreshing = False
+ self._tasks_refreshing = set()
+ self._controller = controller
+
+ def reset(self):
+ self._folders_cache.set_invalid({})
+ self._tasks_cache = {}
+
+ self._folders_by_id = {}
+ self._tasks_by_id = {}
+
+ def refresh(self):
+ self._refresh_folders_cache()
+
+ def get_folder_items(self, sender):
+ if not self._folders_cache.is_valid:
+ self._refresh_folders_cache(sender)
+ return self._folders_cache.get_data()
+
+ def get_tasks_items(self, folder_id, sender):
+ if not folder_id:
+ return []
+
+ task_cache = self._tasks_cache.get(folder_id)
+ if task_cache is None or not task_cache.is_valid:
+ self._refresh_tasks_cache(folder_id, sender)
+ task_cache = self._tasks_cache.get(folder_id)
+ return task_cache.get_data()
+
+ def get_folder_entity(self, folder_id):
+ if folder_id not in self._folders_by_id:
+ entity = None
+ if folder_id:
+ project_name = self._controller.get_current_project_name()
+ entity = ayon_api.get_folder_by_id(project_name, folder_id)
+ self._folders_by_id[folder_id] = entity
+ return self._folders_by_id[folder_id]
+
+ def get_task_entity(self, task_id):
+ if task_id not in self._tasks_by_id:
+ entity = None
+ if task_id:
+ project_name = self._controller.get_current_project_name()
+ entity = ayon_api.get_task_by_id(project_name, task_id)
+ self._tasks_by_id[task_id] = entity
+ return self._tasks_by_id[task_id]
+
+ @contextlib.contextmanager
+ def _folder_refresh_event_manager(self, project_name, sender):
+ self._folders_refreshing = True
+ self._controller.emit_event(
+ "folders.refresh.started",
+ {"project_name": project_name, "sender": sender},
+ self.event_source
+ )
+ try:
+ yield
+
+ finally:
+ self._controller.emit_event(
+ "folders.refresh.finished",
+ {"project_name": project_name, "sender": sender},
+ self.event_source
+ )
+ self._folders_refreshing = False
+
+ @contextlib.contextmanager
+ def _task_refresh_event_manager(
+ self, project_name, folder_id, sender
+ ):
+ self._tasks_refreshing.add(folder_id)
+ self._controller.emit_event(
+ "tasks.refresh.started",
+ {
+ "project_name": project_name,
+ "folder_id": folder_id,
+ "sender": sender,
+ },
+ self.event_source
+ )
+ try:
+ yield
+
+ finally:
+ self._controller.emit_event(
+ "tasks.refresh.finished",
+ {
+ "project_name": project_name,
+ "folder_id": folder_id,
+ "sender": sender,
+ },
+ self.event_source
+ )
+ self._tasks_refreshing.discard(folder_id)
+
+ def _refresh_folders_cache(self, sender=None):
+ if self._folders_refreshing:
+ return
+ project_name = self._controller.get_current_project_name()
+ with self._folder_refresh_event_manager(project_name, sender):
+ folder_items = self._query_folders(project_name)
+ self._folders_cache.update_data(folder_items)
+
+ def _query_folders(self, project_name):
+ hierarchy = ayon_api.get_folders_hierarchy(project_name)
+
+ folder_items = {}
+ hierachy_queue = collections.deque(hierarchy["hierarchy"])
+ while hierachy_queue:
+ item = hierachy_queue.popleft()
+ folder_item = _get_folder_item_from_hierarchy_item(item)
+ folder_items[folder_item.entity_id] = folder_item
+ hierachy_queue.extend(item["children"] or [])
+ return folder_items
+
+ def _refresh_tasks_cache(self, folder_id, sender=None):
+ if folder_id in self._tasks_refreshing:
+ return
+
+ project_name = self._controller.get_current_project_name()
+ with self._task_refresh_event_manager(
+ project_name, folder_id, sender
+ ):
+ cache_item = self._tasks_cache.get(folder_id)
+ if cache_item is None:
+ cache_item = CacheItem()
+ self._tasks_cache[folder_id] = cache_item
+
+ task_items = self._query_tasks(project_name, folder_id)
+ cache_item.update_data(task_items)
+
+ def _query_tasks(self, project_name, folder_id):
+ tasks = list(ayon_api.get_tasks(
+ project_name,
+ folder_ids=[folder_id],
+ fields={"id", "name", "label", "folderId", "type"}
+ ))
+ return _get_task_items_from_tasks(tasks)
diff --git a/openpype/tools/ayon_workfiles/models/selection.py b/openpype/tools/ayon_workfiles/models/selection.py
new file mode 100644
index 0000000000..ad034794d8
--- /dev/null
+++ b/openpype/tools/ayon_workfiles/models/selection.py
@@ -0,0 +1,91 @@
+class SelectionModel(object):
+ """Model handling selection changes.
+
+ Triggering events:
+ - "selection.folder.changed"
+ - "selection.task.changed"
+ - "workarea.selection.changed"
+ - "selection.representation.changed"
+ """
+
+ event_source = "selection.model"
+
+ def __init__(self, controller):
+ self._controller = controller
+
+ self._folder_id = None
+ self._task_name = None
+ self._task_id = None
+ self._workfile_path = None
+ self._representation_id = None
+
+ def get_selected_folder_id(self):
+ return self._folder_id
+
+ def set_selected_folder(self, folder_id):
+ if folder_id == self._folder_id:
+ return
+
+ self._folder_id = folder_id
+ self._controller.emit_event(
+ "selection.folder.changed",
+ {"folder_id": folder_id},
+ self.event_source
+ )
+
+ def get_selected_task_name(self):
+ return self._task_name
+
+ def get_selected_task_id(self):
+ return self._task_id
+
+ def set_selected_task(self, folder_id, task_id, task_name):
+ if folder_id != self._folder_id:
+ self.set_selected_folder(folder_id)
+
+ if task_id == self._task_id:
+ return
+
+ self._task_name = task_name
+ self._task_id = task_id
+ self._controller.emit_event(
+ "selection.task.changed",
+ {
+ "folder_id": folder_id,
+ "task_name": task_name,
+ "task_id": task_id
+ },
+ self.event_source
+ )
+
+ def get_selected_workfile_path(self):
+ return self._workfile_path
+
+ def set_selected_workfile_path(self, path):
+ if path == self._workfile_path:
+ return
+
+ self._workfile_path = path
+ self._controller.emit_event(
+ "workarea.selection.changed",
+ {
+ "path": path,
+ "folder_id": self._folder_id,
+ "task_name": self._task_name,
+ "task_id": self._task_id,
+ },
+ self.event_source
+ )
+
+ def get_selected_representation_id(self):
+ return self._representation_id
+
+ def set_selected_representation_id(self, representation_id):
+ if representation_id == self._representation_id:
+ return
+ self._representation_id = representation_id
+ self._controller.emit_event(
+ "selection.representation.changed",
+ {"representation_id": representation_id},
+ self.event_source
+ )
diff --git a/openpype/tools/ayon_workfiles/models/workfiles.py b/openpype/tools/ayon_workfiles/models/workfiles.py
new file mode 100644
index 0000000000..eb82f62de3
--- /dev/null
+++ b/openpype/tools/ayon_workfiles/models/workfiles.py
@@ -0,0 +1,711 @@
+import os
+import re
+import copy
+
+import arrow
+import ayon_api
+from ayon_api.operations import OperationsSession
+
+from openpype.client import get_project
+from openpype.client.operations import (
+ prepare_workfile_info_update_data,
+)
+from openpype.pipeline.template_data import (
+ get_template_data,
+)
+from openpype.pipeline.workfile import (
+ get_workdir_with_workdir_data,
+ get_workfile_template_key,
+ get_last_workfile_with_version,
+)
+from openpype.pipeline.version_start import get_versioning_start
+from openpype.tools.ayon_workfiles.abstract import (
+ WorkareaFilepathResult,
+ FileItem,
+ WorkfileInfo,
+)
+
+
+def get_folder_template_data(folder):
+ if not folder:
+ return {}
+ parts = folder["path"].split("/")
+ parts.pop(-1)
+ hierarchy = "/".join(parts)
+ return {
+ "asset": folder["name"],
+ "folder": {
+ "name": folder["name"],
+ "type": folder["folderType"],
+ "path": folder["path"],
+ },
+ "hierarchy": hierarchy,
+ }
+
+
+def get_task_template_data(task):
+ if not task:
+ return {}
+ return {
+ "task": {
+ "name": task["name"],
+ "type": task["taskType"]
+ }
+ }
+
+
+class CommentMatcher(object):
+ """Use anatomy and work file data to parse comments from filenames"""
+ def __init__(self, extensions, file_template, data):
+ self.fname_regex = None
+
+ if "{comment}" not in file_template:
+ # Don't look for comment if template doesn't allow it
+ return
+
+ # Create a regex group for extensions
+ any_extension = "(?:{})".format(
+ "|".join(re.escape(ext.lstrip(".")) for ext in extensions)
+ )
+
+ # Use placeholders that will never be in the filename
+ temp_data = copy.deepcopy(data)
+ temp_data["comment"] = "<>"
+ temp_data["version"] = "<>"
+ temp_data["ext"] = "<>"
+
+ fname_pattern = file_template.format_strict(temp_data)
+ fname_pattern = re.escape(fname_pattern)
+
+ # Replace comment and version with something we can match with regex
+ replacements = {
+ "<>": "(.+)",
+ "<>": "[0-9]+",
+ "<>": any_extension,
+ }
+ for src, dest in replacements.items():
+ fname_pattern = fname_pattern.replace(re.escape(src), dest)
+
+ # Match from beginning to end of string to be safe
+ fname_pattern = "^{}$".format(fname_pattern)
+
+ self.fname_regex = re.compile(fname_pattern)
+
+ def parse_comment(self, filepath):
+ """Parse the {comment} part from a filename"""
+ if not self.fname_regex:
+ return
+
+ fname = os.path.basename(filepath)
+ match = self.fname_regex.match(fname)
+ if match:
+ return match.group(1)
+
+
+class WorkareaModel:
+ """Workfiles model looking for workfiles in workare folder.
+
+ Workarea folder is usually task and host specific, defined by
+ anatomy templates. Is looking for files with extensions defined
+ by host integration.
+ """
+
+ def __init__(self, controller):
+ self._controller = controller
+ extensions = None
+ if controller.is_host_valid():
+ extensions = controller.get_workfile_extensions()
+ self._extensions = extensions
+ self._base_data = None
+ self._fill_data_by_folder_id = {}
+ self._task_data_by_folder_id = {}
+ self._workdir_by_context = {}
+
+ @property
+ def project_name(self):
+ return self._controller.get_current_project_name()
+
+ def reset(self):
+ self._base_data = None
+ self._fill_data_by_folder_id = {}
+ self._task_data_by_folder_id = {}
+
+ def _get_base_data(self):
+ if self._base_data is None:
+ base_data = get_template_data(get_project(self.project_name))
+ base_data["app"] = self._controller.get_host_name()
+ self._base_data = base_data
+ return copy.deepcopy(self._base_data)
+
+ def _get_folder_data(self, folder_id):
+ fill_data = self._fill_data_by_folder_id.get(folder_id)
+ if fill_data is None:
+ folder = self._controller.get_folder_entity(folder_id)
+ fill_data = get_folder_template_data(folder)
+ self._fill_data_by_folder_id[folder_id] = fill_data
+ return copy.deepcopy(fill_data)
+
+ def _get_task_data(self, folder_id, task_id):
+ task_data = self._task_data_by_folder_id.setdefault(folder_id, {})
+ if task_id not in task_data:
+ task = self._controller.get_task_entity(task_id)
+ if task:
+ task_data[task_id] = get_task_template_data(task)
+ return copy.deepcopy(task_data[task_id])
+
+ def _prepare_fill_data(self, folder_id, task_id):
+ if not folder_id or not task_id:
+ return {}
+
+ base_data = self._get_base_data()
+ folder_data = self._get_folder_data(folder_id)
+ task_data = self._get_task_data(folder_id, task_id)
+
+ base_data.update(folder_data)
+ base_data.update(task_data)
+
+ return base_data
+
+ def get_workarea_dir_by_context(self, folder_id, task_id):
+ if not folder_id or not task_id:
+ return None
+ folder_mapping = self._workdir_by_context.setdefault(folder_id, {})
+ workdir = folder_mapping.get(task_id)
+ if workdir is not None:
+ return workdir
+
+ workdir_data = self._prepare_fill_data(folder_id, task_id)
+
+ workdir = get_workdir_with_workdir_data(
+ workdir_data,
+ self.project_name,
+ anatomy=self._controller.project_anatomy,
+ )
+ folder_mapping[task_id] = workdir
+ return workdir
+
+ def get_file_items(self, folder_id, task_id):
+ items = []
+ if not folder_id or not task_id:
+ return items
+
+ workdir = self.get_workarea_dir_by_context(folder_id, task_id)
+ if not os.path.exists(workdir):
+ return items
+
+ for filename in os.listdir(workdir):
+ filepath = os.path.join(workdir, filename)
+ if not os.path.isfile(filepath):
+ continue
+
+ ext = os.path.splitext(filename)[1].lower()
+ if ext not in self._extensions:
+ continue
+
+ modified = os.path.getmtime(filepath)
+ items.append(
+ FileItem(workdir, filename, modified)
+ )
+ return items
+
+ def _get_template_key(self, fill_data):
+ task_type = fill_data.get("task", {}).get("type")
+ # TODO cache
+ return get_workfile_template_key(
+ task_type,
+ self._controller.get_host_name(),
+ project_name=self.project_name
+ )
+
+ def _get_last_workfile_version(
+ self, workdir, file_template, fill_data, extensions
+ ):
+ version = get_last_workfile_with_version(
+ workdir, str(file_template), fill_data, extensions
+ )[1]
+
+ if version is None:
+ task_info = fill_data.get("task", {})
+ version = get_versioning_start(
+ self.project_name,
+ self._controller.get_host_name(),
+ task_name=task_info.get("name"),
+ task_type=task_info.get("type"),
+ family="workfile",
+ project_settings=self._controller.project_settings,
+ )
+ else:
+ version += 1
+ return version
+
+ def _get_comments_from_root(
+ self,
+ file_template,
+ extensions,
+ fill_data,
+ root,
+ current_filename,
+ ):
+ current_comment = None
+ comment_hints = set()
+ filenames = []
+ if root and os.path.exists(root):
+ for filename in os.listdir(root):
+ path = os.path.join(root, filename)
+ if not os.path.isfile(path):
+ continue
+
+ ext = os.path.splitext(filename)[-1].lower()
+ if ext in extensions:
+ filenames.append(filename)
+
+ if not filenames:
+ return comment_hints, current_comment
+
+ matcher = CommentMatcher(extensions, file_template, fill_data)
+
+ for filename in filenames:
+ comment = matcher.parse_comment(filename)
+ if comment:
+ comment_hints.add(comment)
+ if filename == current_filename:
+ current_comment = comment
+
+ return list(comment_hints), current_comment
+
+ def _get_workdir(self, anatomy, template_key, fill_data):
+ template_info = anatomy.templates_obj[template_key]
+ directory_template = template_info["folder"]
+ return directory_template.format_strict(fill_data).normalized()
+
+ def get_workarea_save_as_data(self, folder_id, task_id):
+ folder = None
+ task = None
+ if folder_id:
+ folder = self._controller.get_folder_entity(folder_id)
+ if task_id:
+ task = self._controller.get_task_entity(task_id)
+
+ if not folder or not task:
+ return {
+ "template_key": None,
+ "template_has_version": None,
+ "template_has_comment": None,
+ "ext": None,
+ "workdir": None,
+ "comment": None,
+ "comment_hints": None,
+ "last_version": None,
+ "extensions": None,
+ }
+
+ anatomy = self._controller.project_anatomy
+ fill_data = self._prepare_fill_data(folder_id, task_id)
+ template_key = self._get_template_key(fill_data)
+
+ current_workfile = self._controller.get_current_workfile()
+ current_filename = None
+ current_ext = None
+ if current_workfile:
+ current_filename = os.path.basename(current_workfile)
+ current_ext = os.path.splitext(current_filename)[1].lower()
+
+ extensions = self._extensions
+ if not current_ext and extensions:
+ current_ext = tuple(extensions)[0]
+
+ workdir = self._get_workdir(anatomy, template_key, fill_data)
+
+ template_info = anatomy.templates_obj[template_key]
+ file_template = template_info["file"]
+
+ comment_hints, comment = self._get_comments_from_root(
+ file_template,
+ extensions,
+ fill_data,
+ workdir,
+ current_filename,
+ )
+ last_version = self._get_last_workfile_version(
+ workdir, file_template, fill_data, extensions)
+ str_file_template = str(file_template)
+ template_has_version = "{version" in str_file_template
+ template_has_comment = "{comment" in str_file_template
+
+ return {
+ "template_key": template_key,
+ "template_has_version": template_has_version,
+ "template_has_comment": template_has_comment,
+ "ext": current_ext,
+ "workdir": workdir,
+ "comment": comment,
+ "comment_hints": comment_hints,
+ "last_version": last_version,
+ "extensions": extensions,
+ }
+
+ def fill_workarea_filepath(
+ self,
+ folder_id,
+ task_id,
+ extension,
+ use_last_version,
+ version,
+ comment,
+ ):
+ anatomy = self._controller.project_anatomy
+ fill_data = self._prepare_fill_data(folder_id, task_id)
+ template_key = self._get_template_key(fill_data)
+
+ workdir = self._get_workdir(anatomy, template_key, fill_data)
+
+ template_info = anatomy.templates_obj[template_key]
+ file_template = template_info["file"]
+
+ if use_last_version:
+ version = self._get_last_workfile_version(
+ workdir, file_template, fill_data, self._extensions
+ )
+ fill_data["version"] = version
+ fill_data["ext"] = extension.lstrip(".")
+
+ if comment:
+ fill_data["comment"] = comment
+
+ filename = file_template.format(fill_data)
+ if not filename.solved:
+ filename = None
+
+ exists = False
+ if filename:
+ filepath = os.path.join(workdir, filename)
+ exists = os.path.exists(filepath)
+
+ return WorkareaFilepathResult(
+ workdir,
+ filename,
+ exists
+ )
+
+
+class WorkfileEntitiesModel:
+ """Workfile entities model.
+
+ Args:
+ control (AbstractWorkfileController): Controller object.
+ """
+
+ def __init__(self, controller):
+ self._controller = controller
+ self._cache = {}
+ self._items = {}
+
+ def _get_workfile_info_identifier(
+ self, folder_id, task_id, rootless_path
+ ):
+ return "_".join([folder_id, task_id, rootless_path])
+
+ def _get_rootless_path(self, filepath):
+ anatomy = self._controller.project_anatomy
+
+ workdir, filename = os.path.split(filepath)
+ success, rootless_dir = anatomy.find_root_template_from_path(workdir)
+ return "/".join([
+ os.path.normpath(rootless_dir).replace("\\", "/"),
+ filename
+ ])
+
+ def _prepare_workfile_info_item(
+ self, folder_id, task_id, workfile_info, filepath
+ ):
+ note = ""
+ if workfile_info:
+ note = workfile_info["attrib"].get("description") or ""
+
+ filestat = os.stat(filepath)
+ return WorkfileInfo(
+ folder_id,
+ task_id,
+ filepath,
+ filesize=filestat.st_size,
+ creation_time=filestat.st_ctime,
+ modification_time=filestat.st_mtime,
+ note=note
+ )
+
+ def _get_workfile_info(self, folder_id, task_id, identifier):
+ workfile_info = self._cache.get(identifier)
+ if workfile_info is not None:
+ return workfile_info
+
+ for workfile_info in ayon_api.get_workfiles_info(
+ self._controller.get_current_project_name(),
+ task_ids=[task_id],
+ fields=["id", "path", "attrib"],
+ ):
+ workfile_identifier = self._get_workfile_info_identifier(
+ folder_id, task_id, workfile_info["path"]
+ )
+ self._cache[workfile_identifier] = workfile_info
+ return self._cache.get(identifier)
+
+ def get_workfile_info(
+ self, folder_id, task_id, filepath, rootless_path=None
+ ):
+ if not folder_id or not task_id or not filepath:
+ return None
+
+ if rootless_path is None:
+ rootless_path = self._get_rootless_path(filepath)
+
+ identifier = self._get_workfile_info_identifier(
+ folder_id, task_id, rootless_path)
+ item = self._items.get(identifier)
+ if item is None:
+ workfile_info = self._get_workfile_info(
+ folder_id, task_id, identifier
+ )
+ item = self._prepare_workfile_info_item(
+ folder_id, task_id, workfile_info, filepath
+ )
+ self._items[identifier] = item
+ return item
+
+ def save_workfile_info(self, folder_id, task_id, filepath, note):
+ rootless_path = self._get_rootless_path(filepath)
+ identifier = self._get_workfile_info_identifier(
+ folder_id, task_id, rootless_path
+ )
+ workfile_info = self._get_workfile_info(
+ folder_id, task_id, identifier
+ )
+ if not workfile_info:
+ self._cache[identifier] = self._create_workfile_info_entity(
+ task_id, rootless_path, note)
+ self._items.pop(identifier, None)
+ return
+
+ new_workfile_info = copy.deepcopy(workfile_info)
+ attrib = new_workfile_info.setdefault("attrib", {})
+ attrib["description"] = note
+ update_data = prepare_workfile_info_update_data(
+ workfile_info, new_workfile_info
+ )
+ self._cache[identifier] = new_workfile_info
+ self._items.pop(identifier, None)
+ if not update_data:
+ return
+
+ project_name = self._controller.get_current_project_name()
+
+ session = OperationsSession()
+ session.update_entity(
+ project_name, "workfile", workfile_info["id"], update_data
+ )
+ session.commit()
+
+ def _create_workfile_info_entity(self, task_id, rootless_path, note):
+ extension = os.path.splitext(rootless_path)[1]
+
+ project_name = self._controller.get_current_project_name()
+
+ workfile_info = {
+ "path": rootless_path,
+ "taskId": task_id,
+ "attrib": {
+ "extension": extension,
+ "description": note
+ }
+ }
+
+ session = OperationsSession()
+ session.create_entity(project_name, "workfile", workfile_info)
+ session.commit()
+ return workfile_info
+
+
+class PublishWorkfilesModel:
+ """Model for handling of published workfiles.
+
+ Todos:
+ Cache workfiles products and representations for some time.
+ Note Representations won't change. Only what can change are
+ versions.
+ """
+
+ def __init__(self, controller):
+ self._controller = controller
+ self._cached_extensions = None
+ self._cached_repre_extensions = None
+
+ @property
+ def _extensions(self):
+ if self._cached_extensions is None:
+ exts = self._controller.get_workfile_extensions() or []
+ self._cached_extensions = exts
+ return self._cached_extensions
+
+ @property
+ def _repre_extensions(self):
+ if self._cached_repre_extensions is None:
+ self._cached_repre_extensions = {
+ ext.lstrip(".") for ext in self._extensions
+ }
+ return self._cached_repre_extensions
+
+ def _file_item_from_representation(
+ self, repre_entity, project_anatomy, task_name=None
+ ):
+ if task_name is not None:
+ task_info = repre_entity["context"].get("task")
+ if not task_info or task_info["name"] != task_name:
+ return None
+
+ # Filter by extension
+ extensions = self._repre_extensions
+ workfile_path = None
+ for repre_file in repre_entity["files"]:
+ ext = (
+ os.path.splitext(repre_file["name"])[1]
+ .lower()
+ .lstrip(".")
+ )
+ if ext in extensions:
+ workfile_path = repre_file["path"]
+ break
+
+ if not workfile_path:
+ return None
+
+ try:
+ workfile_path = workfile_path.format(
+ root=project_anatomy.roots)
+ except Exception as exc:
+ print("Failed to format workfile path: {}".format(exc))
+
+ dirpath, filename = os.path.split(workfile_path)
+ created_at = arrow.get(repre_entity["createdAt"])
+ return FileItem(
+ dirpath,
+ filename,
+ created_at.float_timestamp,
+ repre_entity["id"]
+ )
+
+ def get_file_items(self, folder_id, task_name):
+ # TODO refactor to use less server API calls
+ project_name = self._controller.get_current_project_name()
+ # Get subset docs of asset
+ product_entities = ayon_api.get_products(
+ project_name,
+ folder_ids=[folder_id],
+ product_types=["workfile"],
+ fields=["id", "name"]
+ )
+
+ output = []
+ product_ids = {product["id"] for product in product_entities}
+ if not product_ids:
+ return output
+
+ # Get version docs of subsets with their families
+ version_entities = ayon_api.get_versions(
+ project_name,
+ product_ids=product_ids,
+ fields=["id", "productId"]
+ )
+ version_ids = {version["id"] for version in version_entities}
+ if not version_ids:
+ return output
+
+ # Query representations of filtered versions and add filter for
+ # extension
+ repre_entities = ayon_api.get_representations(
+ project_name,
+ version_ids=version_ids
+ )
+ project_anatomy = self._controller.project_anatomy
+
+ # Filter queried representations by task name if task is set
+ file_items = []
+ for repre_entity in repre_entities:
+ file_item = self._file_item_from_representation(
+ repre_entity, project_anatomy, task_name
+ )
+ if file_item is not None:
+ file_items.append(file_item)
+
+ return file_items
+
+
+class WorkfilesModel:
+ """Workfiles model."""
+
+ def __init__(self, controller):
+ self._controller = controller
+
+ self._entities_model = WorkfileEntitiesModel(controller)
+ self._workarea_model = WorkareaModel(controller)
+ self._published_model = PublishWorkfilesModel(controller)
+
+ def get_workfile_info(self, folder_id, task_id, filepath):
+ return self._entities_model.get_workfile_info(
+ folder_id, task_id, filepath
+ )
+
+ def save_workfile_info(self, folder_id, task_id, filepath, note):
+ self._entities_model.save_workfile_info(
+ folder_id, task_id, filepath, note
+ )
+
+ def get_workarea_dir_by_context(self, folder_id, task_id):
+ """Workarea dir for passed context.
+
+ The directory path is based on project anatomy templates.
+
+ Args:
+ folder_id (str): Folder id.
+ task_id (str): Task id.
+
+ Returns:
+ Union[str, None]: Workarea dir path or None for invalid context.
+ """
+
+ return self._workarea_model.get_workarea_dir_by_context(
+ folder_id, task_id)
+
+ def get_workarea_file_items(self, folder_id, task_id):
+ """Workfile items for passed context from workarea.
+
+ Args:
+ folder_id (Union[str, None]): Folder id.
+ task_id (Union[str, None]): Task id.
+
+ Returns:
+ list[FileItem]: List of file items matching workarea of passed
+ context.
+ """
+
+ return self._workarea_model.get_file_items(folder_id, task_id)
+
+ def get_workarea_save_as_data(self, folder_id, task_id):
+ return self._workarea_model.get_workarea_save_as_data(
+ folder_id, task_id)
+
+ def fill_workarea_filepath(self, *args, **kwargs):
+ return self._workarea_model.fill_workarea_filepath(
+ *args, **kwargs
+ )
+
+ def get_published_file_items(self, folder_id, task_name):
+ """Published workfiles for passed context.
+
+ Args:
+ folder_id (str): Folder id.
+ task_name (str): Task name.
+
+ Returns:
+ list[FileItem]: List of files for published workfiles.
+ """
+
+ return self._published_model.get_file_items(folder_id, task_name)
diff --git a/openpype/tools/ayon_workfiles/widgets/__init__.py b/openpype/tools/ayon_workfiles/widgets/__init__.py
new file mode 100644
index 0000000000..f0c5ba1c40
--- /dev/null
+++ b/openpype/tools/ayon_workfiles/widgets/__init__.py
@@ -0,0 +1,6 @@
+from .window import WorkfilesToolWindow
+
+
+__all__ = (
+ "WorkfilesToolWindow",
+)
diff --git a/openpype/tools/ayon_workfiles/widgets/constants.py b/openpype/tools/ayon_workfiles/widgets/constants.py
new file mode 100644
index 0000000000..fc74fd9bc4
--- /dev/null
+++ b/openpype/tools/ayon_workfiles/widgets/constants.py
@@ -0,0 +1,7 @@
+from qtpy import QtCore
+
+
+ITEM_ID_ROLE = QtCore.Qt.UserRole + 1
+PARENT_ID_ROLE = QtCore.Qt.UserRole + 2
+ITEM_NAME_ROLE = QtCore.Qt.UserRole + 3
+TASK_TYPE_ROLE = QtCore.Qt.UserRole + 4
diff --git a/openpype/tools/ayon_workfiles/widgets/files_widget.py b/openpype/tools/ayon_workfiles/widgets/files_widget.py
new file mode 100644
index 0000000000..fbf4dbc593
--- /dev/null
+++ b/openpype/tools/ayon_workfiles/widgets/files_widget.py
@@ -0,0 +1,398 @@
+import os
+
+import qtpy
+from qtpy import QtWidgets, QtCore
+
+from .save_as_dialog import SaveAsDialog
+from .files_widget_workarea import WorkAreaFilesWidget
+from .files_widget_published import PublishedFilesWidget
+
+
+class FilesWidget(QtWidgets.QWidget):
+ """A widget displaying files that allows to save and open files.
+
+ Args:
+ controller (AbstractWorkfilesFrontend): The control object.
+ parent (QtWidgets.QWidget): The parent widget.
+ """
+
+ def __init__(self, controller, parent):
+ super(FilesWidget, self).__init__(parent)
+
+ files_widget = QtWidgets.QStackedWidget(self)
+ workarea_widget = WorkAreaFilesWidget(controller, files_widget)
+ published_widget = PublishedFilesWidget(controller, files_widget)
+ files_widget.addWidget(workarea_widget)
+ files_widget.addWidget(published_widget)
+
+ btns_widget = QtWidgets.QWidget(self)
+
+ workarea_btns_widget = QtWidgets.QWidget(btns_widget)
+ workarea_btn_open = QtWidgets.QPushButton(
+ "Open", workarea_btns_widget)
+ workarea_btn_browse = QtWidgets.QPushButton(
+ "Browse", workarea_btns_widget)
+ workarea_btn_save = QtWidgets.QPushButton(
+ "Save As", workarea_btns_widget)
+
+ workarea_btns_layout = QtWidgets.QHBoxLayout(workarea_btns_widget)
+ workarea_btns_layout.setContentsMargins(0, 0, 0, 0)
+ workarea_btns_layout.addWidget(workarea_btn_open, 1)
+ workarea_btns_layout.addWidget(workarea_btn_browse, 1)
+ workarea_btns_layout.addWidget(workarea_btn_save, 1)
+
+ published_btns_widget = QtWidgets.QWidget(btns_widget)
+ published_btn_copy_n_open = QtWidgets.QPushButton(
+ "Copy && Open", published_btns_widget
+ )
+ published_btn_change_context = QtWidgets.QPushButton(
+ "Choose different context", published_btns_widget
+ )
+ published_btn_cancel = QtWidgets.QPushButton(
+ "Cancel", published_btns_widget
+ )
+
+ published_btns_layout = QtWidgets.QHBoxLayout(published_btns_widget)
+ published_btns_layout.setContentsMargins(0, 0, 0, 0)
+ published_btns_layout.addWidget(published_btn_copy_n_open, 1)
+ published_btns_layout.addWidget(published_btn_change_context, 1)
+ published_btns_layout.addWidget(published_btn_cancel, 1)
+
+ btns_layout = QtWidgets.QVBoxLayout(btns_widget)
+ btns_layout.setContentsMargins(0, 0, 0, 0)
+ btns_layout.addWidget(workarea_btns_widget, 1)
+ btns_layout.addWidget(published_btns_widget, 1)
+
+ main_layout = QtWidgets.QVBoxLayout(self)
+ main_layout.setContentsMargins(0, 0, 0, 0)
+ main_layout.addWidget(files_widget, 1)
+ main_layout.addWidget(btns_widget, 0)
+
+ controller.register_event_callback(
+ "workarea.selection.changed",
+ self._on_workarea_path_changed
+ )
+ controller.register_event_callback(
+ "selection.representation.changed",
+ self._on_published_repre_changed
+ )
+ controller.register_event_callback(
+ "selection.task.changed",
+ self._on_task_changed
+ )
+ controller.register_event_callback(
+ "copy_representation.finished",
+ self._on_copy_representation_finished,
+ )
+ controller.register_event_callback(
+ "workfile_save_enable.changed",
+ self._on_workfile_save_enabled_change,
+ )
+
+ workarea_widget.open_current_requested.connect(
+ self._on_current_open_requests)
+ workarea_widget.duplicate_requested.connect(
+ self._on_duplicate_request)
+ workarea_btn_open.clicked.connect(self._on_workarea_open_clicked)
+ workarea_btn_browse.clicked.connect(self._on_workarea_browse_clicked)
+ workarea_btn_save.clicked.connect(self._on_workarea_save_clicked)
+
+ published_widget.save_as_requested.connect(self._on_save_as_request)
+ published_btn_copy_n_open.clicked.connect(
+ self._on_published_save_clicked)
+ published_btn_change_context.clicked.connect(
+ self._on_published_change_context_clicked)
+ published_btn_cancel.clicked.connect(
+ self._on_published_cancel_clicked)
+
+ self._selected_folder_id = None
+ self._selected_tak_name = None
+
+ self._pre_select_folder_id = None
+ self._pre_select_task_name = None
+
+ self._select_context_mode = False
+ self._valid_selected_context = False
+ self._valid_representation_id = False
+ self._tmp_text_filter = None
+ self._is_save_enabled = True
+
+ self._controller = controller
+ self._files_widget = files_widget
+ self._workarea_widget = workarea_widget
+ self._published_widget = published_widget
+ self._workarea_btns_widget = workarea_btns_widget
+ self._published_btns_widget = published_btns_widget
+
+ self._workarea_btn_open = workarea_btn_open
+ self._workarea_btn_browse = workarea_btn_browse
+ self._workarea_btn_save = workarea_btn_save
+
+ self._published_widget = published_widget
+ self._published_btn_copy_n_open = published_btn_copy_n_open
+ self._published_btn_change_context = published_btn_change_context
+ self._published_btn_cancel = published_btn_cancel
+
+ # Initial setup
+ workarea_btn_open.setEnabled(False)
+ published_btn_copy_n_open.setEnabled(False)
+ published_btn_change_context.setEnabled(False)
+ published_btn_cancel.setVisible(False)
+
+ def set_published_mode(self, published_mode):
+ # Make sure context selection is disabled
+ self._set_select_contex_mode(False)
+ # Change current widget
+ self._files_widget.setCurrentWidget((
+ self._published_widget
+ if published_mode
+ else self._workarea_widget
+ ))
+ # Pass the mode to the widgets, so they can start/stop handle events
+ self._workarea_widget.set_published_mode(published_mode)
+ self._published_widget.set_published_mode(published_mode)
+
+ # Change available buttons
+ self._workarea_btns_widget.setVisible(not published_mode)
+ self._published_btns_widget.setVisible(published_mode)
+
+ def set_text_filter(self, text_filter):
+ if self._select_context_mode:
+ self._tmp_text_filter = text_filter
+ return
+ self._workarea_widget.set_text_filter(text_filter)
+ self._published_widget.set_text_filter(text_filter)
+
+ def _exec_save_as_dialog(self):
+ """Show SaveAs dialog using currently selected context.
+
+ Returns:
+ Union[dict[str, Any], None]: Result of the dialog.
+ """
+
+ dialog = SaveAsDialog(self._controller, self)
+ dialog.update_context()
+ dialog.exec_()
+ return dialog.get_result()
+
+ # -------------------------------------------------------------
+ # Workarea workfiles
+ # -------------------------------------------------------------
+ def _open_workfile(self, filepath):
+ if self._controller.has_unsaved_changes():
+ result = self._save_changes_prompt()
+ if result is None:
+ return
+
+ if result:
+ self._controller.save_current_workfile()
+ self._controller.open_workfile(filepath)
+
+ def _on_workarea_open_clicked(self):
+ path = self._workarea_widget.get_selected_path()
+ if path:
+ self._open_workfile(path)
+
+ def _on_current_open_requests(self):
+ self._on_workarea_open_clicked()
+
+ def _on_duplicate_request(self):
+ filepath = self._workarea_widget.get_selected_path()
+ if filepath is None:
+ return
+
+ result = self._exec_save_as_dialog()
+ if result is None:
+ return
+ self._controller.duplicate_workfile(
+ filepath,
+ result["workdir"],
+ result["filename"]
+ )
+
+ def _on_workarea_browse_clicked(self):
+ extnsions = self._controller.get_workfile_extensions()
+ ext_filter = "Work File (*{0})".format(
+ " *".join(extnsions)
+ )
+ dir_key = "directory"
+ if qtpy.API in ("pyside", "pyside2", "pyside6"):
+ dir_key = "dir"
+
+ selected_context = self._controller.get_selected_context()
+ workfile_root = self._controller.get_workarea_dir_by_context(
+ selected_context["folder_id"], selected_context["task_id"]
+ )
+ # Find existing directory of workfile root
+ # - Qt will use 'cwd' instead, if path does not exist, which may lead
+ # to igniter directory
+ while workfile_root:
+ if os.path.exists(workfile_root):
+ break
+ workfile_root = os.path.dirname(workfile_root)
+
+ kwargs = {
+ "caption": "Work Files",
+ "filter": ext_filter,
+ dir_key: workfile_root
+ }
+
+ filepath = QtWidgets.QFileDialog.getOpenFileName(**kwargs)[0]
+ if filepath:
+ self._open_workfile(filepath)
+
+ def _on_workarea_save_clicked(self):
+ result = self._exec_save_as_dialog()
+ if result is None:
+ return
+ self._controller.save_as_workfile(
+ result["folder_id"],
+ result["task_id"],
+ result["workdir"],
+ result["filename"],
+ result["template_key"],
+ )
+
+ def _on_workarea_path_changed(self, event):
+ valid_path = event["path"] is not None
+ self._workarea_btn_open.setEnabled(valid_path)
+
+ # -------------------------------------------------------------
+ # Published workfiles
+ # -------------------------------------------------------------
+ def _update_published_btns_state(self):
+ enabled = (
+ self._valid_representation_id
+ and self._valid_selected_context
+ and self._is_save_enabled
+ )
+ self._published_btn_copy_n_open.setEnabled(enabled)
+ self._published_btn_change_context.setEnabled(enabled)
+
+ def _update_workarea_btns_state(self):
+ enabled = self._is_save_enabled
+ self._workarea_btn_save.setEnabled(enabled)
+
+ def _on_published_repre_changed(self, event):
+ self._valid_representation_id = event["representation_id"] is not None
+ self._update_published_btns_state()
+
+ def _on_task_changed(self, event):
+ self._selected_folder_id = event["folder_id"]
+ self._selected_tak_name = event["task_name"]
+ self._valid_selected_context = (
+ self._selected_folder_id is not None
+ and self._selected_tak_name is not None
+ )
+ self._update_published_btns_state()
+
+ def _on_published_save_clicked(self):
+ result = self._exec_save_as_dialog()
+ if result is None:
+ return
+
+ repre_info = self._published_widget.get_selected_repre_info()
+ self._controller.copy_workfile_representation(
+ repre_info["representation_id"],
+ repre_info["filepath"],
+ result["folder_id"],
+ result["task_id"],
+ result["workdir"],
+ result["filename"],
+ result["template_key"],
+ )
+
+ def _on_save_as_request(self):
+ self._on_published_save_clicked()
+
+ def _set_select_contex_mode(self, enabled):
+ if self._select_context_mode is enabled:
+ return
+
+ if enabled:
+ self._pre_select_folder_id = self._selected_folder_id
+ self._pre_select_task_name = self._selected_tak_name
+ else:
+ self._pre_select_folder_id = None
+ self._pre_select_task_name = None
+ self._select_context_mode = enabled
+ self._published_btn_cancel.setVisible(enabled)
+ self._published_btn_change_context.setVisible(not enabled)
+ self._published_widget.set_select_context_mode(enabled)
+
+ if not enabled and self._tmp_text_filter is not None:
+ self.set_text_filter(self._tmp_text_filter)
+ self._tmp_text_filter = None
+
+ def _on_published_change_context_clicked(self):
+ self._set_select_contex_mode(True)
+
+ def _should_set_pre_select_context(self):
+ if self._pre_select_folder_id is None:
+ return False
+ if self._pre_select_folder_id != self._selected_folder_id:
+ return True
+ if self._pre_select_task_name is None:
+ return False
+ return self._pre_select_task_name != self._selected_tak_name
+
+ def _on_published_cancel_clicked(self):
+ folder_id = self._pre_select_folder_id
+ task_name = self._pre_select_task_name
+ representation_id = self._published_widget.get_selected_repre_id()
+ should_change_selection = self._should_set_pre_select_context()
+ self._set_select_contex_mode(False)
+ if should_change_selection:
+ self._controller.set_expected_selection(
+ folder_id, task_name, representation_id=representation_id
+ )
+
+ def _on_copy_representation_finished(self, event):
+ """Callback for when copy representation is finished.
+
+ Make sure that select context mode is disabled when representation
+ copy is finished.
+
+ Args:
+ event (Event): Event object.
+ """
+
+ if not event["failed"]:
+ self._set_select_contex_mode(False)
+
+ def _on_workfile_save_enabled_change(self, event):
+ enabled = event["enabled"]
+ self._is_save_enabled = enabled
+ self._update_published_btns_state()
+ self._update_workarea_btns_state()
+
+ def _save_changes_prompt(self):
+ """Ask user if wants to save changes to current file.
+
+ Returns:
+ Union[bool, None]: True if user wants to save changes, False if
+ user does not want to save changes, None if user cancels
+ operation.
+ """
+ messagebox = QtWidgets.QMessageBox(parent=self)
+ messagebox.setWindowFlags(
+ messagebox.windowFlags() | QtCore.Qt.FramelessWindowHint
+ )
+ messagebox.setIcon(QtWidgets.QMessageBox.Warning)
+ messagebox.setWindowTitle("Unsaved Changes!")
+ messagebox.setText(
+ "There are unsaved changes to the current file."
+ "\nDo you want to save the changes?"
+ )
+ messagebox.setStandardButtons(
+ QtWidgets.QMessageBox.Yes
+ | QtWidgets.QMessageBox.No
+ | QtWidgets.QMessageBox.Cancel
+ )
+
+ result = messagebox.exec_()
+ if result == QtWidgets.QMessageBox.Yes:
+ return True
+ if result == QtWidgets.QMessageBox.No:
+ return False
+ return None
diff --git a/openpype/tools/ayon_workfiles/widgets/files_widget_published.py b/openpype/tools/ayon_workfiles/widgets/files_widget_published.py
new file mode 100644
index 0000000000..bc59447777
--- /dev/null
+++ b/openpype/tools/ayon_workfiles/widgets/files_widget_published.py
@@ -0,0 +1,378 @@
+import qtawesome
+from qtpy import QtWidgets, QtCore, QtGui
+
+from openpype.style import (
+ get_default_entity_icon_color,
+ get_disabled_entity_icon_color,
+)
+from openpype.tools.utils.delegates import PrettyTimeDelegate
+
+from .utils import TreeView, BaseOverlayFrame
+
+
+REPRE_ID_ROLE = QtCore.Qt.UserRole + 1
+FILEPATH_ROLE = QtCore.Qt.UserRole + 2
+DATE_MODIFIED_ROLE = QtCore.Qt.UserRole + 3
+
+
+class PublishedFilesModel(QtGui.QStandardItemModel):
+ """A model for displaying files.
+
+ Args:
+ controller (AbstractWorkfilesFrontend): The control object.
+ """
+
+ def __init__(self, controller):
+ super(PublishedFilesModel, self).__init__()
+
+ self.setColumnCount(2)
+
+ self.setHeaderData(0, QtCore.Qt.Horizontal, "Name")
+ self.setHeaderData(1, QtCore.Qt.Horizontal, "Date Modified")
+
+ controller.register_event_callback(
+ "selection.task.changed",
+ self._on_task_changed
+ )
+ controller.register_event_callback(
+ "selection.folder.changed",
+ self._on_folder_changed
+ )
+
+ self._file_icon = qtawesome.icon(
+ "fa.file-o",
+ color=get_default_entity_icon_color()
+ )
+ self._controller = controller
+ self._items_by_id = {}
+ self._missing_context_item = None
+ self._missing_context_used = False
+ self._empty_root_item = None
+ self._empty_item_used = False
+
+ self._published_mode = False
+ self._context_select_mode = False
+
+ self._last_folder_id = None
+ self._last_task_id = None
+
+ self._add_empty_item()
+
+ def _clear_items(self):
+ self._remove_missing_context_item()
+ self._remove_empty_item()
+ if self._items_by_id:
+ root = self.invisibleRootItem()
+ root.removeRows(0, root.rowCount())
+ self._items_by_id = {}
+
+ def set_published_mode(self, published_mode):
+ if self._published_mode == published_mode:
+ return
+ self._published_mode = published_mode
+ if published_mode:
+ self._fill_items()
+ elif self._context_select_mode:
+ self.set_select_context_mode(False)
+
+ def set_select_context_mode(self, select_mode):
+ if self._context_select_mode is select_mode:
+ return
+ self._context_select_mode = select_mode
+ if not select_mode and self._published_mode:
+ self._fill_items()
+
+ def get_index_by_representation_id(self, representation_id):
+ item = self._items_by_id.get(representation_id)
+ if item is None:
+ return QtCore.QModelIndex()
+ return self.indexFromItem(item)
+
+ def _get_missing_context_item(self):
+ if self._missing_context_item is None:
+ message = "Select folder"
+ item = QtGui.QStandardItem(message)
+ icon = qtawesome.icon(
+ "fa.times",
+ color=get_disabled_entity_icon_color()
+ )
+ item.setData(icon, QtCore.Qt.DecorationRole)
+ item.setFlags(QtCore.Qt.NoItemFlags)
+ item.setColumnCount(self.columnCount())
+ self._missing_context_item = item
+ return self._missing_context_item
+
+ def _add_missing_context_item(self):
+ if self._missing_context_used:
+ return
+ self._clear_items()
+ root_item = self.invisibleRootItem()
+ root_item.appendRow(self._get_missing_context_item())
+ self._missing_context_used = True
+
+ def _remove_missing_context_item(self):
+ if not self._missing_context_used:
+ return
+ root_item = self.invisibleRootItem()
+ root_item.takeRow(self._missing_context_item.row())
+ self._missing_context_used = False
+
+ def _get_empty_root_item(self):
+ if self._empty_root_item is None:
+ message = "Didn't find any published workfiles."
+ item = QtGui.QStandardItem(message)
+ icon = qtawesome.icon(
+ "fa.times",
+ color=get_disabled_entity_icon_color()
+ )
+ item.setData(icon, QtCore.Qt.DecorationRole)
+ item.setFlags(QtCore.Qt.NoItemFlags)
+ item.setColumnCount(self.columnCount())
+ self._empty_root_item = item
+ return self._empty_root_item
+
+ def _add_empty_item(self):
+ if self._empty_item_used:
+ return
+ self._clear_items()
+ root_item = self.invisibleRootItem()
+ root_item.appendRow(self._get_empty_root_item())
+ self._empty_item_used = True
+
+ def _remove_empty_item(self):
+ if not self._empty_item_used:
+ return
+ root_item = self.invisibleRootItem()
+ root_item.takeRow(self._empty_root_item.row())
+ self._empty_item_used = False
+
+ def _on_folder_changed(self, event):
+ self._last_folder_id = event["folder_id"]
+ self._last_task_id = None
+ if self._context_select_mode:
+ return
+
+ if self._published_mode:
+ self._fill_items()
+
+ def _on_task_changed(self, event):
+ self._last_folder_id = event["folder_id"]
+ self._last_task_id = event["task_id"]
+ if self._context_select_mode:
+ return
+
+ if self._published_mode:
+ self._fill_items()
+
+ def _fill_items(self):
+ folder_id = self._last_folder_id
+ task_id = self._last_task_id
+ if not folder_id:
+ self._add_missing_context_item()
+ return
+
+ file_items = self._controller.get_published_file_items(
+ folder_id, task_id
+ )
+ root_item = self.invisibleRootItem()
+ if not file_items:
+ self._add_empty_item()
+ return
+ self._remove_empty_item()
+ self._remove_missing_context_item()
+
+ items_to_remove = set(self._items_by_id.keys())
+ new_items = []
+ for file_item in file_items:
+ repre_id = file_item.representation_id
+ if repre_id in self._items_by_id:
+ items_to_remove.discard(repre_id)
+ item = self._items_by_id[repre_id]
+ else:
+ item = QtGui.QStandardItem()
+ new_items.append(item)
+ item.setColumnCount(self.columnCount())
+ item.setData(self._file_icon, QtCore.Qt.DecorationRole)
+ item.setData(file_item.filename, QtCore.Qt.DisplayRole)
+ item.setData(repre_id, REPRE_ID_ROLE)
+
+ if file_item.exists:
+ flags = QtCore.Qt.ItemIsEnabled | QtCore.Qt.ItemIsSelectable
+ else:
+ flags = QtCore.Qt.NoItemFlags
+
+ item.setFlags(flags)
+ item.setData(file_item.filepath, FILEPATH_ROLE)
+ item.setData(file_item.modified, DATE_MODIFIED_ROLE)
+
+ self._items_by_id[repre_id] = item
+
+ if new_items:
+ root_item.appendRows(new_items)
+
+ for repre_id in items_to_remove:
+ item = self._items_by_id.pop(repre_id)
+ root_item.removeRow(item.row())
+
+ if root_item.rowCount() == 0:
+ self._add_empty_item()
+
+ def flags(self, index):
+ # Use flags of first column for all columns
+ if index.column() != 0:
+ index = self.index(index.row(), 0, index.parent())
+ return super(PublishedFilesModel, self).flags(index)
+
+ def data(self, index, role=None):
+ if role is None:
+ role = QtCore.Qt.DisplayRole
+
+ # Handle roles for first column
+ if index.column() == 1:
+ if role == QtCore.Qt.DecorationRole:
+ return None
+
+ if role in (QtCore.Qt.DisplayRole, QtCore.Qt.EditRole):
+ role = DATE_MODIFIED_ROLE
+ index = self.index(index.row(), 0, index.parent())
+
+ return super(PublishedFilesModel, self).data(index, role)
+
+
+class SelectContextOverlay(BaseOverlayFrame):
+ """Overlay for files view when user should select context.
+
+ Todos:
+ The look of this overlay should be improved, it is "not nice" now.
+ """
+
+ def __init__(self, parent):
+ super(SelectContextOverlay, self).__init__(parent)
+
+ label_widget = QtWidgets.QLabel(
+ "Please choose context on the left
<",
+ self
+ )
+ label_widget.setAlignment(QtCore.Qt.AlignCenter)
+ label_widget.setObjectName("OverlayFrameLabel")
+
+ layout = QtWidgets.QHBoxLayout(self)
+ layout.addWidget(label_widget, 1, QtCore.Qt.AlignCenter)
+
+ label_widget.setAttribute(QtCore.Qt.WA_TranslucentBackground)
+
+
+class PublishedFilesWidget(QtWidgets.QWidget):
+ """Published workfiles widget.
+
+ Args:
+ controller (AbstractWorkfilesFrontend): The control object.
+ parent (QtWidgets.QWidget): The parent widget.
+ """
+
+ selection_changed = QtCore.Signal()
+ save_as_requested = QtCore.Signal()
+
+ def __init__(self, controller, parent):
+ super(PublishedFilesWidget, self).__init__(parent)
+
+ view = TreeView(self)
+ view.setSortingEnabled(True)
+ view.setContextMenuPolicy(QtCore.Qt.CustomContextMenu)
+ # Smaller indentation
+ view.setIndentation(0)
+
+ model = PublishedFilesModel(controller)
+ proxy_model = QtCore.QSortFilterProxyModel()
+ proxy_model.setSourceModel(model)
+ proxy_model.setSortCaseSensitivity(QtCore.Qt.CaseInsensitive)
+ proxy_model.setDynamicSortFilter(True)
+
+ view.setModel(proxy_model)
+
+ time_delegate = PrettyTimeDelegate()
+ view.setItemDelegateForColumn(1, time_delegate)
+
+ # Default to a wider first filename column it is what we mostly care
+ # about and the date modified is relatively small anyway.
+ view.setColumnWidth(0, 330)
+
+ select_overlay = SelectContextOverlay(view)
+ select_overlay.setVisible(False)
+
+ main_layout = QtWidgets.QVBoxLayout(self)
+ main_layout.setContentsMargins(0, 0, 0, 0)
+ main_layout.addWidget(view, 1)
+
+ selection_model = view.selectionModel()
+ selection_model.selectionChanged.connect(self._on_selection_change)
+ view.double_clicked_left.connect(self._on_left_double_click)
+
+ controller.register_event_callback(
+ "expected_selection_changed",
+ self._on_expected_selection_change
+ )
+
+ self._view = view
+ self._select_overlay = select_overlay
+ self._model = model
+ self._proxy_model = proxy_model
+ self._time_delegate = time_delegate
+ self._controller = controller
+
+ def set_published_mode(self, published_mode):
+ self._model.set_published_mode(published_mode)
+
+ def set_select_context_mode(self, select_mode):
+ self._model.set_select_context_mode(select_mode)
+ self._select_overlay.setVisible(select_mode)
+
+ def set_text_filter(self, text_filter):
+ self._proxy_model.setFilterFixedString(text_filter)
+
+ def get_selected_repre_info(self):
+ selection_model = self._view.selectionModel()
+ representation_id = None
+ filepath = None
+ for index in selection_model.selectedIndexes():
+ representation_id = index.data(REPRE_ID_ROLE)
+ filepath = index.data(FILEPATH_ROLE)
+
+ return {
+ "representation_id": representation_id,
+ "filepath": filepath,
+ }
+
+ def get_selected_repre_id(self):
+ return self.get_selected_repre_info()["representation_id"]
+
+ def _on_selection_change(self):
+ repre_id = self.get_selected_repre_id()
+ self._controller.set_selected_representation_id(repre_id)
+
+ def _on_left_double_click(self):
+ self.save_as_requested.emit()
+
+ def _on_expected_selection_change(self, event):
+ if (
+ event["representation_id_selected"]
+ or not event["folder_selected"]
+ or (event["task_name"] and not event["task_selected"])
+ ):
+ return
+
+ representation_id = event["representation_id"]
+ selected_repre_id = self.get_selected_repre_id()
+ if (
+ representation_id is not None
+ and representation_id != selected_repre_id
+ ):
+ index = self._model.get_index_by_representation_id(
+ representation_id)
+ if index.isValid():
+ proxy_index = self._proxy_model.mapFromSource(index)
+ self._view.setCurrentIndex(proxy_index)
+
+ self._controller.expected_representation_selected(
+ event["folder_id"], event["task_name"], representation_id
+ )
diff --git a/openpype/tools/ayon_workfiles/widgets/files_widget_workarea.py b/openpype/tools/ayon_workfiles/widgets/files_widget_workarea.py
new file mode 100644
index 0000000000..e8ccd094d1
--- /dev/null
+++ b/openpype/tools/ayon_workfiles/widgets/files_widget_workarea.py
@@ -0,0 +1,380 @@
+import qtawesome
+from qtpy import QtWidgets, QtCore, QtGui
+
+from openpype.style import (
+ get_default_entity_icon_color,
+ get_disabled_entity_icon_color,
+)
+from openpype.tools.utils.delegates import PrettyTimeDelegate
+
+from .utils import TreeView
+
+FILENAME_ROLE = QtCore.Qt.UserRole + 1
+FILEPATH_ROLE = QtCore.Qt.UserRole + 2
+DATE_MODIFIED_ROLE = QtCore.Qt.UserRole + 3
+
+
+class WorkAreaFilesModel(QtGui.QStandardItemModel):
+ """A model for workare workfiles.
+
+ Args:
+ controller (AbstractWorkfilesFrontend): The control object.
+ """
+
+ def __init__(self, controller):
+ super(WorkAreaFilesModel, self).__init__()
+
+ self.setColumnCount(2)
+
+ self.setHeaderData(0, QtCore.Qt.Horizontal, "Name")
+ self.setHeaderData(1, QtCore.Qt.Horizontal, "Date Modified")
+
+ controller.register_event_callback(
+ "selection.task.changed",
+ self._on_task_changed
+ )
+ controller.register_event_callback(
+ "workfile_duplicate.finished",
+ self._on_duplicate_finished
+ )
+ controller.register_event_callback(
+ "save_as.finished",
+ self._on_save_as_finished
+ )
+
+ self._file_icon = qtawesome.icon(
+ "fa.file-o",
+ color=get_default_entity_icon_color()
+ )
+ self._controller = controller
+ self._items_by_filename = {}
+ self._missing_context_item = None
+ self._missing_context_used = False
+ self._empty_root_item = None
+ self._empty_item_used = False
+ self._published_mode = False
+ self._selected_folder_id = None
+ self._selected_task_id = None
+
+ self._add_missing_context_item()
+
+ def get_index_by_filename(self, filename):
+ item = self._items_by_filename.get(filename)
+ if item is None:
+ return QtCore.QModelIndex()
+ return self.indexFromItem(item)
+
+ def _get_missing_context_item(self):
+ if self._missing_context_item is None:
+ message = "Select folder and task"
+ item = QtGui.QStandardItem(message)
+ icon = qtawesome.icon(
+ "fa.times",
+ color=get_disabled_entity_icon_color()
+ )
+ item.setData(icon, QtCore.Qt.DecorationRole)
+ item.setFlags(QtCore.Qt.NoItemFlags)
+ item.setColumnCount(self.columnCount())
+ self._missing_context_item = item
+ return self._missing_context_item
+
+ def _clear_items(self):
+ self._remove_missing_context_item()
+ self._remove_empty_item()
+ if self._items_by_filename:
+ root = self.invisibleRootItem()
+ root.removeRows(0, root.rowCount())
+ self._items_by_filename = {}
+
+ def _add_missing_context_item(self):
+ if self._missing_context_used:
+ return
+ self._clear_items()
+ root_item = self.invisibleRootItem()
+ root_item.appendRow(self._get_missing_context_item())
+ self._missing_context_used = True
+
+ def _remove_missing_context_item(self):
+ if not self._missing_context_used:
+ return
+ root_item = self.invisibleRootItem()
+ root_item.takeRow(self._missing_context_item.row())
+ self._missing_context_used = False
+
+ def _get_empty_root_item(self):
+ if self._empty_root_item is None:
+ message = "Work Area is empty.."
+ item = QtGui.QStandardItem(message)
+ icon = qtawesome.icon(
+ "fa.exclamation-circle",
+ color=get_disabled_entity_icon_color()
+ )
+ item.setData(icon, QtCore.Qt.DecorationRole)
+ item.setFlags(QtCore.Qt.NoItemFlags)
+ item.setColumnCount(self.columnCount())
+ self._empty_root_item = item
+ return self._empty_root_item
+
+ def _add_empty_item(self):
+ if self._empty_item_used:
+ return
+ self._clear_items()
+ root_item = self.invisibleRootItem()
+ root_item.appendRow(self._get_empty_root_item())
+ self._empty_item_used = True
+
+ def _remove_empty_item(self):
+ if not self._empty_item_used:
+ return
+ root_item = self.invisibleRootItem()
+ root_item.takeRow(self._empty_root_item.row())
+ self._empty_item_used = False
+
+ def _on_task_changed(self, event):
+ self._selected_folder_id = event["folder_id"]
+ self._selected_task_id = event["task_id"]
+ if not self._published_mode:
+ self._fill_items()
+
+ def _on_duplicate_finished(self, event):
+ if event["failed"]:
+ return
+
+ if not self._published_mode:
+ self._fill_items()
+
+ def _on_save_as_finished(self, event):
+ if event["failed"]:
+ return
+
+ if not self._published_mode:
+ self._fill_items()
+
+ def _fill_items(self):
+ folder_id = self._selected_folder_id
+ task_id = self._selected_task_id
+ if not folder_id or not task_id:
+ self._add_missing_context_item()
+ return
+
+ file_items = self._controller.get_workarea_file_items(
+ folder_id, task_id
+ )
+ root_item = self.invisibleRootItem()
+ if not file_items:
+ self._add_empty_item()
+ return
+ self._remove_empty_item()
+ self._remove_missing_context_item()
+
+ items_to_remove = set(self._items_by_filename.keys())
+ new_items = []
+ for file_item in file_items:
+ filename = file_item.filename
+ if filename in self._items_by_filename:
+ items_to_remove.discard(filename)
+ item = self._items_by_filename[filename]
+ else:
+ item = QtGui.QStandardItem()
+ new_items.append(item)
+ item.setColumnCount(self.columnCount())
+ item.setFlags(
+ QtCore.Qt.ItemIsEnabled | QtCore.Qt.ItemIsSelectable
+ )
+ item.setData(self._file_icon, QtCore.Qt.DecorationRole)
+ item.setData(file_item.filename, QtCore.Qt.DisplayRole)
+ item.setData(file_item.filename, FILENAME_ROLE)
+
+ item.setData(file_item.filepath, FILEPATH_ROLE)
+ item.setData(file_item.modified, DATE_MODIFIED_ROLE)
+
+ self._items_by_filename[file_item.filename] = item
+
+ if new_items:
+ root_item.appendRows(new_items)
+
+ for filename in items_to_remove:
+ item = self._items_by_filename.pop(filename)
+ root_item.removeRow(item.row())
+
+ if root_item.rowCount() == 0:
+ self._add_empty_item()
+
+ def flags(self, index):
+ # Use flags of first column for all columns
+ if index.column() != 0:
+ index = self.index(index.row(), 0, index.parent())
+ return super(WorkAreaFilesModel, self).flags(index)
+
+ def data(self, index, role=None):
+ if role is None:
+ role = QtCore.Qt.DisplayRole
+
+ # Handle roles for first column
+ if index.column() == 1:
+ if role == QtCore.Qt.DecorationRole:
+ return None
+
+ if role in (QtCore.Qt.DisplayRole, QtCore.Qt.EditRole):
+ role = DATE_MODIFIED_ROLE
+ index = self.index(index.row(), 0, index.parent())
+
+ return super(WorkAreaFilesModel, self).data(index, role)
+
+ def set_published_mode(self, published_mode):
+ if self._published_mode == published_mode:
+ return
+ self._published_mode = published_mode
+ if not published_mode:
+ self._fill_items()
+
+
+class WorkAreaFilesWidget(QtWidgets.QWidget):
+ """Workarea files widget.
+
+ Args:
+ controller (AbstractWorkfilesFrontend): The control object.
+ parent (QtWidgets.QWidget): The parent widget.
+ """
+
+ selection_changed = QtCore.Signal()
+ open_current_requested = QtCore.Signal()
+ duplicate_requested = QtCore.Signal()
+
+ def __init__(self, controller, parent):
+ super(WorkAreaFilesWidget, self).__init__(parent)
+
+ view = TreeView(self)
+ view.setSortingEnabled(True)
+ view.setContextMenuPolicy(QtCore.Qt.CustomContextMenu)
+ # Smaller indentation
+ view.setIndentation(0)
+
+ model = WorkAreaFilesModel(controller)
+ proxy_model = QtCore.QSortFilterProxyModel()
+ proxy_model.setSourceModel(model)
+ proxy_model.setSortCaseSensitivity(QtCore.Qt.CaseInsensitive)
+ proxy_model.setDynamicSortFilter(True)
+
+ view.setModel(proxy_model)
+
+ time_delegate = PrettyTimeDelegate()
+ view.setItemDelegateForColumn(1, time_delegate)
+
+ # Default to a wider first filename column it is what we mostly care
+ # about and the date modified is relatively small anyway.
+ view.setColumnWidth(0, 330)
+
+ main_layout = QtWidgets.QVBoxLayout(self)
+ main_layout.setContentsMargins(0, 0, 0, 0)
+ main_layout.addWidget(view, 1)
+
+ selection_model = view.selectionModel()
+ selection_model.selectionChanged.connect(self._on_selection_change)
+ view.double_clicked_left.connect(self._on_left_double_click)
+ view.customContextMenuRequested.connect(self._on_context_menu)
+
+ controller.register_event_callback(
+ "expected_selection_changed",
+ self._on_expected_selection_change
+ )
+
+ self._view = view
+ self._model = model
+ self._proxy_model = proxy_model
+ self._time_delegate = time_delegate
+ self._controller = controller
+
+ self._published_mode = False
+
+ def set_published_mode(self, published_mode):
+ """Set the published mode.
+
+ Widget should ignore most of events when in published mode is enabled.
+
+ Args:
+ published_mode (bool): The published mode.
+ """
+
+ self._model.set_published_mode(published_mode)
+ self._published_mode = published_mode
+
+ def set_text_filter(self, text_filter):
+ """Set the text filter.
+
+ Args:
+ text_filter (str): The text filter.
+ """
+
+ self._proxy_model.setFilterFixedString(text_filter)
+
+ def _get_selected_info(self):
+ selection_model = self._view.selectionModel()
+ filepath = None
+ filename = None
+ for index in selection_model.selectedIndexes():
+ filepath = index.data(FILEPATH_ROLE)
+ filename = index.data(FILENAME_ROLE)
+ return {
+ "filepath": filepath,
+ "filename": filename,
+ }
+
+ def get_selected_path(self):
+ """Selected filepath.
+
+ Returns:
+ Union[str, None]: The selected filepath or None if nothing is
+ selected.
+ """
+ return self._get_selected_info()["filepath"]
+
+ def _on_selection_change(self):
+ filepath = self.get_selected_path()
+ self._controller.set_selected_workfile_path(filepath)
+
+ def _on_left_double_click(self):
+ self.open_current_requested.emit()
+
+ def _on_context_menu(self, point):
+ index = self._view.indexAt(point)
+ if not index.isValid():
+ return
+
+ if not index.flags() & QtCore.Qt.ItemIsEnabled:
+ return
+
+ menu = QtWidgets.QMenu(self)
+
+ # Duplicate
+ action = QtWidgets.QAction("Duplicate", menu)
+ tip = "Duplicate selected file."
+ action.setToolTip(tip)
+ action.setStatusTip(tip)
+ action.triggered.connect(self._on_duplicate_pressed)
+ menu.addAction(action)
+
+ # Show the context action menu
+ global_point = self._view.mapToGlobal(point)
+ _ = menu.exec_(global_point)
+
+ def _on_duplicate_pressed(self):
+ self.duplicate_requested.emit()
+
+ def _on_expected_selection_change(self, event):
+ if event["workfile_name_selected"]:
+ return
+
+ workfile_name = event["workfile_name"]
+ if (
+ workfile_name is not None
+ and workfile_name != self._get_selected_info()["filename"]
+ ):
+ index = self._model.get_index_by_filename(workfile_name)
+ if index.isValid():
+ proxy_index = self._proxy_model.mapFromSource(index)
+ self._view.setCurrentIndex(proxy_index)
+
+ self._controller.expected_workfile_selected(
+ event["folder_id"], event["task_name"], workfile_name
+ )
diff --git a/openpype/tools/ayon_workfiles/widgets/folders_widget.py b/openpype/tools/ayon_workfiles/widgets/folders_widget.py
new file mode 100644
index 0000000000..b35845f4b6
--- /dev/null
+++ b/openpype/tools/ayon_workfiles/widgets/folders_widget.py
@@ -0,0 +1,324 @@
+import uuid
+import collections
+
+import qtawesome
+from qtpy import QtWidgets, QtGui, QtCore
+
+from openpype.tools.utils import (
+ RecursiveSortFilterProxyModel,
+ DeselectableTreeView,
+)
+
+from .constants import ITEM_ID_ROLE, ITEM_NAME_ROLE
+
+SENDER_NAME = "qt_folders_model"
+
+
+class FoldersRefreshThread(QtCore.QThread):
+ """Thread for refreshing folders.
+
+ Call controller to get folders and emit signal when finished.
+
+ Args:
+ controller (AbstractWorkfilesFrontend): The control object.
+ """
+
+ refresh_finished = QtCore.Signal(str)
+
+ def __init__(self, controller):
+ super(FoldersRefreshThread, self).__init__()
+ self._id = uuid.uuid4().hex
+ self._controller = controller
+ self._result = None
+
+ @property
+ def id(self):
+ """Thread id.
+
+ Returns:
+ str: Unique id of the thread.
+ """
+
+ return self._id
+
+ def run(self):
+ self._result = self._controller.get_folder_items(SENDER_NAME)
+ self.refresh_finished.emit(self.id)
+
+ def get_result(self):
+ return self._result
+
+
+class FoldersModel(QtGui.QStandardItemModel):
+ """Folders model which cares about refresh of folders.
+
+ Args:
+ controller (AbstractWorkfilesFrontend): The control object.
+ """
+
+ refreshed = QtCore.Signal()
+
+ def __init__(self, controller):
+ super(FoldersModel, self).__init__()
+
+ self._controller = controller
+ self._items_by_id = {}
+ self._parent_id_by_id = {}
+
+ self._refresh_threads = {}
+ self._current_refresh_thread = None
+
+ self._has_content = False
+ self._is_refreshing = False
+
+ @property
+ def is_refreshing(self):
+ """Model is refreshing.
+
+ Returns:
+ bool: True if model is refreshing.
+ """
+ return self._is_refreshing
+
+ @property
+ def has_content(self):
+ """Has at least one folder.
+
+ Returns:
+ bool: True if model has at least one folder.
+ """
+
+ return self._has_content
+
+ def clear(self):
+ self._items_by_id = {}
+ self._parent_id_by_id = {}
+ self._has_content = False
+ super(FoldersModel, self).clear()
+
+ def get_index_by_id(self, item_id):
+ """Get index by folder id.
+
+ Returns:
+ QtCore.QModelIndex: Index of the folder. Can be invalid if folder
+ is not available.
+ """
+ item = self._items_by_id.get(item_id)
+ if item is None:
+ return QtCore.QModelIndex()
+ return self.indexFromItem(item)
+
+ def refresh(self):
+ """Refresh folders items.
+
+ Refresh start thread because it can cause that controller can
+ start query from database if folders are not cached.
+ """
+
+ self._is_refreshing = True
+
+ thread = FoldersRefreshThread(self._controller)
+ self._current_refresh_thread = thread.id
+ self._refresh_threads[thread.id] = thread
+ thread.refresh_finished.connect(self._on_refresh_thread)
+ thread.start()
+
+ def _on_refresh_thread(self, thread_id):
+ """Callback when refresh thread is finished.
+
+ Technically can be running multiple refresh threads at the same time,
+ to avoid using values from wrong thread, we check if thread id is
+ current refresh thread id.
+
+ Folders are stored by id.
+
+ Args:
+ thread_id (str): Thread id.
+ """
+
+ thread = self._refresh_threads.pop(thread_id)
+ if thread_id != self._current_refresh_thread:
+ return
+
+ folder_items_by_id = thread.get_result()
+ if not folder_items_by_id:
+ if folder_items_by_id is not None:
+ self.clear()
+ self._is_refreshing = False
+ return
+
+ self._has_content = True
+
+ folder_ids = set(folder_items_by_id)
+ ids_to_remove = set(self._items_by_id) - folder_ids
+
+ folder_items_by_parent = collections.defaultdict(list)
+ for folder_item in folder_items_by_id.values():
+ folder_items_by_parent[folder_item.parent_id].append(folder_item)
+
+ hierarchy_queue = collections.deque()
+ hierarchy_queue.append(None)
+
+ while hierarchy_queue:
+ parent_id = hierarchy_queue.popleft()
+ folder_items = folder_items_by_parent[parent_id]
+ if parent_id is None:
+ parent_item = self.invisibleRootItem()
+ else:
+ parent_item = self._items_by_id[parent_id]
+
+ new_items = []
+ for folder_item in folder_items:
+ item_id = folder_item.entity_id
+ item = self._items_by_id.get(item_id)
+ if item is None:
+ is_new = True
+ item = QtGui.QStandardItem()
+ item.setEditable(False)
+ else:
+ is_new = self._parent_id_by_id[item_id] != parent_id
+
+ icon = qtawesome.icon(
+ folder_item.icon_name,
+ color=folder_item.icon_color,
+ )
+ item.setData(item_id, ITEM_ID_ROLE)
+ item.setData(folder_item.name, ITEM_NAME_ROLE)
+ item.setData(folder_item.label, QtCore.Qt.DisplayRole)
+ item.setData(icon, QtCore.Qt.DecorationRole)
+ if is_new:
+ new_items.append(item)
+ self._items_by_id[item_id] = item
+ self._parent_id_by_id[item_id] = parent_id
+
+ hierarchy_queue.append(item_id)
+
+ if new_items:
+ parent_item.appendRows(new_items)
+
+ for item_id in ids_to_remove:
+ item = self._items_by_id[item_id]
+ parent_id = self._parent_id_by_id[item_id]
+ if parent_id is None:
+ parent_item = self.invisibleRootItem()
+ else:
+ parent_item = self._items_by_id[parent_id]
+ parent_item.takeChild(item.row())
+
+ for item_id in ids_to_remove:
+ self._items_by_id.pop(item_id)
+ self._parent_id_by_id.pop(item_id)
+
+ self._is_refreshing = False
+ self.refreshed.emit()
+
+
+class FoldersWidget(QtWidgets.QWidget):
+ """Folders widget.
+
+ Widget that handles folders view, model and selection.
+
+ Args:
+ controller (AbstractWorkfilesFrontend): The control object.
+ parent (QtWidgets.QWidget): The parent widget.
+ """
+
+ def __init__(self, controller, parent):
+ super(FoldersWidget, self).__init__(parent)
+
+ folders_view = DeselectableTreeView(self)
+ folders_view.setHeaderHidden(True)
+
+ folders_model = FoldersModel(controller)
+ folders_proxy_model = RecursiveSortFilterProxyModel()
+ folders_proxy_model.setSourceModel(folders_model)
+
+ folders_view.setModel(folders_proxy_model)
+
+ main_layout = QtWidgets.QHBoxLayout(self)
+ main_layout.setContentsMargins(0, 0, 0, 0)
+ main_layout.addWidget(folders_view, 1)
+
+ controller.register_event_callback(
+ "folders.refresh.finished",
+ self._on_folders_refresh_finished
+ )
+ controller.register_event_callback(
+ "controller.refresh.finished",
+ self._on_controller_refresh
+ )
+ controller.register_event_callback(
+ "expected_selection_changed",
+ self._on_expected_selection_change
+ )
+
+ selection_model = folders_view.selectionModel()
+ selection_model.selectionChanged.connect(self._on_selection_change)
+
+ folders_model.refreshed.connect(self._on_model_refresh)
+
+ self._controller = controller
+ self._folders_view = folders_view
+ self._folders_model = folders_model
+ self._folders_proxy_model = folders_proxy_model
+
+ self._expected_selection = None
+
+ def set_name_filer(self, name):
+ self._folders_proxy_model.setFilterFixedString(name)
+
+ def _clear(self):
+ self._folders_model.clear()
+
+ def _on_folders_refresh_finished(self, event):
+ if event["sender"] != SENDER_NAME:
+ self._folders_model.refresh()
+
+ def _on_controller_refresh(self):
+ self._update_expected_selection()
+
+ def _update_expected_selection(self, expected_data=None):
+ if expected_data is None:
+ expected_data = self._controller.get_expected_selection_data()
+
+ # We're done
+ if expected_data["folder_selected"]:
+ return
+
+ folder_id = expected_data["folder_id"]
+ self._expected_selection = folder_id
+ if not self._folders_model.is_refreshing:
+ self._set_expected_selection()
+
+ def _set_expected_selection(self):
+ folder_id = self._expected_selection
+ self._expected_selection = None
+ if (
+ folder_id is not None
+ and folder_id != self._get_selected_item_id()
+ ):
+ index = self._folders_model.get_index_by_id(folder_id)
+ if index.isValid():
+ proxy_index = self._folders_proxy_model.mapFromSource(index)
+ self._folders_view.setCurrentIndex(proxy_index)
+ self._controller.expected_folder_selected(folder_id)
+
+ def _on_model_refresh(self):
+ if self._expected_selection:
+ self._set_expected_selection()
+ self._folders_proxy_model.sort(0)
+
+ def _on_expected_selection_change(self, event):
+ self._update_expected_selection(event.data)
+
+ def _get_selected_item_id(self):
+ selection_model = self._folders_view.selectionModel()
+ for index in selection_model.selectedIndexes():
+ item_id = index.data(ITEM_ID_ROLE)
+ if item_id is not None:
+ return item_id
+ return None
+
+ def _on_selection_change(self):
+ item_id = self._get_selected_item_id()
+ self._controller.set_selected_folder(item_id)
diff --git a/openpype/tools/ayon_workfiles/widgets/save_as_dialog.py b/openpype/tools/ayon_workfiles/widgets/save_as_dialog.py
new file mode 100644
index 0000000000..cdce73f030
--- /dev/null
+++ b/openpype/tools/ayon_workfiles/widgets/save_as_dialog.py
@@ -0,0 +1,351 @@
+from qtpy import QtWidgets, QtCore
+
+from openpype.tools.utils import PlaceholderLineEdit
+
+
+class SubversionLineEdit(QtWidgets.QWidget):
+ """QLineEdit with QPushButton for drop down selection of list of strings"""
+
+ text_changed = QtCore.Signal(str)
+
+ def __init__(self, *args, **kwargs):
+ super(SubversionLineEdit, self).__init__(*args, **kwargs)
+
+ input_field = PlaceholderLineEdit(self)
+ menu_btn = QtWidgets.QPushButton(self)
+ menu_btn.setFixedWidth(18)
+
+ menu = QtWidgets.QMenu(self)
+ menu_btn.setMenu(menu)
+
+ layout = QtWidgets.QHBoxLayout(self)
+ layout.setContentsMargins(0, 0, 0, 0)
+ layout.setSpacing(3)
+
+ layout.addWidget(input_field, 1)
+ layout.addWidget(menu_btn, 0)
+
+ input_field.textChanged.connect(self.text_changed)
+
+ self.setFocusProxy(input_field)
+
+ self._input_field = input_field
+ self._menu_btn = menu_btn
+ self._menu = menu
+
+ def set_placeholder(self, placeholder):
+ self._input_field.setPlaceholderText(placeholder)
+
+ def set_text(self, text):
+ self._input_field.setText(text)
+
+ def set_values(self, values):
+ self._update(values)
+
+ def _on_button_clicked(self):
+ self._menu.exec_()
+
+ def _on_action_clicked(self, action):
+ self._input_field.setText(action.text())
+
+ def _update(self, values):
+ """Create optional predefined subset names
+
+ Args:
+ default_names(list): all predefined names
+
+ Returns:
+ None
+ """
+
+ menu = self._menu
+ button = self._menu_btn
+
+ state = any(values)
+ button.setEnabled(state)
+ if state is False:
+ return
+
+ # Include an empty string
+ values = [""] + sorted(values)
+
+ # Get and destroy the action group
+ group = button.findChild(QtWidgets.QActionGroup)
+ if group:
+ group.deleteLater()
+
+ # Build new action group
+ group = QtWidgets.QActionGroup(button)
+ for name in values:
+ action = group.addAction(name)
+ menu.addAction(action)
+
+ group.triggered.connect(self._on_action_clicked)
+
+
+class SaveAsDialog(QtWidgets.QDialog):
+ """Save as dialog to define a unique filename inside workdir.
+
+ The filename is calculated in controller where UI sends values from
+ dialog inputs.
+
+ Args:
+ controller (AbstractWorkfilesFrontend): The control object.
+ """
+
+ def __init__(self, controller, parent):
+ super(SaveAsDialog, self).__init__(parent=parent)
+ self.setWindowFlags(self.windowFlags() | QtCore.Qt.FramelessWindowHint)
+
+ self._controller = controller
+
+ self._folder_id = None
+ self._task_id = None
+ self._last_version = None
+ self._template_key = None
+ self._comment_value = None
+ self._version_value = None
+ self._ext_value = None
+ self._filename = None
+ self._workdir = None
+
+ self._result = None
+
+ # Btns widget
+ btns_widget = QtWidgets.QWidget(self)
+
+ btn_ok = QtWidgets.QPushButton("Ok", btns_widget)
+ btn_cancel = QtWidgets.QPushButton("Cancel", btns_widget)
+
+ btns_layout = QtWidgets.QHBoxLayout(btns_widget)
+ btns_layout.addWidget(btn_ok)
+ btns_layout.addWidget(btn_cancel)
+
+ # Inputs widget
+ inputs_widget = QtWidgets.QWidget(self)
+
+ # Version widget
+ version_widget = QtWidgets.QWidget(inputs_widget)
+
+ # Version number input
+ version_input = QtWidgets.QSpinBox(version_widget)
+ version_input.setMinimum(1)
+ version_input.setMaximum(9999)
+
+ # Last version checkbox
+ last_version_check = QtWidgets.QCheckBox(
+ "Next Available Version", version_widget
+ )
+ last_version_check.setChecked(True)
+
+ version_layout = QtWidgets.QHBoxLayout(version_widget)
+ version_layout.setContentsMargins(0, 0, 0, 0)
+ version_layout.addWidget(version_input)
+ version_layout.addWidget(last_version_check)
+
+ # Preview widget
+ preview_widget = QtWidgets.QLabel("Preview filename", inputs_widget)
+ preview_widget.setWordWrap(True)
+
+ # Subversion input
+ subversion_input = SubversionLineEdit(inputs_widget)
+ subversion_input.set_placeholder("Will be part of filename.")
+
+ # Extensions combobox
+ extension_combobox = QtWidgets.QComboBox(inputs_widget)
+ # Add styled delegate to use stylesheets
+ extension_delegate = QtWidgets.QStyledItemDelegate()
+ extension_combobox.setItemDelegate(extension_delegate)
+
+ version_label = QtWidgets.QLabel("Version:", inputs_widget)
+ subversion_label = QtWidgets.QLabel("Subversion:", inputs_widget)
+ extension_label = QtWidgets.QLabel("Extension:", inputs_widget)
+ preview_label = QtWidgets.QLabel("Preview:", inputs_widget)
+
+ # Build inputs
+ inputs_layout = QtWidgets.QGridLayout(inputs_widget)
+ inputs_layout.addWidget(version_label, 0, 0)
+ inputs_layout.addWidget(version_widget, 0, 1)
+ inputs_layout.addWidget(subversion_label, 1, 0)
+ inputs_layout.addWidget(subversion_input, 1, 1)
+ inputs_layout.addWidget(extension_label, 2, 0)
+ inputs_layout.addWidget(extension_combobox, 2, 1)
+ inputs_layout.addWidget(preview_label, 3, 0)
+ inputs_layout.addWidget(preview_widget, 3, 1)
+
+ # Build layout
+ main_layout = QtWidgets.QVBoxLayout(self)
+ main_layout.addWidget(inputs_widget)
+ main_layout.addWidget(btns_widget)
+
+ # Signal callback registration
+ version_input.valueChanged.connect(self._on_version_spinbox_change)
+ last_version_check.stateChanged.connect(
+ self._on_version_checkbox_change
+ )
+
+ subversion_input.text_changed.connect(self._on_comment_change)
+ extension_combobox.currentIndexChanged.connect(
+ self._on_extension_change)
+
+ btn_ok.pressed.connect(self._on_ok_pressed)
+ btn_cancel.pressed.connect(self._on_cancel_pressed)
+
+ # Store objects
+ self._inputs_layout = inputs_layout
+
+ self._btn_ok = btn_ok
+ self._btn_cancel = btn_cancel
+
+ self._version_widget = version_widget
+
+ self._version_input = version_input
+ self._last_version_check = last_version_check
+
+ self._extension_delegate = extension_delegate
+ self._extension_combobox = extension_combobox
+ self._subversion_input = subversion_input
+ self._preview_widget = preview_widget
+
+ self._version_label = version_label
+ self._subversion_label = subversion_label
+ self._extension_label = extension_label
+ self._preview_label = preview_label
+
+ # Post init setup
+
+ # Allow "Enter" key to accept the save.
+ btn_ok.setDefault(True)
+
+ # Disable version input if last version is checked
+ version_input.setEnabled(not last_version_check.isChecked())
+
+ # Force default focus to comment, some hosts didn't automatically
+ # apply focus to this line edit (e.g. Houdini)
+ subversion_input.setFocus()
+
+ def get_result(self):
+ return self._result
+
+ def update_context(self):
+ # Add version only if template contains version key
+ # - since the version can be padded with "{version:0>4}" we only search
+ # for "{version".
+ selected_context = self._controller.get_selected_context()
+ folder_id = selected_context["folder_id"]
+ task_id = selected_context["task_id"]
+ data = self._controller.get_workarea_save_as_data(folder_id, task_id)
+ last_version = data["last_version"]
+ comment = data["comment"]
+ comment_hints = data["comment_hints"]
+
+ template_has_version = data["template_has_version"]
+ template_has_comment = data["template_has_comment"]
+
+ self._folder_id = folder_id
+ self._task_id = task_id
+ self._workdir = data["workdir"]
+ self._comment_value = data["comment"]
+ self._ext_value = data["ext"]
+ self._template_key = data["template_key"]
+ self._last_version = data["last_version"]
+
+ self._extension_combobox.clear()
+ self._extension_combobox.addItems(data["extensions"])
+
+ self._version_input.setValue(last_version)
+
+ vw_idx = self._inputs_layout.indexOf(self._version_widget)
+ self._version_label.setVisible(template_has_version)
+ self._version_widget.setVisible(template_has_version)
+ if template_has_version:
+ if vw_idx == -1:
+ self._inputs_layout.addWidget(self._version_label, 0, 0)
+ self._inputs_layout.addWidget(self._version_widget, 0, 1)
+ elif vw_idx != -1:
+ self._inputs_layout.takeAt(vw_idx)
+ self._inputs_layout.takeAt(
+ self._inputs_layout.indexOf(self._version_label)
+ )
+
+ cw_idx = self._inputs_layout.indexOf(self._subversion_input)
+ self._subversion_label.setVisible(template_has_comment)
+ self._subversion_input.setVisible(template_has_comment)
+ if template_has_comment:
+ if cw_idx == -1:
+ self._inputs_layout.addWidget(self._subversion_label, 1, 0)
+ self._inputs_layout.addWidget(self._subversion_input, 1, 1)
+ elif cw_idx != -1:
+ self._inputs_layout.takeAt(cw_idx)
+ self._inputs_layout.takeAt(
+ self._inputs_layout.indexOf(self._subversion_label)
+ )
+
+ if template_has_comment:
+ self._subversion_input.set_text(comment or "")
+ self._subversion_input.set_values(comment_hints)
+ self._update_filename()
+
+ def _on_version_spinbox_change(self, value):
+ if value == self._version_value:
+ return
+ self._version_value = value
+ if not self._last_version_check.isChecked():
+ self._update_filename()
+
+ def _on_version_checkbox_change(self):
+ use_last_version = self._last_version_check.isChecked()
+ self._version_input.setEnabled(not use_last_version)
+ if use_last_version:
+ self._version_input.blockSignals(True)
+ self._version_input.setValue(self._last_version)
+ self._version_input.blockSignals(False)
+ self._update_filename()
+
+ def _on_comment_change(self, text):
+ if self._comment_value == text:
+ return
+ self._comment_value = text
+ self._update_filename()
+
+ def _on_extension_change(self):
+ ext = self._extension_combobox.currentText()
+ if ext == self._ext_value:
+ return
+ self._ext_value = ext
+ self._update_filename()
+
+ def _on_ok_pressed(self):
+ self._result = {
+ "filename": self._filename,
+ "workdir": self._workdir,
+ "folder_id": self._folder_id,
+ "task_id": self._task_id,
+ "template_key": self._template_key,
+ }
+ self.close()
+
+ def _on_cancel_pressed(self):
+ self.close()
+
+ def _update_filename(self):
+ result = self._controller.fill_workarea_filepath(
+ self._folder_id,
+ self._task_id,
+ self._ext_value,
+ self._last_version_check.isChecked(),
+ self._version_value,
+ self._comment_value,
+ )
+ self._filename = result.filename
+ self._btn_ok.setEnabled(not result.exists)
+
+ if result.exists:
+ self._preview_widget.setText((
+ "Cannot create \"{}\" because file exists!"
+ ""
+ ).format(result.filename))
+ else:
+ self._preview_widget.setText(
+ "{}".format(result.filename)
+ )
diff --git a/openpype/tools/ayon_workfiles/widgets/side_panel.py b/openpype/tools/ayon_workfiles/widgets/side_panel.py
new file mode 100644
index 0000000000..7f06576a00
--- /dev/null
+++ b/openpype/tools/ayon_workfiles/widgets/side_panel.py
@@ -0,0 +1,163 @@
+import datetime
+
+from qtpy import QtWidgets, QtCore
+
+
+def file_size_to_string(file_size):
+ size = 0
+ size_ending_mapping = {
+ "KB": 1024 ** 1,
+ "MB": 1024 ** 2,
+ "GB": 1024 ** 3
+ }
+ ending = "B"
+ for _ending, _size in size_ending_mapping.items():
+ if file_size < _size:
+ break
+ size = file_size / _size
+ ending = _ending
+ return "{:.2f} {}".format(size, ending)
+
+
+class SidePanelWidget(QtWidgets.QWidget):
+ """Details about selected workfile.
+
+ Todos:
+ At this moment only shows created and modified date of file
+ or its size.
+
+ Args:
+ controller (AbstractWorkfilesFrontend): The control object.
+ parent (QtWidgets.QWidget): The parent widget.
+ """
+
+ published_workfile_message = (
+ "INFO: Opened published workfiles will be stored in"
+ " temp directory on your machine. Current temp size: {}."
+ )
+
+ def __init__(self, controller, parent):
+ super(SidePanelWidget, self).__init__(parent)
+
+ details_label = QtWidgets.QLabel("Details", self)
+ details_input = QtWidgets.QPlainTextEdit(self)
+ details_input.setReadOnly(True)
+
+ artist_note_widget = QtWidgets.QWidget(self)
+ note_label = QtWidgets.QLabel("Artist note", artist_note_widget)
+ note_input = QtWidgets.QPlainTextEdit(artist_note_widget)
+ btn_note_save = QtWidgets.QPushButton("Save note", artist_note_widget)
+
+ artist_note_layout = QtWidgets.QVBoxLayout(artist_note_widget)
+ artist_note_layout.setContentsMargins(0, 0, 0, 0)
+ artist_note_layout.addWidget(note_label, 0)
+ artist_note_layout.addWidget(note_input, 1)
+ artist_note_layout.addWidget(
+ btn_note_save, 0, alignment=QtCore.Qt.AlignRight
+ )
+
+ main_layout = QtWidgets.QVBoxLayout(self)
+ main_layout.setContentsMargins(0, 0, 0, 0)
+ main_layout.addWidget(details_label, 0)
+ main_layout.addWidget(details_input, 1)
+ main_layout.addWidget(artist_note_widget, 1)
+
+ note_input.textChanged.connect(self._on_note_change)
+ btn_note_save.clicked.connect(self._on_save_click)
+
+ controller.register_event_callback(
+ "workarea.selection.changed", self._on_selection_change
+ )
+
+ self._details_input = details_input
+ self._artist_note_widget = artist_note_widget
+ self._note_input = note_input
+ self._btn_note_save = btn_note_save
+
+ self._folder_id = None
+ self._task_id = None
+ self._filepath = None
+ self._orig_note = ""
+ self._controller = controller
+
+ self._set_context(None, None, None)
+
+ def set_published_mode(self, published_mode):
+ """Change published mode.
+
+ Args:
+ published_mode (bool): Published mode enabled.
+ """
+
+ self._artist_note_widget.setVisible(not published_mode)
+
+ def _on_selection_change(self, event):
+ folder_id = event["folder_id"]
+ task_id = event["task_id"]
+ filepath = event["path"]
+
+ self._set_context(folder_id, task_id, filepath)
+
+ def _on_note_change(self):
+ text = self._note_input.toPlainText()
+ self._btn_note_save.setEnabled(self._orig_note != text)
+
+ def _on_save_click(self):
+ note = self._note_input.toPlainText()
+ self._controller.save_workfile_info(
+ self._folder_id,
+ self._task_id,
+ self._filepath,
+ note
+ )
+ self._orig_note = note
+ self._btn_note_save.setEnabled(False)
+
+ def _set_context(self, folder_id, task_id, filepath):
+ workfile_info = None
+ # Check if folder, task and file are selected
+ if bool(folder_id) and bool(task_id) and bool(filepath):
+ workfile_info = self._controller.get_workfile_info(
+ folder_id, task_id, filepath
+ )
+ enabled = workfile_info is not None
+
+ self._details_input.setEnabled(enabled)
+ self._note_input.setEnabled(enabled)
+ self._btn_note_save.setEnabled(enabled)
+
+ self._folder_id = folder_id
+ self._task_id = task_id
+ self._filepath = filepath
+
+ # Disable inputs and remove texts if any required arguments are
+ # missing
+ if not enabled:
+ self._orig_note = ""
+ self._details_input.setPlainText("")
+ self._note_input.setPlainText("")
+ return
+
+ note = workfile_info.note
+ size_value = file_size_to_string(workfile_info.filesize)
+
+ # Append html string
+ datetime_format = "%b %d %Y %H:%M:%S"
+ creation_time = datetime.datetime.fromtimestamp(
+ workfile_info.creation_time)
+ modification_time = datetime.datetime.fromtimestamp(
+ workfile_info.modification_time)
+ lines = (
+ "Size:",
+ size_value,
+ "Created:",
+ creation_time.strftime(datetime_format),
+ "Modified:",
+ modification_time.strftime(datetime_format)
+ )
+ self._orig_note = note
+ self._note_input.setPlainText(note)
+
+ # Set as empty string
+ self._details_input.setPlainText("")
+ self._details_input.appendHtml("
".join(lines))
diff --git a/openpype/tools/ayon_workfiles/widgets/tasks_widget.py b/openpype/tools/ayon_workfiles/widgets/tasks_widget.py
new file mode 100644
index 0000000000..04f5b286b1
--- /dev/null
+++ b/openpype/tools/ayon_workfiles/widgets/tasks_widget.py
@@ -0,0 +1,420 @@
+import uuid
+import qtawesome
+from qtpy import QtWidgets, QtGui, QtCore
+
+from openpype.style import get_disabled_entity_icon_color
+from openpype.tools.utils import DeselectableTreeView
+
+from .constants import (
+ ITEM_NAME_ROLE,
+ ITEM_ID_ROLE,
+ PARENT_ID_ROLE,
+)
+
+SENDER_NAME = "qt_tasks_model"
+
+
+class RefreshThread(QtCore.QThread):
+ """Thread for refreshing tasks.
+
+ Call controller to get tasks and emit signal when finished.
+
+ Args:
+ controller (AbstractWorkfilesFrontend): The control object.
+ folder_id (str): Folder id.
+ """
+
+ refresh_finished = QtCore.Signal(str)
+
+ def __init__(self, controller, folder_id):
+ super(RefreshThread, self).__init__()
+ self._id = uuid.uuid4().hex
+ self._controller = controller
+ self._folder_id = folder_id
+ self._result = None
+
+ @property
+ def id(self):
+ return self._id
+
+ def run(self):
+ self._result = self._controller.get_task_items(
+ self._folder_id, SENDER_NAME)
+ self.refresh_finished.emit(self.id)
+
+ def get_result(self):
+ return self._result
+
+
+class TasksModel(QtGui.QStandardItemModel):
+ """Tasks model which cares about refresh of tasks by folder id.
+
+ Args:
+ controller (AbstractWorkfilesFrontend): The control object.
+ """
+
+ refreshed = QtCore.Signal()
+
+ def __init__(self, controller):
+ super(TasksModel, self).__init__()
+
+ self._controller = controller
+
+ self._items_by_name = {}
+ self._has_content = False
+ self._is_refreshing = False
+
+ self._invalid_selection_item_used = False
+ self._invalid_selection_item = None
+ self._empty_tasks_item_used = False
+ self._empty_tasks_item = None
+
+ self._last_folder_id = None
+
+ self._refresh_threads = {}
+ self._current_refresh_thread = None
+
+ # Initial state
+ self._add_invalid_selection_item()
+
+ def clear(self):
+ self._items_by_name = {}
+ self._has_content = False
+ self._remove_invalid_items()
+ super(TasksModel, self).clear()
+
+ def refresh(self, folder_id):
+ """Refresh tasks for folder.
+
+ Args:
+ folder_id (Union[str, None]): Folder id.
+ """
+
+ self._refresh(folder_id)
+
+ def get_index_by_name(self, task_name):
+ """Find item by name and return its index.
+
+ Returns:
+ QtCore.QModelIndex: Index of item. Is invalid if task is not
+ found by name.
+ """
+
+ item = self._items_by_name.get(task_name)
+ if item is None:
+ return QtCore.QModelIndex()
+ return self.indexFromItem(item)
+
+ def get_last_folder_id(self):
+ """Get last refreshed folder id.
+
+ Returns:
+ Union[str, None]: Folder id.
+ """
+
+ return self._last_folder_id
+
+ def _get_invalid_selection_item(self):
+ if self._invalid_selection_item is None:
+ item = QtGui.QStandardItem("Select a folder")
+ item.setFlags(QtCore.Qt.NoItemFlags)
+ icon = qtawesome.icon(
+ "fa.times",
+ color=get_disabled_entity_icon_color()
+ )
+ item.setData(icon, QtCore.Qt.DecorationRole)
+ self._invalid_selection_item = item
+ return self._invalid_selection_item
+
+ def _get_empty_task_item(self):
+ if self._empty_tasks_item is None:
+ item = QtGui.QStandardItem("No task")
+ icon = qtawesome.icon(
+ "fa.exclamation-circle",
+ color=get_disabled_entity_icon_color()
+ )
+ item.setData(icon, QtCore.Qt.DecorationRole)
+ item.setFlags(QtCore.Qt.NoItemFlags)
+ self._empty_tasks_item = item
+ return self._empty_tasks_item
+
+ def _add_invalid_item(self, item):
+ self.clear()
+ root_item = self.invisibleRootItem()
+ root_item.appendRow(item)
+
+ def _remove_invalid_item(self, item):
+ root_item = self.invisibleRootItem()
+ root_item.takeRow(item.row())
+
+ def _remove_invalid_items(self):
+ self._remove_invalid_selection_item()
+ self._remove_empty_task_item()
+
+ def _add_invalid_selection_item(self):
+ if not self._invalid_selection_item_used:
+ self._add_invalid_item(self._get_invalid_selection_item())
+ self._invalid_selection_item_used = True
+
+ def _remove_invalid_selection_item(self):
+ if self._invalid_selection_item:
+ self._remove_invalid_item(self._get_invalid_selection_item())
+ self._invalid_selection_item_used = False
+
+ def _add_empty_task_item(self):
+ if not self._empty_tasks_item_used:
+ self._add_invalid_item(self._get_empty_task_item())
+ self._empty_tasks_item_used = True
+
+ def _remove_empty_task_item(self):
+ if self._empty_tasks_item_used:
+ self._remove_invalid_item(self._get_empty_task_item())
+ self._empty_tasks_item_used = False
+
+ def _refresh(self, folder_id):
+ self._is_refreshing = True
+ self._last_folder_id = folder_id
+ if not folder_id:
+ self._add_invalid_selection_item()
+ self._current_refresh_thread = None
+ self._is_refreshing = False
+ self.refreshed.emit()
+ return
+
+ thread = RefreshThread(self._controller, folder_id)
+ self._current_refresh_thread = thread.id
+ self._refresh_threads[thread.id] = thread
+ thread.refresh_finished.connect(self._on_refresh_thread)
+ thread.start()
+
+ def _on_refresh_thread(self, thread_id):
+ """Callback when refresh thread is finished.
+
+ Technically can be running multiple refresh threads at the same time,
+ to avoid using values from wrong thread, we check if thread id is
+ current refresh thread id.
+
+ Tasks are stored by name, so if a folder has same task name as
+ previously selected folder it keeps the selection.
+
+ Args:
+ thread_id (str): Thread id.
+ """
+
+ thread = self._refresh_threads.pop(thread_id)
+ if thread_id != self._current_refresh_thread:
+ return
+
+ task_items = thread.get_result()
+ # Task items are refreshed
+ if task_items is None:
+ return
+
+ # No tasks are available on folder
+ if not task_items:
+ self._add_empty_task_item()
+ return
+ self._remove_invalid_items()
+
+ new_items = []
+ new_names = set()
+ for task_item in task_items:
+ name = task_item.name
+ new_names.add(name)
+ item = self._items_by_name.get(name)
+ if item is None:
+ item = QtGui.QStandardItem()
+ item.setEditable(False)
+ new_items.append(item)
+ self._items_by_name[name] = item
+
+ # TODO cache locally
+ icon = qtawesome.icon(
+ task_item.icon_name,
+ color=task_item.icon_color,
+ )
+ item.setData(task_item.label, QtCore.Qt.DisplayRole)
+ item.setData(name, ITEM_NAME_ROLE)
+ item.setData(task_item.id, ITEM_ID_ROLE)
+ item.setData(task_item.parent_id, PARENT_ID_ROLE)
+ item.setData(icon, QtCore.Qt.DecorationRole)
+
+ root_item = self.invisibleRootItem()
+
+ for name in set(self._items_by_name) - new_names:
+ item = self._items_by_name.pop(name)
+ root_item.removeRow(item.row())
+
+ if new_items:
+ root_item.appendRows(new_items)
+
+ self._has_content = root_item.rowCount() > 0
+ self._is_refreshing = False
+ self.refreshed.emit()
+
+ @property
+ def is_refreshing(self):
+ """Model is refreshing.
+
+ Returns:
+ bool: Model is refreshing
+ """
+
+ return self._is_refreshing
+
+ @property
+ def has_content(self):
+ """Model has content.
+
+ Returns:
+ bools: Have at least one task.
+ """
+
+ return self._has_content
+
+ def headerData(self, section, orientation, role):
+ # Show nice labels in the header
+ if (
+ role == QtCore.Qt.DisplayRole
+ and orientation == QtCore.Qt.Horizontal
+ ):
+ if section == 0:
+ return "Tasks"
+
+ return super(TasksModel, self).headerData(
+ section, orientation, role
+ )
+
+
+class TasksWidget(QtWidgets.QWidget):
+ """Tasks widget.
+
+ Widget that handles tasks view, model and selection.
+
+ Args:
+ controller (AbstractWorkfilesFrontend): Workfiles controller.
+ """
+
+ def __init__(self, controller, parent):
+ super(TasksWidget, self).__init__(parent)
+
+ tasks_view = DeselectableTreeView(self)
+ tasks_view.setIndentation(0)
+
+ tasks_model = TasksModel(controller)
+ tasks_proxy_model = QtCore.QSortFilterProxyModel()
+ tasks_proxy_model.setSourceModel(tasks_model)
+
+ tasks_view.setModel(tasks_proxy_model)
+
+ main_layout = QtWidgets.QHBoxLayout(self)
+ main_layout.setContentsMargins(0, 0, 0, 0)
+ main_layout.addWidget(tasks_view, 1)
+
+ controller.register_event_callback(
+ "tasks.refresh.finished",
+ self._on_tasks_refresh_finished
+ )
+ controller.register_event_callback(
+ "selection.folder.changed",
+ self._folder_selection_changed
+ )
+ controller.register_event_callback(
+ "expected_selection_changed",
+ self._on_expected_selection_change
+ )
+
+ selection_model = tasks_view.selectionModel()
+ selection_model.selectionChanged.connect(self._on_selection_change)
+
+ tasks_model.refreshed.connect(self._on_tasks_model_refresh)
+
+ self._controller = controller
+ self._tasks_view = tasks_view
+ self._tasks_model = tasks_model
+ self._tasks_proxy_model = tasks_proxy_model
+
+ self._selected_folder_id = None
+
+ self._expected_selection_data = None
+
+ def _clear(self):
+ self._tasks_model.clear()
+
+ def _on_tasks_refresh_finished(self, event):
+ """Tasks were refreshed in controller.
+
+ Ignore if refresh was triggered by tasks model, or refreshed folder is
+ not the same as currently selected folder.
+
+ Args:
+ event (Event): Event object.
+ """
+
+ # Refresh only if current folder id is the same
+ if (
+ event["sender"] == SENDER_NAME
+ or event["folder_id"] != self._selected_folder_id
+ ):
+ return
+ self._tasks_model.refresh(self._selected_folder_id)
+
+ def _folder_selection_changed(self, event):
+ self._selected_folder_id = event["folder_id"]
+ self._tasks_model.refresh(self._selected_folder_id)
+
+ def _on_tasks_model_refresh(self):
+ if not self._set_expected_selection():
+ self._on_selection_change()
+ self._tasks_proxy_model.sort(0)
+
+ def _set_expected_selection(self):
+ if self._expected_selection_data is None:
+ return False
+ folder_id = self._expected_selection_data["folder_id"]
+ task_name = self._expected_selection_data["task_name"]
+ self._expected_selection_data = None
+ model_folder_id = self._tasks_model.get_last_folder_id()
+ if folder_id != model_folder_id:
+ return False
+ if task_name is not None:
+ index = self._tasks_model.get_index_by_name(task_name)
+ if index.isValid():
+ proxy_index = self._tasks_proxy_model.mapFromSource(index)
+ self._tasks_view.setCurrentIndex(proxy_index)
+ self._controller.expected_task_selected(folder_id, task_name)
+ return True
+
+ def _on_expected_selection_change(self, event):
+ if event["task_selected"] or not event["folder_selected"]:
+ return
+
+ model_folder_id = self._tasks_model.get_last_folder_id()
+ folder_id = event["folder_id"]
+ self._expected_selection_data = {
+ "task_name": event["task_name"],
+ "folder_id": folder_id,
+ }
+
+ if folder_id != model_folder_id or self._tasks_model.is_refreshing:
+ return
+ self._set_expected_selection()
+
+ def _get_selected_item_ids(self):
+ selection_model = self._tasks_view.selectionModel()
+ for index in selection_model.selectedIndexes():
+ task_id = index.data(ITEM_ID_ROLE)
+ task_name = index.data(ITEM_NAME_ROLE)
+ parent_id = index.data(PARENT_ID_ROLE)
+ if task_name is not None:
+ return parent_id, task_id, task_name
+ return self._selected_folder_id, None, None
+
+ def _on_selection_change(self):
+ # Don't trigger task change during refresh
+ # - a task was deselected if that happens
+ # - can cause crash triggered during tasks refreshing
+ if self._tasks_model.is_refreshing:
+ return
+ parent_id, task_id, task_name = self._get_selected_item_ids()
+ self._controller.set_selected_task(parent_id, task_id, task_name)
diff --git a/openpype/tools/ayon_workfiles/widgets/utils.py b/openpype/tools/ayon_workfiles/widgets/utils.py
new file mode 100644
index 0000000000..6a61239f8d
--- /dev/null
+++ b/openpype/tools/ayon_workfiles/widgets/utils.py
@@ -0,0 +1,94 @@
+from qtpy import QtWidgets, QtCore
+from openpype.tools.flickcharm import FlickCharm
+
+
+class TreeView(QtWidgets.QTreeView):
+ """Ultimate TreeView with flick charm and double click signals.
+
+ Tree view have deselectable mode, which allows to deselect items by
+ clicking on item area without any items.
+
+ Todos:
+ Add to tools utils.
+ """
+
+ double_clicked_left = QtCore.Signal()
+ double_clicked_right = QtCore.Signal()
+
+ def __init__(self, *args, **kwargs):
+ super(TreeView, self).__init__(*args, **kwargs)
+ self._deselectable = False
+
+ self._flick_charm_activated = False
+ self._flick_charm = FlickCharm(parent=self)
+ self._before_flick_scroll_mode = None
+
+ def is_deselectable(self):
+ return self._deselectable
+
+ def set_deselectable(self, deselectable):
+ self._deselectable = deselectable
+
+ deselectable = property(is_deselectable, set_deselectable)
+
+ def mousePressEvent(self, event):
+ if self._deselectable:
+ index = self.indexAt(event.pos())
+ if not index.isValid():
+ # clear the selection
+ self.clearSelection()
+ # clear the current index
+ self.setCurrentIndex(QtCore.QModelIndex())
+ super(TreeView, self).mousePressEvent(event)
+
+ def mouseDoubleClickEvent(self, event):
+ if event.button() == QtCore.Qt.LeftButton:
+ self.double_clicked_left.emit()
+
+ elif event.button() == QtCore.Qt.RightButton:
+ self.double_clicked_right.emit()
+
+ return super(TreeView, self).mouseDoubleClickEvent(event)
+
+ def activate_flick_charm(self):
+ if self._flick_charm_activated:
+ return
+ self._flick_charm_activated = True
+ self._before_flick_scroll_mode = self.verticalScrollMode()
+ self._flick_charm.activateOn(self)
+ self.setVerticalScrollMode(QtWidgets.QAbstractItemView.ScrollPerPixel)
+
+ def deactivate_flick_charm(self):
+ if not self._flick_charm_activated:
+ return
+ self._flick_charm_activated = False
+ self._flick_charm.deactivateFrom(self)
+ if self._before_flick_scroll_mode is not None:
+ self.setVerticalScrollMode(self._before_flick_scroll_mode)
+
+
+class BaseOverlayFrame(QtWidgets.QFrame):
+ """Base frame for overlay widgets.
+
+ Has implemented automated resize and event filtering.
+ """
+
+ def __init__(self, parent):
+ super(BaseOverlayFrame, self).__init__(parent)
+ self.setObjectName("OverlayFrame")
+
+ self._parent = parent
+
+ def setVisible(self, visible):
+ super(BaseOverlayFrame, self).setVisible(visible)
+ if visible:
+ self._parent.installEventFilter(self)
+ self.resize(self._parent.size())
+ else:
+ self._parent.removeEventFilter(self)
+
+ def eventFilter(self, obj, event):
+ if event.type() == QtCore.QEvent.Resize:
+ self.resize(obj.size())
+
+ return super(BaseOverlayFrame, self).eventFilter(obj, event)
diff --git a/openpype/tools/ayon_workfiles/widgets/window.py b/openpype/tools/ayon_workfiles/widgets/window.py
new file mode 100644
index 0000000000..ef352c8b18
--- /dev/null
+++ b/openpype/tools/ayon_workfiles/widgets/window.py
@@ -0,0 +1,400 @@
+from qtpy import QtCore, QtWidgets, QtGui
+
+from openpype import style, resources
+from openpype.tools.utils import (
+ PlaceholderLineEdit,
+ MessageOverlayObject,
+)
+from openpype.tools.utils.lib import get_qta_icon_by_name_and_color
+
+from openpype.tools.ayon_workfiles.control import BaseWorkfileController
+
+from .side_panel import SidePanelWidget
+from .folders_widget import FoldersWidget
+from .tasks_widget import TasksWidget
+from .files_widget import FilesWidget
+from .utils import BaseOverlayFrame
+
+
+# TODO move to utils
+# from openpype.tools.utils.lib import (
+# get_refresh_icon, get_go_to_current_icon)
+def get_refresh_icon():
+ return get_qta_icon_by_name_and_color(
+ "fa.refresh", style.get_default_tools_icon_color()
+ )
+
+
+def get_go_to_current_icon():
+ return get_qta_icon_by_name_and_color(
+ "fa.arrow-down", style.get_default_tools_icon_color()
+ )
+
+
+class InvalidHostOverlay(BaseOverlayFrame):
+ def __init__(self, parent):
+ super(InvalidHostOverlay, self).__init__(parent)
+
+ label_widget = QtWidgets.QLabel(
+ (
+ "Workfiles tool is not supported in this host/DCCs."
+ "
This may be caused by a bug."
+ " Please contact your TD for more information."
+ ),
+ self
+ )
+ label_widget.setAlignment(QtCore.Qt.AlignCenter)
+ label_widget.setObjectName("OverlayFrameLabel")
+
+ layout = QtWidgets.QVBoxLayout(self)
+ layout.addStretch(2)
+ layout.addWidget(label_widget, 0, QtCore.Qt.AlignCenter)
+ layout.addStretch(3)
+
+ label_widget.setAttribute(QtCore.Qt.WA_TranslucentBackground)
+
+
+class WorkfilesToolWindow(QtWidgets.QWidget):
+ """WorkFiles Window.
+
+ Main windows of workfiles tool.
+
+ Args:
+ controller (AbstractWorkfilesFrontend): Frontend controller.
+ parent (Optional[QtWidgets.QWidget]): Parent widget.
+ """
+
+ title = "Work Files"
+
+ def __init__(self, controller=None, parent=None):
+ super(WorkfilesToolWindow, self).__init__(parent=parent)
+
+ if controller is None:
+ controller = BaseWorkfileController()
+
+ self.setWindowTitle(self.title)
+ icon = QtGui.QIcon(resources.get_openpype_icon_filepath())
+ self.setWindowIcon(icon)
+ flags = self.windowFlags() | QtCore.Qt.Window
+ self.setWindowFlags(flags)
+
+ self._default_window_flags = flags
+
+ self._folder_widget = None
+ self._folder_filter_input = None
+
+ self._files_widget = None
+
+ self._first_show = True
+ self._controller_refreshed = False
+ self._context_to_set = None
+ # Host validation should happen only once
+ self._host_is_valid = None
+
+ self._controller = controller
+
+ # Create pages widget and set it as central widget
+ pages_widget = QtWidgets.QStackedWidget(self)
+
+ home_page_widget = QtWidgets.QWidget(pages_widget)
+ home_body_widget = QtWidgets.QWidget(home_page_widget)
+
+ col_1_widget = self._create_col_1_widget(controller, parent)
+ tasks_widget = TasksWidget(controller, home_body_widget)
+ col_3_widget = self._create_col_3_widget(controller, home_body_widget)
+ side_panel = SidePanelWidget(controller, home_body_widget)
+
+ pages_widget.addWidget(home_page_widget)
+
+ # Build home
+ home_page_layout = QtWidgets.QVBoxLayout(home_page_widget)
+ home_page_layout.addWidget(home_body_widget)
+
+ # Build home - body
+ body_layout = QtWidgets.QVBoxLayout(home_body_widget)
+ split_widget = QtWidgets.QSplitter(home_body_widget)
+ split_widget.addWidget(col_1_widget)
+ split_widget.addWidget(tasks_widget)
+ split_widget.addWidget(col_3_widget)
+ split_widget.addWidget(side_panel)
+ split_widget.setSizes([255, 160, 455, 175])
+
+ body_layout.addWidget(split_widget)
+
+ main_layout = QtWidgets.QHBoxLayout(self)
+ main_layout.addWidget(pages_widget, 1)
+
+ overlay_messages_widget = MessageOverlayObject(self)
+ overlay_invalid_host = InvalidHostOverlay(self)
+ overlay_invalid_host.setVisible(False)
+
+ first_show_timer = QtCore.QTimer()
+ first_show_timer.setSingleShot(True)
+ first_show_timer.setInterval(50)
+
+ first_show_timer.timeout.connect(self._on_first_show)
+
+ controller.register_event_callback(
+ "save_as.finished",
+ self._on_save_as_finished,
+ )
+ controller.register_event_callback(
+ "copy_representation.finished",
+ self._on_copy_representation_finished,
+ )
+ controller.register_event_callback(
+ "workfile_duplicate.finished",
+ self._on_duplicate_finished
+ )
+ controller.register_event_callback(
+ "open_workfile.finished",
+ self._on_open_finished
+ )
+ controller.register_event_callback(
+ "controller.refresh.started",
+ self._on_controller_refresh_started,
+ )
+ controller.register_event_callback(
+ "controller.refresh.finished",
+ self._on_controller_refresh_finished,
+ )
+
+ self._overlay_messages_widget = overlay_messages_widget
+ self._overlay_invalid_host = overlay_invalid_host
+ self._home_page_widget = home_page_widget
+ self._pages_widget = pages_widget
+ self._home_body_widget = home_body_widget
+ self._split_widget = split_widget
+
+ self._tasks_widget = tasks_widget
+ self._side_panel = side_panel
+
+ self._first_show_timer = first_show_timer
+
+ self._post_init()
+
+ def _post_init(self):
+ self._on_published_checkbox_changed()
+
+ # Force focus on the open button by default, required for Houdini.
+ self._files_widget.setFocus()
+
+ self.resize(1200, 600)
+
+ def _create_col_1_widget(self, controller, parent):
+ col_widget = QtWidgets.QWidget(parent)
+ header_widget = QtWidgets.QWidget(col_widget)
+
+ folder_filter_input = PlaceholderLineEdit(header_widget)
+ folder_filter_input.setPlaceholderText("Filter folders..")
+
+ go_to_current_btn = QtWidgets.QPushButton(header_widget)
+ go_to_current_btn.setIcon(get_go_to_current_icon())
+ go_to_current_btn_sp = go_to_current_btn.sizePolicy()
+ go_to_current_btn_sp.setVerticalPolicy(QtWidgets.QSizePolicy.Minimum)
+ go_to_current_btn.setSizePolicy(go_to_current_btn_sp)
+
+ refresh_btn = QtWidgets.QPushButton(header_widget)
+ refresh_btn.setIcon(get_refresh_icon())
+ refresh_btn_sp = refresh_btn.sizePolicy()
+ refresh_btn_sp.setVerticalPolicy(QtWidgets.QSizePolicy.Minimum)
+ refresh_btn.setSizePolicy(refresh_btn_sp)
+
+ folder_widget = FoldersWidget(controller, col_widget)
+
+ header_layout = QtWidgets.QHBoxLayout(header_widget)
+ header_layout.setContentsMargins(0, 0, 0, 0)
+ header_layout.addWidget(folder_filter_input, 1)
+ header_layout.addWidget(go_to_current_btn, 0)
+ header_layout.addWidget(refresh_btn, 0)
+
+ col_layout = QtWidgets.QVBoxLayout(col_widget)
+ col_layout.setContentsMargins(0, 0, 0, 0)
+ col_layout.addWidget(header_widget, 0)
+ col_layout.addWidget(folder_widget, 1)
+
+ folder_filter_input.textChanged.connect(self._on_folder_filter_change)
+ go_to_current_btn.clicked.connect(self._on_go_to_current_clicked)
+ refresh_btn.clicked.connect(self._on_refresh_clicked)
+
+ self._folder_filter_input = folder_filter_input
+ self._folder_widget = folder_widget
+
+ return col_widget
+
+ def _create_col_3_widget(self, controller, parent):
+ col_widget = QtWidgets.QWidget(parent)
+
+ header_widget = QtWidgets.QWidget(col_widget)
+
+ files_filter_input = PlaceholderLineEdit(header_widget)
+ files_filter_input.setPlaceholderText("Filter files..")
+
+ published_checkbox = QtWidgets.QCheckBox("Published", header_widget)
+ published_checkbox.setToolTip("Show published workfiles")
+
+ header_layout = QtWidgets.QHBoxLayout(header_widget)
+ header_layout.setContentsMargins(0, 0, 0, 0)
+ header_layout.addWidget(files_filter_input, 1)
+ header_layout.addWidget(published_checkbox, 0)
+
+ files_widget = FilesWidget(controller, col_widget)
+
+ col_layout = QtWidgets.QVBoxLayout(col_widget)
+ col_layout.setContentsMargins(0, 0, 0, 0)
+ col_layout.addWidget(header_widget, 0)
+ col_layout.addWidget(files_widget, 1)
+
+ files_filter_input.textChanged.connect(
+ self._on_file_text_filter_change)
+ published_checkbox.stateChanged.connect(
+ self._on_published_checkbox_changed
+ )
+
+ self._files_filter_input = files_filter_input
+ self._published_checkbox = published_checkbox
+
+ self._files_widget = files_widget
+
+ return col_widget
+
+ def set_window_on_top(self, on_top):
+ """Set window on top of other windows.
+
+ Args:
+ on_top (bool): Show on top of other windows.
+ """
+
+ flags = self._default_window_flags
+ if on_top:
+ flags |= QtCore.Qt.WindowStaysOnTopHint
+ if self.windowFlags() != flags:
+ self.setWindowFlags(flags)
+
+ def ensure_visible(self, use_context=True, save=True, on_top=False):
+ """Ensure the window is visible.
+
+ This method expects arguments for compatibility with previous variant
+ of Workfiles tool.
+
+ Args:
+ use_context (Optional[bool]): DEPRECATED: This argument is
+ ignored.
+ save (Optional[bool]): Allow to save workfiles.
+ on_top (Optional[bool]): Show on top of other windows.
+ """
+
+ save = True if save is None else save
+ on_top = False if on_top is None else on_top
+
+ is_visible = self.isVisible()
+ self._controller.set_save_enabled(save)
+ self.set_window_on_top(on_top)
+
+ self.show()
+ self.raise_()
+ self.activateWindow()
+ if is_visible:
+ self.refresh()
+
+ def refresh(self):
+ """Trigger refresh of workfiles tool controller."""
+
+ self._controller.refresh()
+
+ def showEvent(self, event):
+ super(WorkfilesToolWindow, self).showEvent(event)
+ if self._first_show:
+ self._first_show = False
+ self._first_show_timer.start()
+ self.setStyleSheet(style.load_stylesheet())
+
+ def keyPressEvent(self, event):
+ """Custom keyPressEvent.
+
+ Override keyPressEvent to do nothing so that Maya's panels won't
+ take focus when pressing "SHIFT" whilst mouse is over viewport or
+ outliner. This way users don't accidentally perform Maya commands
+ whilst trying to name an instance.
+ """
+
+ pass
+
+ def _on_first_show(self):
+ if not self._controller_refreshed:
+ self.refresh()
+
+ def _on_file_text_filter_change(self, text):
+ self._files_widget.set_text_filter(text)
+
+ def _on_published_checkbox_changed(self):
+ """Publish mode changed.
+
+ Tell children widgets about it so they can handle the mode.
+ """
+
+ published_mode = self._published_checkbox.isChecked()
+ self._files_widget.set_published_mode(published_mode)
+ self._side_panel.set_published_mode(published_mode)
+
+ def _on_folder_filter_change(self, text):
+ self._folder_widget.set_name_filer(text)
+
+ def _on_go_to_current_clicked(self):
+ self._controller.go_to_current_context()
+
+ def _on_refresh_clicked(self):
+ self.refresh()
+
+ def _on_controller_refresh_started(self):
+ self._controller_refreshed = True
+
+ def _on_controller_refresh_finished(self):
+ if self._host_is_valid is None:
+ self._host_is_valid = self._controller.is_host_valid()
+ self._overlay_invalid_host.setVisible(not self._host_is_valid)
+
+ if not self._host_is_valid:
+ return
+
+ def _on_save_as_finished(self, event):
+ if event["failed"]:
+ self._overlay_messages_widget.add_message(
+ "Failed to save workfile",
+ "error",
+ )
+ else:
+ self._overlay_messages_widget.add_message(
+ "Workfile saved"
+ )
+
+ def _on_copy_representation_finished(self, event):
+ if event["failed"]:
+ self._overlay_messages_widget.add_message(
+ "Failed to copy published workfile",
+ "error",
+ )
+ else:
+ self._overlay_messages_widget.add_message(
+ "Publish workfile saved"
+ )
+
+ def _on_duplicate_finished(self, event):
+ if event["failed"]:
+ self._overlay_messages_widget.add_message(
+ "Failed to duplicate workfile",
+ "error",
+ )
+ else:
+ self._overlay_messages_widget.add_message(
+ "Workfile duplicated"
+ )
+
+ def _on_open_finished(self, event):
+ if event["failed"]:
+ self._overlay_messages_widget.add_message(
+ "Failed to open workfile",
+ "error",
+ )
+ else:
+ self.close()
diff --git a/openpype/tools/publisher/widgets/screenshot_widget.py b/openpype/tools/publisher/widgets/screenshot_widget.py
index 4ccf920571..64cccece6c 100644
--- a/openpype/tools/publisher/widgets/screenshot_widget.py
+++ b/openpype/tools/publisher/widgets/screenshot_widget.py
@@ -31,7 +31,6 @@ class ScreenMarquee(QtWidgets.QDialog):
fade_anim.setEndValue(50)
fade_anim.setDuration(200)
fade_anim.setEasingCurve(QtCore.QEasingCurve.OutCubic)
- fade_anim.start(QtCore.QAbstractAnimation.DeleteWhenStopped)
fade_anim.valueChanged.connect(self._on_fade_anim)
@@ -46,7 +45,7 @@ class ScreenMarquee(QtWidgets.QDialog):
for screen in QtWidgets.QApplication.screens():
screen.geometryChanged.connect(self._fit_screen_geometry)
- self._opacity = fade_anim.currentValue()
+ self._opacity = fade_anim.startValue()
self._click_pos = None
self._capture_rect = None
diff --git a/openpype/tools/utils/host_tools.py b/openpype/tools/utils/host_tools.py
index bc4b7867c2..2ebc973a47 100644
--- a/openpype/tools/utils/host_tools.py
+++ b/openpype/tools/utils/host_tools.py
@@ -6,6 +6,8 @@ use singleton approach with global functions (using helper anyway).
import os
import pyblish.api
+
+from openpype import AYON_SERVER_ENABLED
from openpype.host import IWorkfileHost, ILoadHost
from openpype.lib import Logger
from openpype.pipeline import (
@@ -46,17 +48,29 @@ class HostToolsHelper:
self._log = Logger.get_logger(self.__class__.__name__)
return self._log
+ def _init_ayon_workfiles_tool(self, parent):
+ from openpype.tools.ayon_workfiles.widgets import WorkfilesToolWindow
+
+ workfiles_window = WorkfilesToolWindow(parent=parent)
+ self._workfiles_tool = workfiles_window
+
+ def _init_openpype_workfiles_tool(self, parent):
+ from openpype.tools.workfiles.app import Window
+
+ # Host validation
+ host = registered_host()
+ IWorkfileHost.validate_workfile_methods(host)
+
+ workfiles_window = Window(parent=parent)
+ self._workfiles_tool = workfiles_window
+
def get_workfiles_tool(self, parent):
"""Create, cache and return workfiles tool window."""
if self._workfiles_tool is None:
- from openpype.tools.workfiles.app import Window
-
- # Host validation
- host = registered_host()
- IWorkfileHost.validate_workfile_methods(host)
-
- workfiles_window = Window(parent=parent)
- self._workfiles_tool = workfiles_window
+ if AYON_SERVER_ENABLED:
+ self._init_ayon_workfiles_tool(parent)
+ else:
+ self._init_openpype_workfiles_tool(parent)
return self._workfiles_tool
diff --git a/openpype/version.py b/openpype/version.py
index 466f9ce033..c4a87e7843 100644
--- a/openpype/version.py
+++ b/openpype/version.py
@@ -1,3 +1,3 @@
# -*- coding: utf-8 -*-
"""Package declaring Pype version."""
-__version__ = "3.16.5-nightly.5"
+__version__ = "3.16.7-nightly.1"
diff --git a/pyproject.toml b/pyproject.toml
index a07c547123..f859e1aff4 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -1,6 +1,6 @@
[tool.poetry]
name = "OpenPype"
-version = "3.16.4" # OpenPype
+version = "3.16.6" # OpenPype
description = "Open VFX and Animation pipeline with support."
authors = ["OpenPype Team "]
license = "MIT License"
diff --git a/server_addon/houdini/server/settings/publish_plugins.py b/server_addon/houdini/server/settings/publish_plugins.py
index 7d35d7e634..4534d8d0d9 100644
--- a/server_addon/houdini/server/settings/publish_plugins.py
+++ b/server_addon/houdini/server/settings/publish_plugins.py
@@ -120,7 +120,7 @@ class ValidateWorkfilePathsModel(BaseSettingsModel):
)
-class ValidateContainersModel(BaseSettingsModel):
+class BasicValidateModel(BaseSettingsModel):
enabled: bool = Field(title="Enabled")
optional: bool = Field(title="Optional")
active: bool = Field(title="Active")
@@ -130,8 +130,11 @@ class PublishPluginsModel(BaseSettingsModel):
ValidateWorkfilePaths: ValidateWorkfilePathsModel = Field(
default_factory=ValidateWorkfilePathsModel,
title="Validate workfile paths settings.")
- ValidateContainers: ValidateContainersModel = Field(
- default_factory=ValidateContainersModel,
+ ValidateReviewColorspace: BasicValidateModel = Field(
+ default_factory=BasicValidateModel,
+ title="Validate Review Colorspace.")
+ ValidateContainers: BasicValidateModel = Field(
+ default_factory=BasicValidateModel,
title="Validate Latest Containers.")
@@ -148,6 +151,11 @@ DEFAULT_HOUDINI_PUBLISH_SETTINGS = {
"$JOB"
]
},
+ "ValidateReviewColorspace": {
+ "enabled": True,
+ "optional": True,
+ "active": True
+ },
"ValidateContainers": {
"enabled": True,
"optional": True,
diff --git a/server_addon/houdini/server/version.py b/server_addon/houdini/server/version.py
index 485f44ac21..b3f4756216 100644
--- a/server_addon/houdini/server/version.py
+++ b/server_addon/houdini/server/version.py
@@ -1 +1 @@
-__version__ = "0.1.1"
+__version__ = "0.1.2"
diff --git a/server_addon/nuke/server/settings/common.py b/server_addon/nuke/server/settings/common.py
index 700f01f3dc..2bc3c9be81 100644
--- a/server_addon/nuke/server/settings/common.py
+++ b/server_addon/nuke/server/settings/common.py
@@ -89,12 +89,6 @@ knob_types_enum = [
class KnobModel(BaseSettingsModel):
- """# TODO: new data structure
- - v3 was having type, name, value but
- ayon is not able to make it the same. Current model is
- defining `type` as `text` and instead of `value` the key is `text`.
- So if `type` is `boolean` then key is `boolean` (value).
- """
_layout = "expanded"
type: str = Field(
diff --git a/server_addon/nuke/server/settings/create_plugins.py b/server_addon/nuke/server/settings/create_plugins.py
index 0bbae4ee77..80aec51ae0 100644
--- a/server_addon/nuke/server/settings/create_plugins.py
+++ b/server_addon/nuke/server/settings/create_plugins.py
@@ -16,13 +16,10 @@ def instance_attributes_enum():
class PrenodeModel(BaseSettingsModel):
- # TODO: missing in host api
- # - good for `dependency`
name: str = Field(
title="Node name"
)
- # TODO: `nodeclass` should be renamed to `nuke_node_class`
nodeclass: str = Field(
"",
title="Node class"
@@ -32,11 +29,8 @@ class PrenodeModel(BaseSettingsModel):
title="Incoming dependency"
)
- """# TODO: Changes in host api:
- - Need complete rework of knob types in nuke integration.
- - We could not support v3 style of settings.
- """
knobs: list[KnobModel] = Field(
+ default_factory=list,
title="Knobs",
)
@@ -61,11 +55,8 @@ class CreateWriteRenderModel(BaseSettingsModel):
title="Instance attributes"
)
- """# TODO: Changes in host api:
- - prenodes key was originally dict and now is list
- (we could not support v3 style of settings)
- """
prenodes: list[PrenodeModel] = Field(
+ default_factory=list,
title="Preceding nodes",
)
@@ -90,11 +81,8 @@ class CreateWritePrerenderModel(BaseSettingsModel):
title="Instance attributes"
)
- """# TODO: Changes in host api:
- - prenodes key was originally dict and now is list
- (we could not support v3 style of settings)
- """
prenodes: list[PrenodeModel] = Field(
+ default_factory=list,
title="Preceding nodes",
)
@@ -119,11 +107,8 @@ class CreateWriteImageModel(BaseSettingsModel):
title="Instance attributes"
)
- """# TODO: Changes in host api:
- - prenodes key was originally dict and now is list
- (we could not support v3 style of settings)
- """
prenodes: list[PrenodeModel] = Field(
+ default_factory=list,
title="Preceding nodes",
)
diff --git a/server_addon/nuke/server/settings/dirmap.py b/server_addon/nuke/server/settings/dirmap.py
index 2da6d7bf60..7e3c443957 100644
--- a/server_addon/nuke/server/settings/dirmap.py
+++ b/server_addon/nuke/server/settings/dirmap.py
@@ -25,19 +25,6 @@ class DirmapSettings(BaseSettingsModel):
)
-"""# TODO:
-nuke is having originally implemented
-following data inputs:
-
-"nuke-dirmap": {
- "enabled": false,
- "paths": {
- "source-path": [],
- "destination-path": []
- }
-}
-"""
-
DEFAULT_DIRMAP_SETTINGS = {
"enabled": False,
"paths": {
diff --git a/server_addon/nuke/server/settings/imageio.py b/server_addon/nuke/server/settings/imageio.py
index b43017ef8b..811b12104b 100644
--- a/server_addon/nuke/server/settings/imageio.py
+++ b/server_addon/nuke/server/settings/imageio.py
@@ -9,22 +9,17 @@ from .common import KnobModel
class NodesModel(BaseSettingsModel):
- """# TODO: This needs to be somehow labeled in settings panel
- or at least it could show gist of configuration
- """
_layout = "expanded"
plugins: list[str] = Field(
+ default_factory=list,
title="Used in plugins"
)
- # TODO: rename `nukeNodeClass` to `nuke_node_class`
nukeNodeClass: str = Field(
title="Nuke Node Class",
)
- """ # TODO: Need complete rework of knob types
- in nuke integration. We could not support v3 style of settings.
- """
knobs: list[KnobModel] = Field(
+ default_factory=list,
title="Knobs",
)
@@ -66,22 +61,6 @@ def ocio_configs_switcher_enum():
class WorkfileColorspaceSettings(BaseSettingsModel):
"""Nuke workfile colorspace preset. """
- """# TODO: enhance settings with host api:
- we need to add mapping to resolve properly keys.
- Nuke is excpecting camel case key names,
- but for better code consistency we need to
- be using snake_case:
-
- color_management = colorManagement
- ocio_config = OCIO_config
- working_space_name = workingSpaceLUT
- monitor_name = monitorLut
- monitor_out_name = monitorOutLut
- int_8_name = int8Lut
- int_16_name = int16Lut
- log_name = logLut
- float_name = floatLut
- """
colorManagement: Literal["Nuke", "OCIO"] = Field(
title="Color Management"
@@ -100,18 +79,6 @@ class WorkfileColorspaceSettings(BaseSettingsModel):
monitorLut: str = Field(
title="Monitor"
)
- int8Lut: str = Field(
- title="8-bit files"
- )
- int16Lut: str = Field(
- title="16-bit files"
- )
- logLut: str = Field(
- title="Log files"
- )
- floatLut: str = Field(
- title="Float files"
- )
class ReadColorspaceRulesItems(BaseSettingsModel):
@@ -170,7 +137,7 @@ class ImageIOSettings(BaseSettingsModel):
_isGroup: bool = True
"""# TODO: enhance settings with host api:
- to restruture settings for simplification.
+ to restructure settings for simplification.
now: nuke/imageio/viewer/viewerProcess
future: nuke/imageio/viewer
@@ -193,7 +160,7 @@ class ImageIOSettings(BaseSettingsModel):
)
"""# TODO: enhance settings with host api:
- to restruture settings for simplification.
+ to restructure settings for simplification.
now: nuke/imageio/baking/viewerProcess
future: nuke/imageio/baking
@@ -215,9 +182,9 @@ class ImageIOSettings(BaseSettingsModel):
title="Nodes"
)
"""# TODO: enhance settings with host api:
- - old settings are using `regexInputs` key but we
+ - [ ] old settings are using `regexInputs` key but we
need to rename to `regex_inputs`
- - no need for `inputs` middle part. It can stay
+ - [ ] no need for `inputs` middle part. It can stay
directly on `regex_inputs`
"""
regexInputs: RegexInputsModel = Field(
@@ -238,10 +205,6 @@ DEFAULT_IMAGEIO_SETTINGS = {
"OCIO_config": "nuke-default",
"workingSpaceLUT": "linear",
"monitorLut": "sRGB",
- "int8Lut": "sRGB",
- "int16Lut": "sRGB",
- "logLut": "Cineon",
- "floatLut": "linear"
},
"nodes": {
"requiredNodes": [
diff --git a/server_addon/nuke/server/settings/loader_plugins.py b/server_addon/nuke/server/settings/loader_plugins.py
index 6db381bffb..51e2c2149b 100644
--- a/server_addon/nuke/server/settings/loader_plugins.py
+++ b/server_addon/nuke/server/settings/loader_plugins.py
@@ -6,10 +6,6 @@ class LoadImageModel(BaseSettingsModel):
enabled: bool = Field(
title="Enabled"
)
- """# TODO: v3 api used `_representation`
- New api is hiding it so it had to be renamed
- to `representations_include`
- """
representations_include: list[str] = Field(
default_factory=list,
title="Include representations"
@@ -33,10 +29,6 @@ class LoadClipModel(BaseSettingsModel):
enabled: bool = Field(
title="Enabled"
)
- """# TODO: v3 api used `_representation`
- New api is hiding it so it had to be renamed
- to `representations_include`
- """
representations_include: list[str] = Field(
default_factory=list,
title="Include representations"
diff --git a/server_addon/nuke/server/settings/main.py b/server_addon/nuke/server/settings/main.py
index 4687d48ac9..cdaaa3a9e2 100644
--- a/server_addon/nuke/server/settings/main.py
+++ b/server_addon/nuke/server/settings/main.py
@@ -59,9 +59,7 @@ class NukeSettings(BaseSettingsModel):
default_factory=ImageIOSettings,
title="Color Management (imageio)",
)
- """# TODO: fix host api:
- - rename `nuke-dirmap` to `dirmap` was inevitable
- """
+
dirmap: DirmapSettings = Field(
default_factory=DirmapSettings,
title="Nuke Directory Mapping",
diff --git a/server_addon/nuke/server/settings/publish_plugins.py b/server_addon/nuke/server/settings/publish_plugins.py
index 7e898f8c9a..c78685534f 100644
--- a/server_addon/nuke/server/settings/publish_plugins.py
+++ b/server_addon/nuke/server/settings/publish_plugins.py
@@ -28,11 +28,9 @@ def nuke_product_types_enum():
class NodeModel(BaseSettingsModel):
- # TODO: missing in host api
name: str = Field(
title="Node name"
)
- # TODO: `nodeclass` rename to `nuke_node_class`
nodeclass: str = Field(
"",
title="Node class"
@@ -41,11 +39,8 @@ class NodeModel(BaseSettingsModel):
"",
title="Incoming dependency"
)
- """# TODO: Changes in host api:
- - Need complete rework of knob types in nuke integration.
- - We could not support v3 style of settings.
- """
knobs: list[KnobModel] = Field(
+ default_factory=list,
title="Knobs",
)
@@ -99,12 +94,9 @@ class ExtractThumbnailModel(BaseSettingsModel):
use_rendered: bool = Field(title="Use rendered images")
bake_viewer_process: bool = Field(title="Bake view process")
bake_viewer_input_process: bool = Field(title="Bake viewer input process")
- """# TODO: needs to rewrite from v3 to ayon
- - `nodes` in v3 was dict but now `prenodes` is list of dict
- - also later `nodes` should be `prenodes`
- """
nodes: list[NodeModel] = Field(
+ default_factory=list,
title="Nodes (deprecated)"
)
reposition_nodes: list[ThumbnailRepositionNodeModel] = Field(
@@ -177,6 +169,7 @@ class ExtractReviewDataMovModel(BaseSettingsModel):
enabled: bool = Field(title="Enabled")
viewer_lut_raw: bool = Field(title="Viewer lut raw")
outputs: list[BakingStreamModel] = Field(
+ default_factory=list,
title="Baking streams"
)
@@ -213,12 +206,6 @@ class ExctractSlateFrameParamModel(BaseSettingsModel):
class ExtractSlateFrameModel(BaseSettingsModel):
viewer_lut_raw: bool = Field(title="Viewer lut raw")
- """# TODO: v3 api different model:
- - not possible to replicate v3 model:
- {"name": [bool, str]}
- - not it is:
- {"name": {"enabled": bool, "template": str}}
- """
key_value_mapping: ExctractSlateFrameParamModel = Field(
title="Key value mapping",
default_factory=ExctractSlateFrameParamModel
@@ -287,7 +274,6 @@ class PublishPuginsModel(BaseSettingsModel):
title="Extract Slate Frame",
default_factory=ExtractSlateFrameModel
)
- # TODO: plugin should be renamed - `workfile` not `script`
IncrementScriptVersion: IncrementScriptVersionModel = Field(
title="Increment Workfile Version",
default_factory=IncrementScriptVersionModel,
diff --git a/server_addon/nuke/server/settings/scriptsmenu.py b/server_addon/nuke/server/settings/scriptsmenu.py
index 9d1c32ebac..0b2d660da5 100644
--- a/server_addon/nuke/server/settings/scriptsmenu.py
+++ b/server_addon/nuke/server/settings/scriptsmenu.py
@@ -17,7 +17,6 @@ class ScriptsmenuSettings(BaseSettingsModel):
"""Nuke script menu project settings."""
_isGroup = True
- # TODO: in api rename key `name` to `menu_name`
name: str = Field(title="Menu Name")
definition: list[ScriptsmenuSubmodel] = Field(
default_factory=list,
diff --git a/server_addon/nuke/server/settings/templated_workfile_build.py b/server_addon/nuke/server/settings/templated_workfile_build.py
index e0245c8d06..0899be841e 100644
--- a/server_addon/nuke/server/settings/templated_workfile_build.py
+++ b/server_addon/nuke/server/settings/templated_workfile_build.py
@@ -28,6 +28,7 @@ class TemplatedWorkfileProfileModel(BaseSettingsModel):
class TemplatedWorkfileBuildModel(BaseSettingsModel):
+ """Settings for templated workfile builder."""
profiles: list[TemplatedWorkfileProfileModel] = Field(
default_factory=list
)
diff --git a/server_addon/nuke/server/settings/workfile_builder.py b/server_addon/nuke/server/settings/workfile_builder.py
index ee67c7c16a..3ae3b08788 100644
--- a/server_addon/nuke/server/settings/workfile_builder.py
+++ b/server_addon/nuke/server/settings/workfile_builder.py
@@ -48,20 +48,32 @@ class BuilderProfileModel(BaseSettingsModel):
title="Task names"
)
current_context: list[BuilderProfileItemModel] = Field(
- title="Current context")
+ default_factory=list,
+ title="Current context"
+ )
linked_assets: list[BuilderProfileItemModel] = Field(
- title="Linked assets/shots")
+ default_factory=list,
+ title="Linked assets/shots"
+ )
class WorkfileBuilderModel(BaseSettingsModel):
+ """[deprecated] use Template Workfile Build Settings instead.
+ """
create_first_version: bool = Field(
title="Create first workfile")
custom_templates: list[CustomTemplateModel] = Field(
- title="Custom templates")
+ default_factory=list,
+ title="Custom templates"
+ )
builder_on_start: bool = Field(
- title="Run Builder at first workfile")
+ default=False,
+ title="Run Builder at first workfile"
+ )
profiles: list[BuilderProfileModel] = Field(
- title="Builder profiles")
+ default_factory=list,
+ title="Builder profiles"
+ )
DEFAULT_WORKFILE_BUILDER_SETTINGS = {
diff --git a/tests/unit/openpype/pipeline/test_colorspace.py b/tests/unit/openpype/pipeline/test_colorspace.py
index c22acee2d4..ac35a28303 100644
--- a/tests/unit/openpype/pipeline/test_colorspace.py
+++ b/tests/unit/openpype/pipeline/test_colorspace.py
@@ -28,10 +28,9 @@ class TestPipelineColorspace(TestPipeline):
cd to OpenPype repo root dir
poetry run python ./start.py runtests ../tests/unit/openpype/pipeline
"""
-
TEST_FILES = [
(
- "1Lf-mFxev7xiwZCWfImlRcw7Fj8XgNQMh",
+ "1csqimz8bbNcNgxtEXklLz6GRv91D3KgA",
"test_pipeline_colorspace.zip",
""
)
diff --git a/website/docs/admin_hosts_aftereffects.md b/website/docs/admin_hosts_aftereffects.md
index 974428fe06..72fdb32faf 100644
--- a/website/docs/admin_hosts_aftereffects.md
+++ b/website/docs/admin_hosts_aftereffects.md
@@ -18,6 +18,10 @@ Location: Settings > Project > AfterEffects
## Publish plugins
+### Collect Review
+
+Enable/disable creation of auto instance of review.
+
### Validate Scene Settings
#### Skip Resolution Check for Tasks
@@ -28,6 +32,10 @@ Set regex pattern(s) to look for in a Task name to skip resolution check against
Set regex pattern(s) to look for in a Task name to skip `frameStart`, `frameEnd` check against values from DB.
+### ValidateContainers
+
+By default this validator will look loaded items with lower version than latest. This validator is context wide so it must be disabled in Context button.
+
### AfterEffects Submit to Deadline
* `Use Published scene` - Set to True (green) when Deadline should take published scene as a source instead of uploaded local one.
diff --git a/website/docs/admin_hosts_photoshop.md b/website/docs/admin_hosts_photoshop.md
index de684f01d2..d79789760e 100644
--- a/website/docs/admin_hosts_photoshop.md
+++ b/website/docs/admin_hosts_photoshop.md
@@ -33,7 +33,6 @@ Provides list of [variants](artist_concepts.md#variant) that will be shown to an
Provides simplified publishing process. It will create single `image` instance for artist automatically. This instance will
produce flatten image from all visible layers in a workfile.
-- Subset template for flatten image - provide template for subset name for this instance (example `imageBeauty`)
- Review - should be separate review created for this instance
### Create Review
@@ -111,11 +110,11 @@ Set Byte limit for review file. Applicable if gigantic `image` instances are pro
#### Extract jpg Options
-Handles tags for produced `.jpg` representation. `Create review` and `Add review to Ftrack` are defaults.
+Handles tags for produced `.jpg` representation. `Create review` and `Add review to Ftrack` are defaults.
#### Extract mov Options
-Handles tags for produced `.mov` representation. `Create review` and `Add review to Ftrack` are defaults.
+Handles tags for produced `.mov` representation. `Create review` and `Add review to Ftrack` are defaults.
### Workfile Builder
@@ -124,4 +123,4 @@ Allows to open prepared workfile for an artist when no workfile exists. Useful t
Could be configured per `Task type`, eg. `composition` task type could use different `.psd` template file than `art` task.
Workfile template must be accessible for all artists.
-(Currently not handled by [SiteSync](module_site_sync.md))
\ No newline at end of file
+(Currently not handled by [SiteSync](module_site_sync.md))
diff --git a/website/docs/assets/admin_hosts_photoshop_settings.png b/website/docs/assets/admin_hosts_photoshop_settings.png
index aaa6ecbed7..9478fbedf7 100644
Binary files a/website/docs/assets/admin_hosts_photoshop_settings.png and b/website/docs/assets/admin_hosts_photoshop_settings.png differ