diff --git a/.github/workflows/prerelease.yml b/.github/workflows/prerelease.yml index 5acd20007c..bf39f8f956 100644 --- a/.github/workflows/prerelease.yml +++ b/.github/workflows/prerelease.yml @@ -69,16 +69,14 @@ jobs: run: | git config user.email ${{ secrets.CI_EMAIL }} git config user.name ${{ secrets.CI_USER }} - cd repos/avalon-core git checkout main git pull - cd ../.. git add . git commit -m "[Automated] Bump version" tag_name="CI/${{ steps.version.outputs.next_tag }}" echo $tag_name git tag -a $tag_name -m "nightly build" - + - name: Push to protected main branch uses: CasperWA/push-protected@v2.10.0 with: diff --git a/CHANGELOG.md b/CHANGELOG.md index e2ff9f919c..4a5e1f1067 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,166 +1,167 @@ # Changelog -## [3.10.0-nightly.2](https://github.com/pypeclub/OpenPype/tree/HEAD) +## [3.10.1-nightly.1](https://github.com/pypeclub/OpenPype/tree/HEAD) -[Full Changelog](https://github.com/pypeclub/OpenPype/compare/3.9.4...HEAD) - -### 📖 Documentation - -- Docs: add all-contributors config and initial list [\#3094](https://github.com/pypeclub/OpenPype/pull/3094) -- Nuke docs with videos [\#3052](https://github.com/pypeclub/OpenPype/pull/3052) +[Full Changelog](https://github.com/pypeclub/OpenPype/compare/3.10.0...HEAD) **🚀 Enhancements** -- Standalone publisher: add support for bgeo and vdb [\#3080](https://github.com/pypeclub/OpenPype/pull/3080) -- Update collect\_render.py [\#3055](https://github.com/pypeclub/OpenPype/pull/3055) -- SiteSync: Added compute\_resource\_sync\_sites to sync\_server\_module [\#2983](https://github.com/pypeclub/OpenPype/pull/2983) +- TVPaint: Init file for TVPaint worker also handle guideline images [\#3250](https://github.com/pypeclub/OpenPype/pull/3250) +- Support for Unreal 5 [\#3122](https://github.com/pypeclub/OpenPype/pull/3122) **🐛 Bug fixes** -- RoyalRender Control Submission - AVALON\_APP\_NAME default [\#3091](https://github.com/pypeclub/OpenPype/pull/3091) -- Ftrack: Update Create Folders action [\#3089](https://github.com/pypeclub/OpenPype/pull/3089) -- Project Manager: Avoid unnecessary updates of asset documents [\#3083](https://github.com/pypeclub/OpenPype/pull/3083) -- Standalone publisher: Fix plugins install [\#3077](https://github.com/pypeclub/OpenPype/pull/3077) -- General: Extract review sequence is not converted with same names [\#3076](https://github.com/pypeclub/OpenPype/pull/3076) -- Webpublisher: Use variant value [\#3068](https://github.com/pypeclub/OpenPype/pull/3068) -- Nuke: Add aov matching even for remainder and prerender [\#3060](https://github.com/pypeclub/OpenPype/pull/3060) +- Unreal: Fix Camera Loading if Layout is missing [\#3255](https://github.com/pypeclub/OpenPype/pull/3255) +- Unreal: Fixed Animation loading in UE5 [\#3240](https://github.com/pypeclub/OpenPype/pull/3240) +- Unreal: Fixed Render creation in UE5 [\#3239](https://github.com/pypeclub/OpenPype/pull/3239) +- Unreal: Fixed Camera loading in UE5 [\#3238](https://github.com/pypeclub/OpenPype/pull/3238) + +## [3.10.0](https://github.com/pypeclub/OpenPype/tree/3.10.0) (2022-05-26) + +[Full Changelog](https://github.com/pypeclub/OpenPype/compare/CI/3.10.0-nightly.6...3.10.0) + +**🆕 New features** + +- General: OpenPype modules publish plugins are registered in host [\#3180](https://github.com/pypeclub/OpenPype/pull/3180) +- General: Creator plugins from addons can be registered [\#3179](https://github.com/pypeclub/OpenPype/pull/3179) +- Ftrack: Single image reviewable [\#3157](https://github.com/pypeclub/OpenPype/pull/3157) +- Nuke: Expose write attributes to settings [\#3123](https://github.com/pypeclub/OpenPype/pull/3123) + +**🚀 Enhancements** + +- Maya: FBX camera export [\#3253](https://github.com/pypeclub/OpenPype/pull/3253) +- General: updating common vendor `scriptmenu` to 1.5.2 [\#3246](https://github.com/pypeclub/OpenPype/pull/3246) +- Project Manager: Allow to paste Tasks into multiple assets at the same time [\#3226](https://github.com/pypeclub/OpenPype/pull/3226) +- Project manager: Sped up project load [\#3216](https://github.com/pypeclub/OpenPype/pull/3216) +- Loader UI: Speed issues of loader with sync server [\#3199](https://github.com/pypeclub/OpenPype/pull/3199) +- Looks: add basic support for Renderman [\#3190](https://github.com/pypeclub/OpenPype/pull/3190) +- Maya: added clean\_import option to Import loader [\#3181](https://github.com/pypeclub/OpenPype/pull/3181) +- Add the scripts menu definition to nuke [\#3168](https://github.com/pypeclub/OpenPype/pull/3168) +- Maya: add maya 2023 to default applications [\#3167](https://github.com/pypeclub/OpenPype/pull/3167) +- Compressed bgeo publishing in SAP and Houdini loader [\#3153](https://github.com/pypeclub/OpenPype/pull/3153) +- General: Add 'dataclasses' to required python modules [\#3149](https://github.com/pypeclub/OpenPype/pull/3149) +- Hooks: Tweak logging grammar [\#3147](https://github.com/pypeclub/OpenPype/pull/3147) +- Nuke: settings for reformat node in CreateWriteRender node [\#3143](https://github.com/pypeclub/OpenPype/pull/3143) +- Houdini: Add loader for alembic through Alembic Archive node [\#3140](https://github.com/pypeclub/OpenPype/pull/3140) +- Publisher: UI Modifications and fixes [\#3139](https://github.com/pypeclub/OpenPype/pull/3139) +- General: Simplified OP modules/addons import [\#3137](https://github.com/pypeclub/OpenPype/pull/3137) +- Terminal: Tweak coloring of TrayModuleManager logging enabled states [\#3133](https://github.com/pypeclub/OpenPype/pull/3133) +- General: Cleanup some Loader docstrings [\#3131](https://github.com/pypeclub/OpenPype/pull/3131) + +**🐛 Bug fixes** + +- nuke: use framerange issue [\#3254](https://github.com/pypeclub/OpenPype/pull/3254) +- Ftrack: Chunk sizes for queries has minimal condition [\#3244](https://github.com/pypeclub/OpenPype/pull/3244) +- Maya: renderman displays needs to be filtered [\#3242](https://github.com/pypeclub/OpenPype/pull/3242) +- Ftrack: Validate that the user exists on ftrack [\#3237](https://github.com/pypeclub/OpenPype/pull/3237) +- Maya: Fix support for multiple resolutions [\#3236](https://github.com/pypeclub/OpenPype/pull/3236) +- TVPaint: Look for more groups than 12 [\#3228](https://github.com/pypeclub/OpenPype/pull/3228) +- Hiero: debugging frame range and other 3.10 [\#3222](https://github.com/pypeclub/OpenPype/pull/3222) +- Project Manager: Fix persistent editors on project change [\#3218](https://github.com/pypeclub/OpenPype/pull/3218) +- Deadline: instance data overwrite fix [\#3214](https://github.com/pypeclub/OpenPype/pull/3214) +- Ftrack: Push hierarchical attributes action works [\#3210](https://github.com/pypeclub/OpenPype/pull/3210) +- Standalone Publisher: Always create new representation for thumbnail [\#3203](https://github.com/pypeclub/OpenPype/pull/3203) +- Photoshop: skip collector when automatic testing [\#3202](https://github.com/pypeclub/OpenPype/pull/3202) +- Nuke: render/workfile version sync doesn't work on farm [\#3185](https://github.com/pypeclub/OpenPype/pull/3185) +- Ftrack: Review image only if there are no mp4 reviews [\#3183](https://github.com/pypeclub/OpenPype/pull/3183) +- Ftrack: Locations deepcopy issue [\#3177](https://github.com/pypeclub/OpenPype/pull/3177) +- General: Avoid creating multiple thumbnails [\#3176](https://github.com/pypeclub/OpenPype/pull/3176) +- General/Hiero: better clip duration calculation [\#3169](https://github.com/pypeclub/OpenPype/pull/3169) +- General: Oiio conversion for ffmpeg checks for invalid characters [\#3166](https://github.com/pypeclub/OpenPype/pull/3166) +- Fix for attaching render to subset [\#3164](https://github.com/pypeclub/OpenPype/pull/3164) +- Harmony: fixed missing task name in render instance [\#3163](https://github.com/pypeclub/OpenPype/pull/3163) +- Ftrack: Action delete old versions formatting works [\#3152](https://github.com/pypeclub/OpenPype/pull/3152) +- Deadline: fix the output directory [\#3144](https://github.com/pypeclub/OpenPype/pull/3144) +- General: New Session schema [\#3141](https://github.com/pypeclub/OpenPype/pull/3141) +- General: Missing version on headless mode crash properly [\#3136](https://github.com/pypeclub/OpenPype/pull/3136) +- TVPaint: Composite layers in reversed order [\#3135](https://github.com/pypeclub/OpenPype/pull/3135) +- Nuke: fix anatomy imageio regex default [\#3119](https://github.com/pypeclub/OpenPype/pull/3119) **🔀 Refactored code** -- General: Move host install [\#3009](https://github.com/pypeclub/OpenPype/pull/3009) +- Avalon repo removed from Jobs workflow [\#3193](https://github.com/pypeclub/OpenPype/pull/3193) +- General: Remove remaining imports from avalon [\#3130](https://github.com/pypeclub/OpenPype/pull/3130) **Merged pull requests:** -- Nuke: added suspend\_publish knob [\#3078](https://github.com/pypeclub/OpenPype/pull/3078) -- Bump async from 2.6.3 to 2.6.4 in /website [\#3065](https://github.com/pypeclub/OpenPype/pull/3065) +- Harmony: message length in 21.1 [\#3257](https://github.com/pypeclub/OpenPype/pull/3257) +- Harmony: 21.1 fix [\#3249](https://github.com/pypeclub/OpenPype/pull/3249) +- Maya: added jpg to filter for Image Plane Loader [\#3223](https://github.com/pypeclub/OpenPype/pull/3223) +- Webpublisher: replace space by underscore in subset names [\#3160](https://github.com/pypeclub/OpenPype/pull/3160) + +## [3.9.8](https://github.com/pypeclub/OpenPype/tree/3.9.8) (2022-05-19) + +[Full Changelog](https://github.com/pypeclub/OpenPype/compare/3.9.7...3.9.8) + +**🚀 Enhancements** + +- nuke: generate publishing nodes inside render group node [\#3206](https://github.com/pypeclub/OpenPype/pull/3206) +- Loader UI: Speed issues of loader with sync server [\#3200](https://github.com/pypeclub/OpenPype/pull/3200) +- Backport of fix for attaching renders to subsets [\#3195](https://github.com/pypeclub/OpenPype/pull/3195) + +**🐛 Bug fixes** + +- Standalone Publisher: Always create new representation for thumbnail [\#3204](https://github.com/pypeclub/OpenPype/pull/3204) +- Nuke: render/workfile version sync doesn't work on farm [\#3184](https://github.com/pypeclub/OpenPype/pull/3184) +- Ftrack: Review image only if there are no mp4 reviews [\#3182](https://github.com/pypeclub/OpenPype/pull/3182) +- Ftrack: Locations deepcopy issue [\#3175](https://github.com/pypeclub/OpenPype/pull/3175) +- General: Avoid creating multiple thumbnails [\#3174](https://github.com/pypeclub/OpenPype/pull/3174) +- General: TemplateResult can be copied [\#3170](https://github.com/pypeclub/OpenPype/pull/3170) + +**Merged pull requests:** + +- hiero: otio p3 compatibility issue - metadata on effect use update [\#3194](https://github.com/pypeclub/OpenPype/pull/3194) + +## [3.9.7](https://github.com/pypeclub/OpenPype/tree/3.9.7) (2022-05-11) + +[Full Changelog](https://github.com/pypeclub/OpenPype/compare/3.9.6...3.9.7) + +**🆕 New features** + +- Ftrack: Single image reviewable [\#3158](https://github.com/pypeclub/OpenPype/pull/3158) + +**🚀 Enhancements** + +- Deadline output dir issue to 3.9x [\#3155](https://github.com/pypeclub/OpenPype/pull/3155) +- nuke: removing redundant code from startup [\#3142](https://github.com/pypeclub/OpenPype/pull/3142) + +**🐛 Bug fixes** + +- Ftrack: Action delete old versions formatting works [\#3154](https://github.com/pypeclub/OpenPype/pull/3154) +- nuke: adding extract thumbnail settings [\#3148](https://github.com/pypeclub/OpenPype/pull/3148) + +**Merged pull requests:** + +- Webpublisher: replace space by underscore in subset names [\#3159](https://github.com/pypeclub/OpenPype/pull/3159) + +## [3.9.6](https://github.com/pypeclub/OpenPype/tree/3.9.6) (2022-05-03) + +[Full Changelog](https://github.com/pypeclub/OpenPype/compare/3.9.5...3.9.6) + +**🆕 New features** + +- Nuke: render instance with subset name filtered overrides \(3.9.x\) [\#3125](https://github.com/pypeclub/OpenPype/pull/3125) + +**🐛 Bug fixes** + +- TVPaint: Composite layers in reversed order [\#3134](https://github.com/pypeclub/OpenPype/pull/3134) + +## [3.9.5](https://github.com/pypeclub/OpenPype/tree/3.9.5) (2022-04-25) + +[Full Changelog](https://github.com/pypeclub/OpenPype/compare/CI/3.10.0-nightly.2...3.9.5) ## [3.9.4](https://github.com/pypeclub/OpenPype/tree/3.9.4) (2022-04-15) [Full Changelog](https://github.com/pypeclub/OpenPype/compare/CI/3.9.4-nightly.2...3.9.4) -### 📖 Documentation - -- Documentation: more info about Tasks [\#3062](https://github.com/pypeclub/OpenPype/pull/3062) -- Documentation: Python requirements to 3.7.9 [\#3035](https://github.com/pypeclub/OpenPype/pull/3035) -- Website Docs: Remove unused pages [\#2974](https://github.com/pypeclub/OpenPype/pull/2974) - -**🆕 New features** - -- General: Local overrides for environment variables [\#3045](https://github.com/pypeclub/OpenPype/pull/3045) - -**🚀 Enhancements** - -- TVPaint: Added init file for worker to triggers missing sound file dialog [\#3053](https://github.com/pypeclub/OpenPype/pull/3053) -- Ftrack: Custom attributes can be filled in slate values [\#3036](https://github.com/pypeclub/OpenPype/pull/3036) -- Resolve environment variable in google drive credential path [\#3008](https://github.com/pypeclub/OpenPype/pull/3008) - -**🐛 Bug fixes** - -- GitHub: Updated push-protected action in github workflow [\#3064](https://github.com/pypeclub/OpenPype/pull/3064) -- Nuke: Typos in imports from Nuke implementation [\#3061](https://github.com/pypeclub/OpenPype/pull/3061) -- Hotfix: fixing deadline job publishing [\#3059](https://github.com/pypeclub/OpenPype/pull/3059) -- General: Extract Review handle invalid characters for ffmpeg [\#3050](https://github.com/pypeclub/OpenPype/pull/3050) -- Slate Review: Support to keep format on slate concatenation [\#3049](https://github.com/pypeclub/OpenPype/pull/3049) -- Webpublisher: fix processing of workfile [\#3048](https://github.com/pypeclub/OpenPype/pull/3048) -- Ftrack: Integrate ftrack api fix [\#3044](https://github.com/pypeclub/OpenPype/pull/3044) -- Webpublisher - removed wrong hardcoded family [\#3043](https://github.com/pypeclub/OpenPype/pull/3043) -- LibraryLoader: Use current project for asset query in families filter [\#3042](https://github.com/pypeclub/OpenPype/pull/3042) -- SiteSync: Providers ignore that site is disabled [\#3041](https://github.com/pypeclub/OpenPype/pull/3041) -- Unreal: Creator import fixes [\#3040](https://github.com/pypeclub/OpenPype/pull/3040) -- Settings UI: Version column can be extended so version are visible [\#3032](https://github.com/pypeclub/OpenPype/pull/3032) -- SiteSync: fix transitive alternate sites, fix dropdown in Local Settings [\#3018](https://github.com/pypeclub/OpenPype/pull/3018) - -**Merged pull requests:** - -- Deadline: reworked pools assignment [\#3051](https://github.com/pypeclub/OpenPype/pull/3051) -- Houdini: Avoid ImportError on `hdefereval` when Houdini runs without UI [\#2987](https://github.com/pypeclub/OpenPype/pull/2987) - ## [3.9.3](https://github.com/pypeclub/OpenPype/tree/3.9.3) (2022-04-07) [Full Changelog](https://github.com/pypeclub/OpenPype/compare/CI/3.9.3-nightly.2...3.9.3) -### 📖 Documentation - -- Website Docs: Manager Ftrack fix broken links [\#2979](https://github.com/pypeclub/OpenPype/pull/2979) - -**🆕 New features** - -- Ftrack: Add description integrator [\#3027](https://github.com/pypeclub/OpenPype/pull/3027) -- Publishing textures for Unreal [\#2988](https://github.com/pypeclub/OpenPype/pull/2988) - -**🚀 Enhancements** - -- Ftrack: Add more options for note text of integrate ftrack note [\#3025](https://github.com/pypeclub/OpenPype/pull/3025) -- Console Interpreter: Changed how console splitter size are reused on show [\#3016](https://github.com/pypeclub/OpenPype/pull/3016) -- Deadline: Use more suitable name for sequence review logic [\#3015](https://github.com/pypeclub/OpenPype/pull/3015) -- General: default workfile subset name for workfile [\#3011](https://github.com/pypeclub/OpenPype/pull/3011) -- Deadline: priority configurable in Maya jobs [\#2995](https://github.com/pypeclub/OpenPype/pull/2995) - -**🐛 Bug fixes** - -- Deadline: Fixed default value of use sequence for review [\#3033](https://github.com/pypeclub/OpenPype/pull/3033) -- General: Fix validate asset docs plug-in filename and class name [\#3029](https://github.com/pypeclub/OpenPype/pull/3029) -- General: Fix import after movements [\#3028](https://github.com/pypeclub/OpenPype/pull/3028) -- Harmony: Added creating subset name for workfile from template [\#3024](https://github.com/pypeclub/OpenPype/pull/3024) -- AfterEffects: Added creating subset name for workfile from template [\#3023](https://github.com/pypeclub/OpenPype/pull/3023) -- General: Add example addons to ignored [\#3022](https://github.com/pypeclub/OpenPype/pull/3022) -- Maya: Remove missing import [\#3017](https://github.com/pypeclub/OpenPype/pull/3017) -- Ftrack: multiple reviewable componets [\#3012](https://github.com/pypeclub/OpenPype/pull/3012) -- Tray publisher: Fixes after code movement [\#3010](https://github.com/pypeclub/OpenPype/pull/3010) -- Nuke: fixing unicode type detection in effect loaders [\#3002](https://github.com/pypeclub/OpenPype/pull/3002) -- Nuke: removing redundant Ftrack asset when farm publishing [\#2996](https://github.com/pypeclub/OpenPype/pull/2996) - -**Merged pull requests:** - -- Maya: Allow to select invalid camera contents if no cameras found [\#3030](https://github.com/pypeclub/OpenPype/pull/3030) -- General: adding limitations for pyright [\#2994](https://github.com/pypeclub/OpenPype/pull/2994) - ## [3.9.2](https://github.com/pypeclub/OpenPype/tree/3.9.2) (2022-04-04) [Full Changelog](https://github.com/pypeclub/OpenPype/compare/CI/3.9.2-nightly.4...3.9.2) -### 📖 Documentation - -- Documentation: Added mention of adding My Drive as a root [\#2999](https://github.com/pypeclub/OpenPype/pull/2999) -- Docs: Added MongoDB requirements [\#2951](https://github.com/pypeclub/OpenPype/pull/2951) - -**🆕 New features** - -- nuke: bypass baking [\#2992](https://github.com/pypeclub/OpenPype/pull/2992) -- Maya to Unreal: Static and Skeletal Meshes [\#2978](https://github.com/pypeclub/OpenPype/pull/2978) - -**🚀 Enhancements** - -- Nuke: add concurrency attr to deadline job [\#3005](https://github.com/pypeclub/OpenPype/pull/3005) -- Photoshop: create image without instance [\#3001](https://github.com/pypeclub/OpenPype/pull/3001) -- TVPaint: Render scene family [\#3000](https://github.com/pypeclub/OpenPype/pull/3000) -- Nuke: ReviewDataMov Read RAW attribute [\#2985](https://github.com/pypeclub/OpenPype/pull/2985) -- General: `METADATA\_KEYS` constant as `frozenset` for optimal immutable lookup [\#2980](https://github.com/pypeclub/OpenPype/pull/2980) -- General: Tools with host filters [\#2975](https://github.com/pypeclub/OpenPype/pull/2975) -- Hero versions: Use custom templates [\#2967](https://github.com/pypeclub/OpenPype/pull/2967) - -**🐛 Bug fixes** - -- Hosts: Remove path existence checks in 'add\_implementation\_envs' [\#3004](https://github.com/pypeclub/OpenPype/pull/3004) -- Fix - remove doubled dot in workfile created from template [\#2998](https://github.com/pypeclub/OpenPype/pull/2998) -- PS: fix renaming subset incorrectly in PS [\#2991](https://github.com/pypeclub/OpenPype/pull/2991) -- Fix: Disable setuptools auto discovery [\#2990](https://github.com/pypeclub/OpenPype/pull/2990) -- AEL: fix opening existing workfile if no scene opened [\#2989](https://github.com/pypeclub/OpenPype/pull/2989) -- Maya: Don't do hardlinks on windows for look publishing [\#2986](https://github.com/pypeclub/OpenPype/pull/2986) -- Settings UI: Fix version completer on linux [\#2981](https://github.com/pypeclub/OpenPype/pull/2981) -- Photoshop: Fix creation of subset names in PS review and workfile [\#2969](https://github.com/pypeclub/OpenPype/pull/2969) -- Slack: Added default for review\_upload\_limit for Slack [\#2965](https://github.com/pypeclub/OpenPype/pull/2965) -- General: OIIO conversion for ffmeg can handle sequences [\#2958](https://github.com/pypeclub/OpenPype/pull/2958) -- Settings: Conditional dictionary avoid invalid logs [\#2956](https://github.com/pypeclub/OpenPype/pull/2956) -- General: Smaller fixes and typos [\#2950](https://github.com/pypeclub/OpenPype/pull/2950) - -**Merged pull requests:** - -- Bump paramiko from 2.9.2 to 2.10.1 [\#2973](https://github.com/pypeclub/OpenPype/pull/2973) -- Bump minimist from 1.2.5 to 1.2.6 in /website [\#2954](https://github.com/pypeclub/OpenPype/pull/2954) -- Bump node-forge from 1.2.1 to 1.3.0 in /website [\#2953](https://github.com/pypeclub/OpenPype/pull/2953) -- Maya - added transparency into review creator [\#2952](https://github.com/pypeclub/OpenPype/pull/2952) - ## [3.9.1](https://github.com/pypeclub/OpenPype/tree/3.9.1) (2022-03-18) [Full Changelog](https://github.com/pypeclub/OpenPype/compare/CI/3.9.1-nightly.3...3.9.1) diff --git a/openpype/api.py b/openpype/api.py index b692b36065..9ce745b653 100644 --- a/openpype/api.py +++ b/openpype/api.py @@ -3,7 +3,6 @@ from .settings import ( get_project_settings, get_current_project_settings, get_anatomy_settings, - get_environments, SystemSettings, ProjectSettings @@ -23,7 +22,6 @@ from .lib import ( get_app_environments_for_context, source_hash, get_latest_version, - get_global_environments, get_local_site_id, change_openpype_mongo_url, create_project_folders, @@ -69,10 +67,10 @@ __all__ = [ "get_project_settings", "get_current_project_settings", "get_anatomy_settings", - "get_environments", "get_project_basic_paths", "SystemSettings", + "ProjectSettings", "PypeLogger", "Logger", @@ -102,8 +100,9 @@ __all__ = [ # get contextual data "version_up", - "get_hierarchy", "get_asset", + "get_hierarchy", + "get_workdir_data", "get_version_from_path", "get_last_version_from_path", "get_app_environments_for_context", @@ -111,7 +110,6 @@ __all__ = [ "run_subprocess", "get_latest_version", - "get_global_environments", "get_local_site_id", "change_openpype_mongo_url", diff --git a/openpype/hosts/blender/api/plugin.py b/openpype/hosts/blender/api/plugin.py index 3207f543b7..c59be8d7ff 100644 --- a/openpype/hosts/blender/api/plugin.py +++ b/openpype/hosts/blender/api/plugin.py @@ -266,7 +266,7 @@ class AssetLoader(LoaderPlugin): # Only containerise if it's not already a collection from a .blend file. # representation = context["representation"]["name"] # if representation != "blend": - # from avalon.blender.pipeline import containerise + # from openpype.hosts.blender.api.pipeline import containerise # return containerise( # name=name, # namespace=namespace, diff --git a/openpype/hosts/flame/api/lib.py b/openpype/hosts/flame/api/lib.py index c7c444c1fb..d59308ad6c 100644 --- a/openpype/hosts/flame/api/lib.py +++ b/openpype/hosts/flame/api/lib.py @@ -3,6 +3,7 @@ import os import re import json import pickle +import clique import tempfile import itertools import contextlib @@ -560,7 +561,7 @@ def get_segment_attributes(segment): if not hasattr(segment, attr_name): continue attr = getattr(segment, attr_name) - segment_attrs_data[attr] = str(attr).replace("+", ":") + segment_attrs_data[attr_name] = str(attr).replace("+", ":") if attr_name in ["record_in", "record_out"]: clip_data[attr_name] = attr.relative_frame @@ -762,6 +763,7 @@ class MediaInfoFile(object): _start_frame = None _fps = None _drop_mode = None + _file_pattern = None def __init__(self, path, **kwargs): @@ -773,17 +775,28 @@ class MediaInfoFile(object): self._validate_media_script_path() # derivate other feed variables - self.feed_basename = os.path.basename(path) - self.feed_dir = os.path.dirname(path) - self.feed_ext = os.path.splitext(self.feed_basename)[1][1:].lower() + feed_basename = os.path.basename(path) + feed_dir = os.path.dirname(path) + feed_ext = os.path.splitext(feed_basename)[1][1:].lower() with maintained_temp_file_path(".clip") as tmp_path: self.log.info("Temp File: {}".format(tmp_path)) - self._generate_media_info_file(tmp_path) + self._generate_media_info_file(tmp_path, feed_ext, feed_dir) + + # get collection containing feed_basename from path + self.file_pattern = self._get_collection( + feed_basename, feed_dir, feed_ext) + + if ( + not self.file_pattern + and os.path.exists(os.path.join(feed_dir, feed_basename)) + ): + self.file_pattern = feed_basename # get clip data and make them single if there is multiple # clips data - xml_data = self._make_single_clip_media_info(tmp_path) + xml_data = self._make_single_clip_media_info( + tmp_path, feed_basename, self.file_pattern) self.log.debug("xml_data: {}".format(xml_data)) self.log.debug("type: {}".format(type(xml_data))) @@ -794,6 +807,123 @@ class MediaInfoFile(object): self.log.debug("drop frame: {}".format(self.drop_mode)) self.clip_data = xml_data + def _get_collection(self, feed_basename, feed_dir, feed_ext): + """ Get collection string + + Args: + feed_basename (str): file base name + feed_dir (str): file's directory + feed_ext (str): file extension + + Raises: + AttributeError: feed_ext is not matching feed_basename + + Returns: + str: collection basename with range of sequence + """ + partialname = self._separate_file_head(feed_basename, feed_ext) + self.log.debug("__ partialname: {}".format(partialname)) + + # make sure partial input basename is having correct extensoon + if not partialname: + raise AttributeError( + "Wrong input attributes. Basename - {}, Ext - {}".format( + feed_basename, feed_ext + ) + ) + + # get all related files + files = [ + f for f in os.listdir(feed_dir) + if partialname == self._separate_file_head(f, feed_ext) + ] + + # ignore reminders as we dont need them + collections = clique.assemble(files)[0] + + # in case no collection found return None + # it is probably just single file + if not collections: + return + + # we expect only one collection + collection = collections[0] + + self.log.debug("__ collection: {}".format(collection)) + + if collection.is_contiguous(): + return self._format_collection(collection) + + # add `[` in front to make sure it want capture + # shot name with the same number + number_from_path = self._separate_number(feed_basename, feed_ext) + search_number_pattern = "[" + number_from_path + # convert to multiple collections + _continues_colls = collection.separate() + for _coll in _continues_colls: + coll_to_text = self._format_collection( + _coll, len(number_from_path)) + self.log.debug("__ coll_to_text: {}".format(coll_to_text)) + if search_number_pattern in coll_to_text: + return coll_to_text + + @staticmethod + def _format_collection(collection, padding=None): + padding = padding or collection.padding + # if no holes then return collection + head = collection.format("{head}") + tail = collection.format("{tail}") + range_template = "[{{:0{0}d}}-{{:0{0}d}}]".format( + padding) + ranges = range_template.format( + min(collection.indexes), + max(collection.indexes) + ) + # if no holes then return collection + return "{}{}{}".format(head, ranges, tail) + + def _separate_file_head(self, basename, extension): + """ Get only head with out sequence and extension + + Args: + basename (str): file base name + extension (str): file extension + + Returns: + str: file head + """ + # in case sequence file + found = re.findall( + r"(.*)[._][\d]*(?=.{})".format(extension), + basename, + ) + if found: + return found.pop() + + # in case single file + name, ext = os.path.splitext(basename) + + if extension == ext[1:]: + return name + + def _separate_number(self, basename, extension): + """ Get only sequence number as string + + Args: + basename (str): file base name + extension (str): file extension + + Returns: + str: number with padding + """ + # in case sequence file + found = re.findall( + r"[._]([\d]*)(?=.{})".format(extension), + basename, + ) + if found: + return found.pop() + @property def clip_data(self): """Clip's xml clip data @@ -846,18 +976,41 @@ class MediaInfoFile(object): def drop_mode(self, text): self._drop_mode = str(text) + @property + def file_pattern(self): + """Clips file patter + + Returns: + str: file pattern. ex. file.[1-2].exr + """ + return self._file_pattern + + @file_pattern.setter + def file_pattern(self, fpattern): + self._file_pattern = fpattern + def _validate_media_script_path(self): if not os.path.isfile(self.MEDIA_SCRIPT_PATH): raise IOError("Media Scirpt does not exist: `{}`".format( self.MEDIA_SCRIPT_PATH)) - def _generate_media_info_file(self, fpath): + def _generate_media_info_file(self, fpath, feed_ext, feed_dir): + """ Generate media info xml .clip file + + Args: + fpath (str): .clip file path + feed_ext (str): file extension to be filtered + feed_dir (str): look up directory + + Raises: + TypeError: Type error if it fails + """ # Create cmd arguments for gettig xml file info file cmd_args = [ self.MEDIA_SCRIPT_PATH, - "-e", self.feed_ext, + "-e", feed_ext, "-o", fpath, - self.feed_dir + feed_dir ] try: @@ -867,7 +1020,20 @@ class MediaInfoFile(object): raise TypeError( "Error creating `{}` due: {}".format(fpath, error)) - def _make_single_clip_media_info(self, fpath): + def _make_single_clip_media_info(self, fpath, feed_basename, path_pattern): + """ Separate only relative clip object form .clip file + + Args: + fpath (str): clip file path + feed_basename (str): search basename + path_pattern (str): search file pattern (file.[1-2].exr) + + Raises: + ET.ParseError: if nothing found + + Returns: + ET.Element: xml element data of matching clip + """ with open(fpath) as f: lines = f.readlines() _added_root = itertools.chain( @@ -878,14 +1044,30 @@ class MediaInfoFile(object): xml_clips = new_root.findall("clip") matching_clip = None for xml_clip in xml_clips: - if xml_clip.find("name").text in self.feed_basename: - matching_clip = xml_clip + clip_name = xml_clip.find("name").text + self.log.debug("__ clip_name: `{}`".format(clip_name)) + if clip_name not in feed_basename: + continue + + # test path pattern + for out_track in xml_clip.iter("track"): + for out_feed in out_track.iter("feed"): + for span in out_feed.iter("span"): + # start frame + span_path = span.find("path") + self.log.debug( + "__ span_path.text: {}, path_pattern: {}".format( + span_path.text, path_pattern + ) + ) + if path_pattern in span_path.text: + matching_clip = xml_clip if matching_clip is None: # return warning there is missing clip raise ET.ParseError( "Missing clip in `{}`. Available clips {}".format( - self.feed_basename, [ + feed_basename, [ xml_clip.find("name").text for xml_clip in xml_clips ] @@ -894,6 +1076,11 @@ class MediaInfoFile(object): return matching_clip def _get_time_info_from_origin(self, xml_data): + """Set time info to class attributes + + Args: + xml_data (ET.Element): clip data + """ try: for out_track in xml_data.iter('track'): for out_feed in out_track.iter('feed'): @@ -912,8 +1099,6 @@ class MediaInfoFile(object): 'startTimecode/dropMode') self.drop_mode = out_feed_drop_mode_obj.text break - else: - continue except Exception as msg: self.log.warning(msg) diff --git a/openpype/hosts/flame/api/plugin.py b/openpype/hosts/flame/api/plugin.py index c87445fdd3..efbabb6a55 100644 --- a/openpype/hosts/flame/api/plugin.py +++ b/openpype/hosts/flame/api/plugin.py @@ -360,6 +360,7 @@ class PublishableClip: driving_layer_default = "" index_from_segment_default = False use_shot_name_default = False + include_handles_default = False def __init__(self, segment, **kwargs): self.rename_index = kwargs["rename_index"] @@ -493,6 +494,8 @@ class PublishableClip: "reviewTrack", {}).get("value") or self.review_track_default self.audio = self.ui_inputs.get( "audio", {}).get("value") or False + self.include_handles = self.ui_inputs.get( + "includeHandles", {}).get("value") or self.include_handles_default # build subset name from layer name if self.subset_name == "[ track name ]": @@ -873,6 +876,5 @@ class OpenClipSolver(flib.MediaInfoFile): if feed_clr_obj is not None: feed_clr_obj = ET.Element( "colourSpace", {"type": "string"}) + feed_clr_obj.text = profile_name feed_storage_obj.append(feed_clr_obj) - - feed_clr_obj.text = profile_name diff --git a/openpype/hosts/flame/api/render_utils.py b/openpype/hosts/flame/api/render_utils.py index 473fb2f985..a29d6be695 100644 --- a/openpype/hosts/flame/api/render_utils.py +++ b/openpype/hosts/flame/api/render_utils.py @@ -1,5 +1,8 @@ import os from xml.etree import ElementTree as ET +from openpype.api import Logger + +log = Logger.get_logger(__name__) def export_clip(export_path, clip, preset_path, **kwargs): @@ -143,10 +146,40 @@ def modify_preset_file(xml_path, staging_dir, data): # change xml following data keys with open(xml_path, "r") as datafile: - tree = ET.parse(datafile) + _root = ET.parse(datafile) + for key, value in data.items(): - for element in tree.findall(".//{}".format(key)): - element.text = str(value) - tree.write(temp_path) + try: + if "/" in key: + if not key.startswith("./"): + key = ".//" + key + + split_key_path = key.split("/") + element_key = split_key_path[-1] + parent_obj_path = "/".join(split_key_path[:-1]) + + parent_obj = _root.find(parent_obj_path) + element_obj = parent_obj.find(element_key) + if not element_obj: + append_element(parent_obj, element_key, value) + else: + finds = _root.findall(".//{}".format(key)) + if not finds: + raise AttributeError + for element in finds: + element.text = str(value) + except AttributeError: + log.warning( + "Cannot create attribute: {}: {}. Skipping".format( + key, value + )) + _root.write(temp_path) return temp_path + + +def append_element(root_element_obj, key, value): + new_element_obj = ET.Element(key) + log.debug("__ new_element_obj: {}".format(new_element_obj)) + new_element_obj.text = str(value) + root_element_obj.insert(0, new_element_obj) diff --git a/openpype/hosts/flame/otio/flame_export.py b/openpype/hosts/flame/otio/flame_export.py index 4fe05ec1d8..1e4ef866ed 100644 --- a/openpype/hosts/flame/otio/flame_export.py +++ b/openpype/hosts/flame/otio/flame_export.py @@ -94,83 +94,30 @@ def create_otio_time_range(start_frame, frame_duration, fps): def _get_metadata(item): if hasattr(item, 'metadata'): - if not item.metadata: - return {} - return {key: value for key, value in dict(item.metadata)} + return dict(item.metadata) if item.metadata else {} return {} -def create_time_effects(otio_clip, item): - # todo #2426: add retiming effects to export - # get all subtrack items - # subTrackItems = flatten(track_item.parent().subTrackItems()) - # speed = track_item.playbackSpeed() +def create_time_effects(otio_clip, speed): + otio_effect = None - # otio_effect = None - # # retime on track item - # if speed != 1.: - # # make effect - # otio_effect = otio.schema.LinearTimeWarp() - # otio_effect.name = "Speed" - # otio_effect.time_scalar = speed - # otio_effect.metadata = {} + # retime on track item + if speed != 1.: + # make effect + otio_effect = otio.schema.LinearTimeWarp() + otio_effect.name = "Speed" + otio_effect.time_scalar = speed + otio_effect.metadata = {} - # # freeze frame effect - # if speed == 0.: - # otio_effect = otio.schema.FreezeFrame() - # otio_effect.name = "FreezeFrame" - # otio_effect.metadata = {} + # freeze frame effect + if speed == 0.: + otio_effect = otio.schema.FreezeFrame() + otio_effect.name = "FreezeFrame" + otio_effect.metadata = {} - # if otio_effect: - # # add otio effect to clip effects - # otio_clip.effects.append(otio_effect) - - # # loop through and get all Timewarps - # for effect in subTrackItems: - # if ((track_item not in effect.linkedItems()) - # and (len(effect.linkedItems()) > 0)): - # continue - # # avoid all effect which are not TimeWarp and disabled - # if "TimeWarp" not in effect.name(): - # continue - - # if not effect.isEnabled(): - # continue - - # node = effect.node() - # name = node["name"].value() - - # # solve effect class as effect name - # _name = effect.name() - # if "_" in _name: - # effect_name = re.sub(r"(?:_)[_0-9]+", "", _name) # more numbers - # else: - # effect_name = re.sub(r"\d+", "", _name) # one number - - # metadata = {} - # # add knob to metadata - # for knob in ["lookup", "length"]: - # value = node[knob].value() - # animated = node[knob].isAnimated() - # if animated: - # value = [ - # ((node[knob].getValueAt(i)) - i) - # for i in range( - # track_item.timelineIn(), - # track_item.timelineOut() + 1) - # ] - - # metadata[knob] = value - - # # make effect - # otio_effect = otio.schema.TimeEffect() - # otio_effect.name = name - # otio_effect.effect_name = effect_name - # otio_effect.metadata = metadata - - # # add otio effect to clip effects - # otio_clip.effects.append(otio_effect) - pass + if otio_effect: + # add otio effect to clip effects + otio_clip.effects.append(otio_effect) def _get_marker_color(flame_colour): @@ -260,6 +207,7 @@ def create_otio_markers(otio_item, item): def create_otio_reference(clip_data, fps=None): metadata = _get_metadata(clip_data) + duration = int(clip_data["source_duration"]) # get file info for path and start frame frame_start = 0 @@ -273,7 +221,6 @@ def create_otio_reference(clip_data, fps=None): # get padding and other file infos log.debug("_ path: {}".format(path)) - frame_duration = clip_data["source_duration"] otio_ex_ref_item = None is_sequence = frame_number = utils.get_frame_from_filename(file_name) @@ -300,7 +247,7 @@ def create_otio_reference(clip_data, fps=None): rate=fps, available_range=create_otio_time_range( frame_start, - frame_duration, + duration, fps ) ) @@ -316,7 +263,7 @@ def create_otio_reference(clip_data, fps=None): target_url=reformated_path, available_range=create_otio_time_range( frame_start, - frame_duration, + duration, fps ) ) @@ -333,23 +280,50 @@ def create_otio_clip(clip_data): segment = clip_data["PySegment"] # calculate source in - media_info = MediaInfoFile(clip_data["fpath"]) + media_info = MediaInfoFile(clip_data["fpath"], logger=log) media_timecode_start = media_info.start_frame media_fps = media_info.fps - # create media reference - media_reference = create_otio_reference(clip_data, media_fps) - # define first frame first_frame = media_timecode_start or utils.get_frame_from_filename( clip_data["fpath"]) or 0 - source_in = int(clip_data["source_in"]) - int(first_frame) + _clip_source_in = int(clip_data["source_in"]) + _clip_source_out = int(clip_data["source_out"]) + _clip_record_duration = int(clip_data["record_duration"]) + + # first solve if the reverse timing + speed = 1 + if clip_data["source_in"] > clip_data["source_out"]: + source_in = _clip_source_out - int(first_frame) + source_out = _clip_source_in - int(first_frame) + speed = -1 + else: + source_in = _clip_source_in - int(first_frame) + source_out = _clip_source_out - int(first_frame) + + source_duration = (source_out - source_in + 1) + + # secondly check if any change of speed + if source_duration != _clip_record_duration: + retime_speed = float(source_duration) / float(_clip_record_duration) + log.debug("_ retime_speed: {}".format(retime_speed)) + speed *= retime_speed + + log.debug("_ source_in: {}".format(source_in)) + log.debug("_ source_out: {}".format(source_out)) + log.debug("_ speed: {}".format(speed)) + log.debug("_ source_duration: {}".format(source_duration)) + log.debug("_ _clip_record_duration: {}".format(_clip_record_duration)) + + # create media reference + media_reference = create_otio_reference( + clip_data, media_fps) # creatae source range source_range = create_otio_time_range( source_in, - clip_data["record_duration"], + _clip_record_duration, CTX.get_fps() ) @@ -363,6 +337,9 @@ def create_otio_clip(clip_data): if MARKERS_INCLUDE: create_otio_markers(otio_clip, segment) + if speed != 1: + create_time_effects(otio_clip, speed) + return otio_clip diff --git a/openpype/hosts/flame/plugins/create/create_shot_clip.py b/openpype/hosts/flame/plugins/create/create_shot_clip.py index 11c00dab42..fa239ea420 100644 --- a/openpype/hosts/flame/plugins/create/create_shot_clip.py +++ b/openpype/hosts/flame/plugins/create/create_shot_clip.py @@ -268,6 +268,14 @@ class CreateShotClip(opfapi.Creator): "target": "tag", "toolTip": "Handle at end of clip", # noqa "order": 2 + }, + "includeHandles": { + "value": False, + "type": "QCheckBox", + "label": "Include handles", + "target": "tag", + "toolTip": "By default handles are excluded", # noqa + "order": 3 } } } diff --git a/openpype/hosts/flame/plugins/publish/collect_timeline_instances.py b/openpype/hosts/flame/plugins/publish/collect_timeline_instances.py index 95c2002bd9..0aca7c38d5 100644 --- a/openpype/hosts/flame/plugins/publish/collect_timeline_instances.py +++ b/openpype/hosts/flame/plugins/publish/collect_timeline_instances.py @@ -1,8 +1,8 @@ import re import pyblish -import openpype import openpype.hosts.flame.api as opfapi from openpype.hosts.flame.otio import flame_export +import openpype.lib as oplib # # developer reload modules from pprint import pformat @@ -26,18 +26,17 @@ class CollectTimelineInstances(pyblish.api.ContextPlugin): add_tasks = [] def process(self, context): - project = context.data["flameProject"] selected_segments = context.data["flameSelectedSegments"] self.log.debug("__ selected_segments: {}".format(selected_segments)) self.otio_timeline = context.data["otioTimeline"] - self.clips_in_reels = opfapi.get_clips_in_reels(project) self.fps = context.data["fps"] # process all sellected for segment in selected_segments: # get openpype tag data marker_data = opfapi.get_segment_data_marker(segment) + self.log.debug("__ marker_data: {}".format( pformat(marker_data))) @@ -60,27 +59,44 @@ class CollectTimelineInstances(pyblish.api.ContextPlugin): clip_name = clip_data["segment_name"] self.log.debug("clip_name: {}".format(clip_name)) + # get otio clip data + otio_data = self._get_otio_clip_instance_data(clip_data) or {} + self.log.debug("__ otio_data: {}".format(pformat(otio_data))) + # get file path file_path = clip_data["fpath"] - # get source clip - source_clip = self._get_reel_clip(file_path) - first_frame = opfapi.get_frame_from_filename(file_path) or 0 - head, tail = self._get_head_tail(clip_data, first_frame) + head, tail = self._get_head_tail( + clip_data, + otio_data["otioClip"], + marker_data["handleStart"], + marker_data["handleEnd"] + ) + + # make sure value is absolute + if head != 0: + head = abs(head) + if tail != 0: + tail = abs(tail) # solve handles length marker_data["handleStart"] = min( - marker_data["handleStart"], abs(head)) + marker_data["handleStart"], head) marker_data["handleEnd"] = min( - marker_data["handleEnd"], abs(tail)) + marker_data["handleEnd"], tail) + + workfile_start = self._set_workfile_start(marker_data) with_audio = bool(marker_data.pop("audio")) # add marker data to instance data inst_data = dict(marker_data.items()) + # add ocio_data to instance data + inst_data.update(otio_data) + asset = marker_data["asset"] subset = marker_data["subset"] @@ -103,7 +119,7 @@ class CollectTimelineInstances(pyblish.api.ContextPlugin): "families": families, "publish": marker_data["publish"], "fps": self.fps, - "flameSourceClip": source_clip, + "workfileFrameStart": workfile_start, "sourceFirstFrame": int(first_frame), "path": file_path, "flameAddTasks": self.add_tasks, @@ -111,13 +127,6 @@ class CollectTimelineInstances(pyblish.api.ContextPlugin): task["name"]: {"type": task["type"]} for task in self.add_tasks} }) - - # get otio clip data - otio_data = self._get_otio_clip_instance_data(clip_data) or {} - self.log.debug("__ otio_data: {}".format(pformat(otio_data))) - - # add to instance data - inst_data.update(otio_data) self.log.debug("__ inst_data: {}".format(pformat(inst_data))) # add resolution @@ -151,6 +160,17 @@ class CollectTimelineInstances(pyblish.api.ContextPlugin): if marker_data.get("reviewTrack") is not None: instance.data["reviewAudio"] = True + @staticmethod + def _set_workfile_start(data): + include_handles = data.get("includeHandles") + workfile_start = data["workfileFrameStart"] + handle_start = data["handleStart"] + + if include_handles: + workfile_start += handle_start + + return workfile_start + def _get_comment_attributes(self, segment): comment = segment.comment.get_value() @@ -242,29 +262,25 @@ class CollectTimelineInstances(pyblish.api.ContextPlugin): return split_comments - def _get_head_tail(self, clip_data, first_frame): + def _get_head_tail(self, clip_data, otio_clip, handle_start, handle_end): # calculate head and tail with forward compatibility head = clip_data.get("segment_head") tail = clip_data.get("segment_tail") + self.log.debug("__ head: `{}`".format(head)) + self.log.debug("__ tail: `{}`".format(tail)) # HACK: it is here to serve for versions bellow 2021.1 - if not head: - head = int(clip_data["source_in"]) - int(first_frame) - if not tail: - tail = int( - clip_data["source_duration"] - ( - head + clip_data["record_duration"] - ) - ) - return head, tail + if not any([head, tail]): + retimed_attributes = oplib.get_media_range_with_retimes( + otio_clip, handle_start, handle_end) + self.log.debug( + ">> retimed_attributes: {}".format(retimed_attributes)) - def _get_reel_clip(self, path): - match_reel_clip = [ - clip for clip in self.clips_in_reels - if clip["fpath"] == path - ] - if match_reel_clip: - return match_reel_clip.pop() + # retimed head and tail + head = int(retimed_attributes["handleStart"]) + tail = int(retimed_attributes["handleEnd"]) + + return head, tail def _get_resolution_to_data(self, data, context): assert data.get("otioClip"), "Missing `otioClip` data" @@ -354,7 +370,7 @@ class CollectTimelineInstances(pyblish.api.ContextPlugin): continue if otio_clip.name not in segment.name.get_value(): continue - if openpype.lib.is_overlapping_otio_ranges( + if oplib.is_overlapping_otio_ranges( parent_range, timeline_range, strict=True): # add pypedata marker to otio_clip metadata diff --git a/openpype/hosts/flame/plugins/publish/collect_timeline_otio.py b/openpype/hosts/flame/plugins/publish/collect_timeline_otio.py index f2ae1f62a9..0a9b0db334 100644 --- a/openpype/hosts/flame/plugins/publish/collect_timeline_otio.py +++ b/openpype/hosts/flame/plugins/publish/collect_timeline_otio.py @@ -39,7 +39,8 @@ class CollecTimelineOTIO(pyblish.api.ContextPlugin): "name": subset_name, "asset": asset_doc["name"], "subset": subset_name, - "family": "workfile" + "family": "workfile", + "families": [] } # create instance with workfile diff --git a/openpype/hosts/flame/plugins/publish/extract_subset_resources.py b/openpype/hosts/flame/plugins/publish/extract_subset_resources.py index a780f8c9e5..0bad3f7cfc 100644 --- a/openpype/hosts/flame/plugins/publish/extract_subset_resources.py +++ b/openpype/hosts/flame/plugins/publish/extract_subset_resources.py @@ -1,10 +1,14 @@ import os +import re from pprint import pformat from copy import deepcopy import pyblish.api import openpype.api from openpype.hosts.flame import api as opfapi +from openpype.hosts.flame.api import MediaInfoFile + +import flame class ExtractSubsetResources(openpype.api.Extractor): @@ -20,30 +24,18 @@ class ExtractSubsetResources(openpype.api.Extractor): # plugin defaults default_presets = { "thumbnail": { + "active": True, "ext": "jpg", "xml_preset_file": "Jpeg (8-bit).xml", "xml_preset_dir": "", "export_type": "File Sequence", - "ignore_comment_attrs": True, + "parsed_comment_attrs": False, "colorspace_out": "Output - sRGB", "representation_add_range": False, - "representation_tags": ["thumbnail"] - }, - "ftrackpreview": { - "ext": "mov", - "xml_preset_file": "Apple iPad (1920x1080).xml", - "xml_preset_dir": "", - "export_type": "Movie", - "ignore_comment_attrs": True, - "colorspace_out": "Output - Rec.709", - "representation_add_range": True, - "representation_tags": [ - "review", - "delete" - ] + "representation_tags": ["thumbnail"], + "path_regex": ".*" } } - keep_original_representation = False # hide publisher during exporting hide_ui_on_process = True @@ -52,22 +44,15 @@ class ExtractSubsetResources(openpype.api.Extractor): export_presets_mapping = {} def process(self, instance): - if ( - self.keep_original_representation - and "representations" not in instance.data - or not self.keep_original_representation - ): + if "representations" not in instance.data: instance.data["representations"] = [] # flame objects segment = instance.data["item"] + asset_name = instance.data["asset"] segment_name = segment.name.get_value() + clip_path = instance.data["path"] sequence_clip = instance.context.data["flameSequence"] - clip_data = instance.data["flameSourceClip"] - - reel_clip = None - if clip_data: - reel_clip = clip_data["PyClip"] # segment's parent track name s_track_name = segment.parent.name.get_value() @@ -87,7 +72,6 @@ class ExtractSubsetResources(openpype.api.Extractor): handles = max(handle_start, handle_end) # get media source range with handles - source_end_handles = instance.data["sourceEndH"] source_start_handles = instance.data["sourceStartH"] source_end_handles = instance.data["sourceEndH"] @@ -104,192 +88,231 @@ class ExtractSubsetResources(openpype.api.Extractor): for unique_name, preset_config in export_presets.items(): modify_xml_data = {} + if self._should_skip(preset_config, clip_path, unique_name): + continue + # get all presets attributes + extension = preset_config["ext"] preset_file = preset_config["xml_preset_file"] preset_dir = preset_config["xml_preset_dir"] export_type = preset_config["export_type"] repre_tags = preset_config["representation_tags"] - ignore_comment_attrs = preset_config["ignore_comment_attrs"] + parsed_comment_attrs = preset_config["parsed_comment_attrs"] color_out = preset_config["colorspace_out"] - # get attribures related loading in integrate_batch_group - load_to_batch_group = preset_config.get( - "load_to_batch_group") - batch_group_loader_name = preset_config.get( - "batch_group_loader_name") - - # convert to None if empty string - if batch_group_loader_name == "": - batch_group_loader_name = None + self.log.info( + "Processing `{}` as `{}` to `{}` type...".format( + preset_file, export_type, extension + ) + ) # get frame range with handles for representation range frame_start_handle = frame_start - handle_start + + # calculate duration with handles source_duration_handles = ( - source_end_handles - source_start_handles) + 1 + source_end_handles - source_start_handles) # define in/out marks in_mark = (source_start_handles - source_first_frame) + 1 out_mark = in_mark + source_duration_handles - # make test for type of preset and available reel_clip - if ( - not reel_clip - and export_type != "Sequence Publish" - ): - self.log.warning(( - "Skipping preset {}. Not available " - "reel clip for {}").format( - preset_file, segment_name - )) - continue - - # by default export source clips - exporting_clip = reel_clip - + exporting_clip = None + name_patern_xml = "_{}.".format( + unique_name) if export_type == "Sequence Publish": # change export clip to sequence - exporting_clip = sequence_clip + exporting_clip = flame.duplicate(sequence_clip) + + # only keep visible layer where instance segment is child + self.hide_others( + exporting_clip, segment_name, s_track_name) + + # change name patern + name_patern_xml = ( + "__{}.").format( + unique_name) # change in/out marks to timeline in/out in_mark = clip_in out_mark = clip_out + else: + exporting_clip = self.import_clip(clip_path) + exporting_clip.name.set_value("{}_{}".format( + asset_name, segment_name)) - # add xml tags modifications - modify_xml_data.update({ - "exportHandles": True, - "nbHandles": handles, - "startFrame": frame_start - }) + # add xml tags modifications + modify_xml_data.update({ + "exportHandles": True, + "nbHandles": handles, + "startFrame": frame_start, + "namePattern": name_patern_xml + }) - if not ignore_comment_attrs: - # add any xml overrides collected form segment.comment - modify_xml_data.update(instance.data["xml_overrides"]) + if parsed_comment_attrs: + # add any xml overrides collected form segment.comment + modify_xml_data.update(instance.data["xml_overrides"]) - self.log.debug("__ modify_xml_data: {}".format(pformat( - modify_xml_data - ))) + export_kwargs = {} + # validate xml preset file is filled + if preset_file == "": + raise ValueError( + ("Check Settings for {} preset: " + "`XML preset file` is not filled").format( + unique_name) + ) - # with maintained duplication loop all presets - with opfapi.maintained_object_duplication( - exporting_clip) as duplclip: - kwargs = {} + # resolve xml preset dir if not filled + if preset_dir == "": + preset_dir = opfapi.get_preset_path_by_xml_name( + preset_file) - if export_type == "Sequence Publish": - # only keep visible layer where instance segment is child - self.hide_others(duplclip, segment_name, s_track_name) - - # validate xml preset file is filled - if preset_file == "": + if not preset_dir: raise ValueError( ("Check Settings for {} preset: " - "`XML preset file` is not filled").format( - unique_name) + "`XML preset file` {} is not found").format( + unique_name, preset_file) ) - # resolve xml preset dir if not filled - if preset_dir == "": - preset_dir = opfapi.get_preset_path_by_xml_name( - preset_file) + # create preset path + preset_orig_xml_path = str(os.path.join( + preset_dir, preset_file + )) - if not preset_dir: - raise ValueError( - ("Check Settings for {} preset: " - "`XML preset file` {} is not found").format( - unique_name, preset_file) - ) + # define kwargs based on preset type + if "thumbnail" in unique_name: + modify_xml_data.update({ + "video/posterFrame": True, + "video/useFrameAsPoster": 1, + "namePattern": "__thumbnail" + }) + thumb_frame_number = int(in_mark + ( + source_duration_handles / 2)) - # create preset path - preset_orig_xml_path = str(os.path.join( - preset_dir, preset_file + self.log.debug("__ in_mark: {}".format(in_mark)) + self.log.debug("__ thumb_frame_number: {}".format( + thumb_frame_number )) - preset_path = opfapi.modify_preset_file( - preset_orig_xml_path, staging_dir, modify_xml_data) + export_kwargs["thumb_frame_number"] = thumb_frame_number + else: + export_kwargs.update({ + "in_mark": in_mark, + "out_mark": out_mark + }) - # define kwargs based on preset type - if "thumbnail" in unique_name: - kwargs["thumb_frame_number"] = in_mark + ( - source_duration_handles / 2) - else: - kwargs.update({ - "in_mark": in_mark, - "out_mark": out_mark - }) + self.log.debug("__ modify_xml_data: {}".format( + pformat(modify_xml_data) + )) + preset_path = opfapi.modify_preset_file( + preset_orig_xml_path, staging_dir, modify_xml_data) - # get and make export dir paths - export_dir_path = str(os.path.join( - staging_dir, unique_name - )) - os.makedirs(export_dir_path) + # get and make export dir paths + export_dir_path = str(os.path.join( + staging_dir, unique_name + )) + os.makedirs(export_dir_path) - # export - opfapi.export_clip( - export_dir_path, duplclip, preset_path, **kwargs) + # export + opfapi.export_clip( + export_dir_path, exporting_clip, preset_path, **export_kwargs) - extension = preset_config["ext"] + # make sure only first segment is used if underscore in name + # HACK: `ftrackreview_withLUT` will result only in `ftrackreview` + repr_name = unique_name.split("_")[0] - # create representation data - representation_data = { - "name": unique_name, - "outputName": unique_name, - "ext": extension, - "stagingDir": export_dir_path, - "tags": repre_tags, - "data": { - "colorspace": color_out - }, - "load_to_batch_group": load_to_batch_group, - "batch_group_loader_name": batch_group_loader_name - } + # create representation data + representation_data = { + "name": repr_name, + "outputName": repr_name, + "ext": extension, + "stagingDir": export_dir_path, + "tags": repre_tags, + "data": { + "colorspace": color_out + }, + "load_to_batch_group": preset_config.get( + "load_to_batch_group"), + "batch_group_loader_name": preset_config.get( + "batch_group_loader_name") or None + } - # collect all available content of export dir - files = os.listdir(export_dir_path) + # collect all available content of export dir + files = os.listdir(export_dir_path) - # make sure no nested folders inside - n_stage_dir, n_files = self._unfolds_nested_folders( - export_dir_path, files, extension) + # make sure no nested folders inside + n_stage_dir, n_files = self._unfolds_nested_folders( + export_dir_path, files, extension) - # fix representation in case of nested folders - if n_stage_dir: - representation_data["stagingDir"] = n_stage_dir - files = n_files + # fix representation in case of nested folders + if n_stage_dir: + representation_data["stagingDir"] = n_stage_dir + files = n_files - # add files to represetation but add - # imagesequence as list - if ( - # first check if path in files is not mov extension - [ - f for f in files - if os.path.splitext(f)[-1] == ".mov" - ] - # then try if thumbnail is not in unique name - or unique_name == "thumbnail" - ): - representation_data["files"] = files.pop() - else: - representation_data["files"] = files + # add files to represetation but add + # imagesequence as list + if ( + # first check if path in files is not mov extension + [ + f for f in files + if os.path.splitext(f)[-1] == ".mov" + ] + # then try if thumbnail is not in unique name + or unique_name == "thumbnail" + ): + representation_data["files"] = files.pop() + else: + representation_data["files"] = files - # add frame range - if preset_config["representation_add_range"]: - representation_data.update({ - "frameStart": frame_start_handle, - "frameEnd": ( - frame_start_handle + source_duration_handles), - "fps": instance.data["fps"] - }) + # add frame range + if preset_config["representation_add_range"]: + representation_data.update({ + "frameStart": frame_start_handle, + "frameEnd": ( + frame_start_handle + source_duration_handles), + "fps": instance.data["fps"] + }) - instance.data["representations"].append(representation_data) + instance.data["representations"].append(representation_data) - # add review family if found in tags - if "review" in repre_tags: - instance.data["families"].append("review") + # add review family if found in tags + if "review" in repre_tags: + instance.data["families"].append("review") - self.log.info("Added representation: {}".format( - representation_data)) + self.log.info("Added representation: {}".format( + representation_data)) + + if export_type == "Sequence Publish": + # at the end remove the duplicated clip + flame.delete(exporting_clip) self.log.debug("All representations: {}".format( pformat(instance.data["representations"]))) + def _should_skip(self, preset_config, clip_path, unique_name): + # get activating attributes + activated_preset = preset_config["active"] + filter_path_regex = preset_config.get("filter_path_regex") + + self.log.info( + "Preset `{}` is active `{}` with filter `{}`".format( + unique_name, activated_preset, filter_path_regex + ) + ) + self.log.debug( + "__ clip_path: `{}`".format(clip_path)) + + # skip if not activated presete + if not activated_preset: + return True + + # exclude by regex filter if any + if ( + filter_path_regex + and not re.search(filter_path_regex, clip_path) + ): + return True + def _unfolds_nested_folders(self, stage_dir, files_list, ext): """Unfolds nested folders @@ -373,3 +396,27 @@ class ExtractSubsetResources(openpype.api.Extractor): for segment in track.segments: if segment.name.get_value() != segment_name: segment.hidden = True + + def import_clip(self, path): + """ + Import clip from path + """ + dir_path = os.path.dirname(path) + media_info = MediaInfoFile(path, logger=self.log) + file_pattern = media_info.file_pattern + self.log.debug("__ file_pattern: {}".format(file_pattern)) + + # rejoin the pattern to dir path + new_path = os.path.join(dir_path, file_pattern) + + clips = flame.import_clips(new_path) + self.log.info("Clips [{}] imported from `{}`".format(clips, path)) + + if not clips: + self.log.warning("Path `{}` is not having any clips".format(path)) + return None + elif len(clips) > 1: + self.log.warning( + "Path `{}` is containing more that one clip".format(path) + ) + return clips[0] diff --git a/openpype/hosts/flame/plugins/publish/validate_source_clip.py b/openpype/hosts/flame/plugins/publish/validate_source_clip.py deleted file mode 100644 index 345c00e05a..0000000000 --- a/openpype/hosts/flame/plugins/publish/validate_source_clip.py +++ /dev/null @@ -1,26 +0,0 @@ -import pyblish - - -@pyblish.api.log -class ValidateSourceClip(pyblish.api.InstancePlugin): - """Validate instance is not having empty `flameSourceClip`""" - - order = pyblish.api.ValidatorOrder - label = "Validate Source Clip" - hosts = ["flame"] - families = ["clip"] - optional = True - active = False - - def process(self, instance): - flame_source_clip = instance.data["flameSourceClip"] - - self.log.debug("_ flame_source_clip: {}".format(flame_source_clip)) - - if flame_source_clip is None: - raise AttributeError(( - "Timeline segment `{}` is not having " - "relative clip in reels. Please make sure " - "you push `Save Sources` button in Conform Tab").format( - instance.data["asset"] - )) diff --git a/openpype/hosts/fusion/api/pipeline.py b/openpype/hosts/fusion/api/pipeline.py index 0867b464d5..54002f9f51 100644 --- a/openpype/hosts/fusion/api/pipeline.py +++ b/openpype/hosts/fusion/api/pipeline.py @@ -45,7 +45,8 @@ def install(): This is where you install menus and register families, data and loaders into fusion. - It is called automatically when installing via `api.install(avalon.fusion)` + It is called automatically when installing via + `openpype.pipeline.install_host(openpype.hosts.fusion.api)` See the Maya equivalent for inspiration on how to implement this. diff --git a/openpype/hosts/fusion/plugins/load/actions.py b/openpype/hosts/fusion/plugins/load/actions.py index bc59cec77f..819c9272fd 100644 --- a/openpype/hosts/fusion/plugins/load/actions.py +++ b/openpype/hosts/fusion/plugins/load/actions.py @@ -6,7 +6,7 @@ from openpype.pipeline import load class FusionSetFrameRangeLoader(load.LoaderPlugin): - """Specific loader of Alembic for the avalon.animation family""" + """Set frame range excluding pre- and post-handles""" families = ["animation", "camera", @@ -40,7 +40,7 @@ class FusionSetFrameRangeLoader(load.LoaderPlugin): class FusionSetFrameRangeWithHandlesLoader(load.LoaderPlugin): - """Specific loader of Alembic for the avalon.animation family""" + """Set frame range including pre- and post-handles""" families = ["animation", "camera", diff --git a/openpype/hosts/harmony/api/TB_sceneOpened.js b/openpype/hosts/harmony/api/TB_sceneOpened.js index 6a403fa65e..e7cd555332 100644 --- a/openpype/hosts/harmony/api/TB_sceneOpened.js +++ b/openpype/hosts/harmony/api/TB_sceneOpened.js @@ -35,7 +35,11 @@ function Client() { self.pack = function(num) { var ascii=''; for (var i = 3; i >= 0; i--) { - ascii += String.fromCharCode((num >> (8 * i)) & 255); + var hex = ((num >> (8 * i)) & 255).toString(16); + if (hex.length < 2){ + ascii += "0"; + } + ascii += hex; } return ascii; }; @@ -279,19 +283,22 @@ function Client() { }; self._send = function(message) { - var data = new QByteArray(); - var outstr = new QDataStream(data, QIODevice.WriteOnly); - outstr.writeInt(0); - data.append('UTF-8'); - outstr.device().seek(0); - outstr.writeInt(data.size() - 4); - var codec = QTextCodec.codecForUtfText(data); - var msg = codec.fromUnicode(message); - var l = msg.size(); - var coded = new QByteArray('AH').append(self.pack(l)); - coded = coded.append(msg); - self.socket.write(new QByteArray(coded)); - self.logDebug('Sent.'); + /** Harmony 21.1 doesn't have QDataStream anymore. + + This means we aren't able to write bytes into QByteArray so we had + modify how content lenght is sent do the server. + Content lenght is sent as string of 8 char convertible into integer + (instead of 0x00000001[4 bytes] > "000000001"[8 bytes]) */ + var codec_name = new QByteArray().append("UTF-8"); + + var codec = QTextCodec.codecForName(codec_name); + var msg = codec.fromUnicode(message); + var l = msg.size(); + var header = new QByteArray().append('AH').append(self.pack(l)); + var coded = msg.prepend(header); + + self.socket.write(coded); + self.logDebug('Sent.'); }; self.waitForLock = function() { @@ -351,7 +358,14 @@ function start() { app.avalonClient = new Client(); app.avalonClient.socket.connectToHost(host, port); } - var menuBar = QApplication.activeWindow().menuBar(); + var mainWindow = null; + var widgets = QApplication.topLevelWidgets(); + for (var i = 0 ; i < widgets.length; i++) { + if (widgets[i] instanceof QMainWindow){ + mainWindow = widgets[i]; + } + } + var menuBar = mainWindow.menuBar(); var actions = menuBar.actions(); app.avalonMenu = null; diff --git a/openpype/hosts/harmony/api/lib.py b/openpype/hosts/harmony/api/lib.py index 53fd0f07dd..e5e7ad1b7e 100644 --- a/openpype/hosts/harmony/api/lib.py +++ b/openpype/hosts/harmony/api/lib.py @@ -463,7 +463,7 @@ def imprint(node_id, data, remove=False): remove (bool): Removes the data from the scene. Example: - >>> from avalon.harmony import lib + >>> from openpype.hosts.harmony.api import lib >>> node = "Top/Display" >>> data = {"str": "someting", "int": 1, "float": 0.32, "bool": True} >>> lib.imprint(layer, data) diff --git a/openpype/hosts/harmony/api/server.py b/openpype/hosts/harmony/api/server.py index 88cfe54521..0de359ec61 100644 --- a/openpype/hosts/harmony/api/server.py +++ b/openpype/hosts/harmony/api/server.py @@ -88,21 +88,25 @@ class Server(threading.Thread): """ current_time = time.time() while True: - + self.log.info("wait ttt") # Receive the data in small chunks and retransmit it request = None - header = self.connection.recv(6) + header = self.connection.recv(10) if len(header) == 0: # null data received, socket is closing. self.log.info(f"[{self.timestamp()}] Connection closing.") break + if header[0:2] != b"AH": self.log.error("INVALID HEADER") - length = struct.unpack(">I", header[2:])[0] + content_length_str = header[2:].decode() + + length = int(content_length_str, 16) data = self.connection.recv(length) while (len(data) < length): # we didn't received everything in first try, lets wait for # all data. + self.log.info("loop") time.sleep(0.1) if self.connection is None: self.log.error(f"[{self.timestamp()}] " @@ -113,7 +117,7 @@ class Server(threading.Thread): break data += self.connection.recv(length - len(data)) - + self.log.debug("data:: {} {}".format(data, type(data))) self.received += data.decode("utf-8") pretty = self._pretty(self.received) self.log.debug( diff --git a/openpype/hosts/harmony/plugins/publish/collect_farm_render.py b/openpype/hosts/harmony/plugins/publish/collect_farm_render.py index f5bf051243..3e9e680efd 100644 --- a/openpype/hosts/harmony/plugins/publish/collect_farm_render.py +++ b/openpype/hosts/harmony/plugins/publish/collect_farm_render.py @@ -144,6 +144,7 @@ class CollectFarmRender(openpype.lib.abstract_collect_render. label=node.split("/")[1], subset=subset_name, asset=legacy_io.Session["AVALON_ASSET"], + task=task_name, attachTo=False, setMembers=[node], publish=info[4], diff --git a/openpype/hosts/hiero/api/__init__.py b/openpype/hosts/hiero/api/__init__.py index f3c32b268c..fc2d017f04 100644 --- a/openpype/hosts/hiero/api/__init__.py +++ b/openpype/hosts/hiero/api/__init__.py @@ -27,6 +27,7 @@ from .lib import ( get_track_items, get_current_project, get_current_sequence, + get_timeline_selection, get_current_track, get_track_item_pype_tag, set_track_item_pype_tag, @@ -80,6 +81,7 @@ __all__ = [ "get_track_items", "get_current_project", "get_current_sequence", + "get_timeline_selection", "get_current_track", "get_track_item_pype_tag", "set_track_item_pype_tag", diff --git a/openpype/hosts/hiero/api/events.py b/openpype/hosts/hiero/api/events.py index 7fab3edfc8..59fd278a81 100644 --- a/openpype/hosts/hiero/api/events.py +++ b/openpype/hosts/hiero/api/events.py @@ -109,8 +109,9 @@ def register_hiero_events(): # hiero.core.events.registerInterest("kShutdown", shutDown) # hiero.core.events.registerInterest("kStartup", startupCompleted) - hiero.core.events.registerInterest( - ("kSelectionChanged", "kTimeline"), selection_changed_timeline) + # INFO: was disabled because it was slowing down timeline operations + # hiero.core.events.registerInterest( + # ("kSelectionChanged", "kTimeline"), selection_changed_timeline) # workfiles try: diff --git a/openpype/hosts/hiero/api/lib.py b/openpype/hosts/hiero/api/lib.py index 0e64ddcaf5..761a36bd0f 100644 --- a/openpype/hosts/hiero/api/lib.py +++ b/openpype/hosts/hiero/api/lib.py @@ -1,6 +1,8 @@ """ Host specific functions where host api is connected """ + +from copy import deepcopy import os import re import sys @@ -89,13 +91,19 @@ def get_current_sequence(name=None, new=False): if not sequence: # if nothing found create new with input name sequence = get_current_sequence(name, True) - elif not name and not new: + else: # if name is none and new is False then return current open sequence sequence = hiero.ui.activeSequence() return sequence +def get_timeline_selection(): + active_sequence = hiero.ui.activeSequence() + timeline_editor = hiero.ui.getTimelineEditor(active_sequence) + return list(timeline_editor.selection()) + + def get_current_track(sequence, name, audio=False): """ Get current track in context of active project. @@ -118,7 +126,7 @@ def get_current_track(sequence, name, audio=False): # get track by name track = None for _track in tracks: - if _track.name() in name: + if _track.name() == name: track = _track if not track: @@ -126,13 +134,14 @@ def get_current_track(sequence, name, audio=False): track = hiero.core.VideoTrack(name) else: track = hiero.core.AudioTrack(name) + sequence.addTrack(track) return track def get_track_items( - selected=False, + selection=False, sequence_name=None, track_item_name=None, track_name=None, @@ -143,7 +152,7 @@ def get_track_items( """Get all available current timeline track items. Attribute: - selected (bool)[optional]: return only selected items on timeline + selection (list)[optional]: list of selected track items sequence_name (str)[optional]: return only clips from input sequence track_item_name (str)[optional]: return only item with input name track_name (str)[optional]: return only items from track name @@ -155,32 +164,34 @@ def get_track_items( Return: list or hiero.core.TrackItem: list of track items or single track item """ - return_list = list() - track_items = list() + track_type = track_type or "video" + selection = selection or [] + return_list = [] # get selected track items or all in active sequence - if selected: + if selection: try: - selected_items = list(hiero.selection) - for item in selected_items: - if track_name and track_name in item.parent().name(): - # filter only items fitting input track name - track_items.append(item) - elif not track_name: - # or add all if no track_name was defined - track_items.append(item) + for track_item in selection: + log.info("___ track_item: {}".format(track_item)) + # make sure only trackitems are selected + if not isinstance(track_item, hiero.core.TrackItem): + continue + + if _validate_all_atrributes( + track_item, + track_item_name, + track_name, + track_type, + check_enabled, + check_tagged + ): + log.info("___ valid trackitem: {}".format(track_item)) + return_list.append(track_item) except AttributeError: pass - # check if any collected track items are - # `core.Hiero.Python.TrackItem` instance - if track_items: - any_track_item = track_items[0] - if not isinstance(any_track_item, hiero.core.TrackItem): - selected_items = [] - # collect all available active sequence track items - if not track_items: + if not return_list: sequence = get_current_sequence(name=sequence_name) # get all available tracks from sequence tracks = list(sequence.audioTracks()) + list(sequence.videoTracks()) @@ -191,42 +202,76 @@ def get_track_items( if check_enabled and not track.isEnabled(): continue # and all items in track - for item in track.items(): - if check_tagged and not item.tags(): + for track_item in track.items(): + # make sure no subtrackitem is also track items + if not isinstance(track_item, hiero.core.TrackItem): continue - # check if track item is enabled - if check_enabled: - if not item.isEnabled(): - continue - if track_item_name: - if track_item_name in item.name(): - return item - # make sure only track items with correct track names are added - if track_name and track_name in track.name(): - # filter out only defined track_name items - track_items.append(item) - elif not track_name: - # or add all if no track_name is defined - track_items.append(item) + if _validate_all_atrributes( + track_item, + track_item_name, + track_name, + track_type, + check_enabled, + check_tagged + ): + return_list.append(track_item) - # filter out only track items with defined track_type - for track_item in track_items: - if track_type and track_type == "video" and isinstance( + return return_list + + +def _validate_all_atrributes( + track_item, + track_item_name, + track_name, + track_type, + check_enabled, + check_tagged +): + def _validate_correct_name_track_item(): + if track_item_name and track_item_name in track_item.name(): + return True + elif not track_item_name: + return True + + def _validate_tagged_track_item(): + if check_tagged and track_item.tags(): + return True + elif not check_tagged: + return True + + def _validate_enabled_track_item(): + if check_enabled and track_item.isEnabled(): + return True + elif not check_enabled: + return True + + def _validate_parent_track_item(): + if track_name and track_name in track_item.parent().name(): + # filter only items fitting input track name + return True + elif not track_name: + # or add all if no track_name was defined + return True + + def _validate_type_track_item(): + if track_type == "video" and isinstance( track_item.parent(), hiero.core.VideoTrack): # only video track items are allowed - return_list.append(track_item) - elif track_type and track_type == "audio" and isinstance( + return True + elif track_type == "audio" and isinstance( track_item.parent(), hiero.core.AudioTrack): # only audio track items are allowed - return_list.append(track_item) - elif not track_type: - # add all if no track_type is defined - return_list.append(track_item) + return True - # return output list but make sure all items are TrackItems - return [_i for _i in return_list - if type(_i) == hiero.core.TrackItem] + # check if track item is enabled + return all([ + _validate_enabled_track_item(), + _validate_type_track_item(), + _validate_tagged_track_item(), + _validate_parent_track_item(), + _validate_correct_name_track_item() + ]) def get_track_item_pype_tag(track_item): @@ -245,7 +290,7 @@ def get_track_item_pype_tag(track_item): return None for tag in _tags: # return only correct tag defined by global name - if tag.name() in self.pype_tag_name: + if tag.name() == self.pype_tag_name: return tag @@ -266,7 +311,7 @@ def set_track_item_pype_tag(track_item, data=None): "editable": "0", "note": "OpenPype data container", "icon": "openpype_icon.png", - "metadata": {k: v for k, v in data.items()} + "metadata": dict(data.items()) } # get available pype tag if any _tag = get_track_item_pype_tag(track_item) @@ -301,9 +346,9 @@ def get_track_item_pype_data(track_item): return None # get tag metadata attribute - tag_data = tag.metadata() + tag_data = deepcopy(dict(tag.metadata())) # convert tag metadata to normal keys names and values to correct types - for k, v in dict(tag_data).items(): + for k, v in tag_data.items(): key = k.replace("tag.", "") try: @@ -324,7 +369,7 @@ def get_track_item_pype_data(track_item): log.warning(msg) value = v - data.update({key: value}) + data[key] = value return data @@ -497,7 +542,7 @@ class PyblishSubmission(hiero.exporters.FnSubmission.Submission): from . import publish # Add submission to Hiero module for retrieval in plugins. hiero.submission = self - publish() + publish(hiero.ui.mainWindow()) def add_submission(): @@ -527,7 +572,7 @@ class PublishAction(QtWidgets.QAction): # from getting picked up when not using the "Export" dialog. if hasattr(hiero, "submission"): del hiero.submission - publish() + publish(hiero.ui.mainWindow()) def eventHandler(self, event): # Add the Menu to the right-click menu @@ -553,10 +598,10 @@ class PublishAction(QtWidgets.QAction): # # ''' # import hiero.core -# from avalon.nuke import imprint -# from pype.hosts.nuke import ( -# lib as nklib -# ) +# from openpype.hosts.nuke.api.lib import ( +# BuildWorkfile, +# imprint +# ) # # # check if the file exists if does then Raise "File exists!" # if os.path.exists(filepath): @@ -583,8 +628,7 @@ class PublishAction(QtWidgets.QAction): # # nuke_script.addNode(root_node) # -# # here to call pype.hosts.nuke.lib.BuildWorkfile -# script_builder = nklib.BuildWorkfile( +# script_builder = BuildWorkfile( # root_node=root_node, # root_path=root_path, # nodes=nuke_script.getNodes(), @@ -894,32 +938,33 @@ def apply_colorspace_clips(): def is_overlapping(ti_test, ti_original, strict=False): - covering_exp = bool( + covering_exp = ( (ti_test.timelineIn() <= ti_original.timelineIn()) and (ti_test.timelineOut() >= ti_original.timelineOut()) ) - inside_exp = bool( + + if strict: + return covering_exp + + inside_exp = ( (ti_test.timelineIn() >= ti_original.timelineIn()) and (ti_test.timelineOut() <= ti_original.timelineOut()) ) - overlaying_right_exp = bool( + overlaying_right_exp = ( (ti_test.timelineIn() < ti_original.timelineOut()) and (ti_test.timelineOut() >= ti_original.timelineOut()) ) - overlaying_left_exp = bool( + overlaying_left_exp = ( (ti_test.timelineOut() > ti_original.timelineIn()) and (ti_test.timelineIn() <= ti_original.timelineIn()) ) - if not strict: - return any(( - covering_exp, - inside_exp, - overlaying_right_exp, - overlaying_left_exp - )) - else: - return covering_exp + return any(( + covering_exp, + inside_exp, + overlaying_right_exp, + overlaying_left_exp + )) def get_sequence_pattern_and_padding(file): @@ -937,17 +982,13 @@ def get_sequence_pattern_and_padding(file): """ foundall = re.findall( r"(#+)|(%\d+d)|(?<=[^a-zA-Z0-9])(\d+)(?=\.\w+$)", file) - if foundall: - found = sorted(list(set(foundall[0])))[-1] - - if "%" in found: - padding = int(re.findall(r"\d+", found)[-1]) - else: - padding = len(found) - - return found, padding - else: + if not foundall: return None, None + found = sorted(list(set(foundall[0])))[-1] + + padding = int( + re.findall(r"\d+", found)[-1]) if "%" in found else len(found) + return found, padding def sync_clip_name_to_data_asset(track_items_list): @@ -983,7 +1024,7 @@ def sync_clip_name_to_data_asset(track_items_list): print("asset was changed in clip: {}".format(ti_name)) -def check_inventory_versions(): +def check_inventory_versions(track_items=None): """ Actual version color idetifier of Loaded containers @@ -994,14 +1035,14 @@ def check_inventory_versions(): """ from . import parse_container + track_item = track_items or get_track_items() # presets clip_color_last = "green" clip_color = "red" # get all track items from current timeline - for track_item in get_track_items(): + for track_item in track_item: container = parse_container(track_item) - if container: # get representation from io representation = legacy_io.find_one({ @@ -1039,29 +1080,31 @@ def selection_changed_timeline(event): timeline_editor = event.sender selection = timeline_editor.selection() - selection = [ti for ti in selection - if isinstance(ti, hiero.core.TrackItem)] + track_items = get_track_items( + selection=selection, + track_type="video", + check_enabled=True, + check_locked=True, + check_tagged=True + ) # run checking function - sync_clip_name_to_data_asset(selection) - - # also mark old versions of loaded containers - check_inventory_versions() + sync_clip_name_to_data_asset(track_items) def before_project_save(event): track_items = get_track_items( - selected=False, track_type="video", check_enabled=True, check_locked=True, - check_tagged=True) + check_tagged=True + ) # run checking function sync_clip_name_to_data_asset(track_items) # also mark old versions of loaded containers - check_inventory_versions() + check_inventory_versions(track_items) def get_main_window(): diff --git a/openpype/hosts/hiero/api/pipeline.py b/openpype/hosts/hiero/api/pipeline.py index 8025ebff05..9b628ec70b 100644 --- a/openpype/hosts/hiero/api/pipeline.py +++ b/openpype/hosts/hiero/api/pipeline.py @@ -143,6 +143,11 @@ def parse_container(track_item, validate=True): """ # convert tag metadata to normal keys names data = lib.get_track_item_pype_data(track_item) + if ( + not data + or data.get("id") != "pyblish.avalon.container" + ): + return if validate and data and data.get("schema"): schema.validate(data) diff --git a/openpype/hosts/hiero/api/plugin.py b/openpype/hosts/hiero/api/plugin.py index 54e66bf99a..add416d04e 100644 --- a/openpype/hosts/hiero/api/plugin.py +++ b/openpype/hosts/hiero/api/plugin.py @@ -1,4 +1,5 @@ import os +from pprint import pformat import re from copy import deepcopy @@ -400,7 +401,8 @@ class ClipLoader: # inject asset data to representation dict self._get_asset_data() - log.debug("__init__ self.data: `{}`".format(self.data)) + log.info("__init__ self.data: `{}`".format(pformat(self.data))) + log.info("__init__ options: `{}`".format(pformat(options))) # add active components to class if self.new_sequence: @@ -482,7 +484,9 @@ class ClipLoader: """ asset_name = self.context["representation"]["context"]["asset"] - self.data["assetData"] = openpype.get_asset(asset_name)["data"] + asset_doc = openpype.get_asset(asset_name) + log.debug("__ asset_doc: {}".format(pformat(asset_doc))) + self.data["assetData"] = asset_doc["data"] def _make_track_item(self, source_bin_item, audio=False): """ Create track item with """ @@ -500,7 +504,7 @@ class ClipLoader: track_item.setSource(clip) track_item.setSourceIn(self.handle_start) track_item.setTimelineIn(self.timeline_in) - track_item.setSourceOut(self.media_duration - self.handle_end) + track_item.setSourceOut((self.media_duration) - self.handle_end) track_item.setTimelineOut(self.timeline_out) track_item.setPlaybackSpeed(1) self.active_track.addTrackItem(track_item) @@ -520,14 +524,18 @@ class ClipLoader: self.handle_start = self.data["versionData"].get("handleStart") self.handle_end = self.data["versionData"].get("handleEnd") if self.handle_start is None: - self.handle_start = int(self.data["assetData"]["handleStart"]) + self.handle_start = self.data["assetData"]["handleStart"] if self.handle_end is None: - self.handle_end = int(self.data["assetData"]["handleEnd"]) + self.handle_end = self.data["assetData"]["handleEnd"] + + self.handle_start = int(self.handle_start) + self.handle_end = int(self.handle_end) if self.sequencial_load: last_track_item = lib.get_track_items( sequence_name=self.active_sequence.name(), - track_name=self.active_track.name()) + track_name=self.active_track.name() + ) if len(last_track_item) == 0: last_timeline_out = 0 else: @@ -541,17 +549,12 @@ class ClipLoader: self.timeline_in = int(self.data["assetData"]["clipIn"]) self.timeline_out = int(self.data["assetData"]["clipOut"]) + log.debug("__ self.timeline_in: {}".format(self.timeline_in)) + log.debug("__ self.timeline_out: {}".format(self.timeline_out)) + # check if slate is included - # either in version data families or by calculating frame diff - slate_on = next( - # check iterate if slate is in families - (f for f in self.context["version"]["data"]["families"] - if "slate" in f), - # if nothing was found then use default None - # so other bool could be used - None) or bool(int( - (self.timeline_out - self.timeline_in + 1) - + self.handle_start + self.handle_end) < self.media_duration) + slate_on = "slate" in self.context["version"]["data"]["families"] + log.debug("__ slate_on: {}".format(slate_on)) # if slate is on then remove the slate frame from beginning if slate_on: @@ -572,7 +575,7 @@ class ClipLoader: # there were some cases were hiero was not creating it source_bin_item = None for item in self.active_bin.items(): - if self.data["clip_name"] in item.name(): + if self.data["clip_name"] == item.name(): source_bin_item = item if not source_bin_item: log.warning("Problem with created Source clip: `{}`".format( @@ -599,8 +602,8 @@ class Creator(LegacyCreator): rename_index = None def __init__(self, *args, **kwargs): - import openpype.hosts.hiero.api as phiero super(Creator, self).__init__(*args, **kwargs) + import openpype.hosts.hiero.api as phiero self.presets = openpype.get_current_project_settings()[ "hiero"]["create"].get(self.__class__.__name__, {}) @@ -609,7 +612,10 @@ class Creator(LegacyCreator): self.sequence = phiero.get_current_sequence() if (self.options or {}).get("useSelection"): - self.selected = phiero.get_track_items(selected=True) + timeline_selection = phiero.get_timeline_selection() + self.selected = phiero.get_track_items( + selection=timeline_selection + ) else: self.selected = phiero.get_track_items() @@ -716,6 +722,10 @@ class PublishClip: else: self.tag_data.update({"reviewTrack": None}) + log.debug("___ self.tag_data: {}".format( + pformat(self.tag_data) + )) + # create pype tag on track_item and add data lib.imprint(self.track_item, self.tag_data) diff --git a/openpype/hosts/hiero/api/tags.py b/openpype/hosts/hiero/api/tags.py index e15e3119a6..8c6ff2a77b 100644 --- a/openpype/hosts/hiero/api/tags.py +++ b/openpype/hosts/hiero/api/tags.py @@ -10,16 +10,6 @@ log = Logger.get_logger(__name__) def tag_data(): return { - # "Retiming": { - # "editable": "1", - # "note": "Clip has retime or TimeWarp effects (or multiple effects stacked on the clip)", # noqa - # "icon": "retiming.png", - # "metadata": { - # "family": "retiming", - # "marginIn": 1, - # "marginOut": 1 - # } - # }, "[Lenses]": { "Set lense here": { "editable": "1", @@ -48,6 +38,16 @@ def tag_data(): "family": "comment", "subset": "main" } + }, + "FrameMain": { + "editable": "1", + "note": "Publishing a frame subset.", + "icon": "z_layer_main.png", + "metadata": { + "family": "frame", + "subset": "main", + "format": "png" + } } } @@ -86,7 +86,7 @@ def update_tag(tag, data): # due to hiero bug we have to make sure keys which are not existent in # data are cleared of value by `None` - for _mk in mtd.keys(): + for _mk in mtd.dict().keys(): if _mk.replace("tag.", "") not in data_mtd.keys(): mtd.setValue(_mk, str(None)) diff --git a/openpype/hosts/hiero/plugins/load/load_clip.py b/openpype/hosts/hiero/plugins/load/load_clip.py index da4326c8c1..a3365253b3 100644 --- a/openpype/hosts/hiero/plugins/load/load_clip.py +++ b/openpype/hosts/hiero/plugins/load/load_clip.py @@ -3,10 +3,6 @@ from openpype.pipeline import ( get_representation_path, ) import openpype.hosts.hiero.api as phiero -# from openpype.hosts.hiero.api import plugin, lib -# reload(lib) -# reload(plugin) -# reload(phiero) class LoadClip(phiero.SequenceLoader): @@ -106,7 +102,7 @@ class LoadClip(phiero.SequenceLoader): name = container['name'] namespace = container['namespace'] track_item = phiero.get_track_items( - track_item_name=namespace) + track_item_name=namespace).pop() version = legacy_io.find_one({ "type": "version", "_id": representation["parent"] @@ -157,7 +153,7 @@ class LoadClip(phiero.SequenceLoader): # load clip to timeline and get main variables namespace = container['namespace'] track_item = phiero.get_track_items( - track_item_name=namespace) + track_item_name=namespace).pop() track = track_item.parent() # remove track item from track diff --git a/openpype/hosts/hiero/plugins/publish/collect_frame_tag_instances.py b/openpype/hosts/hiero/plugins/publish/collect_frame_tag_instances.py new file mode 100644 index 0000000000..982a34efd6 --- /dev/null +++ b/openpype/hosts/hiero/plugins/publish/collect_frame_tag_instances.py @@ -0,0 +1,142 @@ +from pprint import pformat +import re +import ast +import json + +import pyblish.api + + +class CollectFrameTagInstances(pyblish.api.ContextPlugin): + """Collect frames from tags. + + Tag is expected to have metadata: + { + "family": "frame" + "subset": "main" + } + """ + + order = pyblish.api.CollectorOrder + label = "Collect Frames" + hosts = ["hiero"] + + def process(self, context): + self._context = context + + # collect all sequence tags + subset_data = self._create_frame_subset_data_sequence(context) + + self.log.debug("__ subset_data: {}".format( + pformat(subset_data) + )) + + # create instances + self._create_instances(subset_data) + + def _get_tag_data(self, tag): + data = {} + + # get tag metadata attribute + tag_data = tag.metadata() + + # convert tag metadata to normal keys names and values to correct types + for k, v in dict(tag_data).items(): + key = k.replace("tag.", "") + + try: + # capture exceptions which are related to strings only + if re.match(r"^[\d]+$", v): + value = int(v) + elif re.match(r"^True$", v): + value = True + elif re.match(r"^False$", v): + value = False + elif re.match(r"^None$", v): + value = None + elif re.match(r"^[\w\d_]+$", v): + value = v + else: + value = ast.literal_eval(v) + except (ValueError, SyntaxError): + value = v + + data[key] = value + + return data + + def _create_frame_subset_data_sequence(self, context): + + sequence_tags = [] + sequence = context.data["activeTimeline"] + + # get all publishable sequence frames + publish_frames = range(int(sequence.duration() + 1)) + + self.log.debug("__ publish_frames: {}".format( + pformat(publish_frames) + )) + + # get all sequence tags + for tag in sequence.tags(): + tag_data = self._get_tag_data(tag) + self.log.debug("__ tag_data: {}".format( + pformat(tag_data) + )) + if not tag_data: + continue + + if "family" not in tag_data: + continue + + if tag_data["family"] != "frame": + continue + + sequence_tags.append(tag_data) + + self.log.debug("__ sequence_tags: {}".format( + pformat(sequence_tags) + )) + + # first collect all available subset tag frames + subset_data = {} + for tag_data in sequence_tags: + frame = int(tag_data["start"]) + + if frame not in publish_frames: + continue + + subset = tag_data["subset"] + + if subset in subset_data: + # update existing subset key + subset_data[subset]["frames"].append(frame) + else: + # create new subset key + subset_data[subset] = { + "frames": [frame], + "format": tag_data["format"], + "asset": context.data["assetEntity"]["name"] + } + return subset_data + + def _create_instances(self, subset_data): + # create instance per subset + for subset_name, subset_data in subset_data.items(): + name = "frame" + subset_name.title() + data = { + "name": name, + "label": "{} {}".format(name, subset_data["frames"]), + "family": "image", + "families": ["frame"], + "asset": subset_data["asset"], + "subset": name, + "format": subset_data["format"], + "frames": subset_data["frames"] + } + self._context.create_instance(**data) + + self.log.info( + "Created instance: {}".format( + json.dumps(data, sort_keys=True, indent=4) + ) + ) diff --git a/openpype/hosts/hiero/plugins/publish/extract_frames.py b/openpype/hosts/hiero/plugins/publish/extract_frames.py new file mode 100644 index 0000000000..aa3eda2e9f --- /dev/null +++ b/openpype/hosts/hiero/plugins/publish/extract_frames.py @@ -0,0 +1,82 @@ +import os +import pyblish.api +import openpype + + +class ExtractFrames(openpype.api.Extractor): + """Extracts frames""" + + order = pyblish.api.ExtractorOrder + label = "Extract Frames" + hosts = ["hiero"] + families = ["frame"] + movie_extensions = ["mov", "mp4"] + + def process(self, instance): + oiio_tool_path = openpype.lib.get_oiio_tools_path() + staging_dir = self.staging_dir(instance) + output_template = os.path.join(staging_dir, instance.data["name"]) + sequence = instance.context.data["activeTimeline"] + + files = [] + for frame in instance.data["frames"]: + track_item = sequence.trackItemAt(frame) + media_source = track_item.source().mediaSource() + input_path = media_source.fileinfos()[0].filename() + input_frame = ( + track_item.mapTimelineToSource(frame) + + track_item.source().mediaSource().startTime() + ) + output_ext = instance.data["format"] + output_path = output_template + output_path += ".{:04d}.{}".format(int(frame), output_ext) + + args = [oiio_tool_path] + + ext = os.path.splitext(input_path)[1][1:] + if ext in self.movie_extensions: + args.extend(["--subimage", str(int(input_frame))]) + else: + args.extend(["--frames", str(int(input_frame))]) + + if ext == "exr": + args.extend(["--powc", "0.45,0.45,0.45,1.0"]) + + args.extend([input_path, "-o", output_path]) + output = openpype.api.run_subprocess(args) + + failed_output = "oiiotool produced no output." + if failed_output in output: + raise ValueError( + "oiiotool processing failed. Args: {}".format(args) + ) + + files.append(output_path) + + # Feedback to user because "oiiotool" can make the publishing + # appear unresponsive. + self.log.info( + "Processed {} of {} frames".format( + instance.data["frames"].index(frame) + 1, + len(instance.data["frames"]) + ) + ) + + if len(files) == 1: + instance.data["representations"] = [ + { + "name": output_ext, + "ext": output_ext, + "files": os.path.basename(files[0]), + "stagingDir": staging_dir + } + ] + else: + instance.data["representations"] = [ + { + "name": output_ext, + "ext": output_ext, + "files": [os.path.basename(x) for x in files], + "stagingDir": staging_dir + } + ] diff --git a/openpype/hosts/hiero/plugins/publish/precollect_instances.py b/openpype/hosts/hiero/plugins/publish/precollect_instances.py index 4eac6a008a..e54d050f0d 100644 --- a/openpype/hosts/hiero/plugins/publish/precollect_instances.py +++ b/openpype/hosts/hiero/plugins/publish/precollect_instances.py @@ -19,9 +19,12 @@ class PrecollectInstances(pyblish.api.ContextPlugin): def process(self, context): self.otio_timeline = context.data["otioTimeline"] - + timeline_selection = phiero.get_timeline_selection() selected_timeline_items = phiero.get_track_items( - selected=True, check_tagged=True, check_enabled=True) + selection=timeline_selection, + check_tagged=True, + check_enabled=True + ) # only return enabled track items if not selected_timeline_items: @@ -292,10 +295,12 @@ class PrecollectInstances(pyblish.api.ContextPlugin): for otio_clip in self.otio_timeline.each_clip(): track_name = otio_clip.parent().name parent_range = otio_clip.range_in_parent() - if ti_track_name not in track_name: + if ti_track_name != track_name: continue - if otio_clip.name not in track_item.name(): + if otio_clip.name != track_item.name(): continue + self.log.debug("__ parent_range: {}".format(parent_range)) + self.log.debug("__ timeline_range: {}".format(timeline_range)) if openpype.lib.is_overlapping_otio_ranges( parent_range, timeline_range, strict=True): @@ -312,7 +317,7 @@ class PrecollectInstances(pyblish.api.ContextPlugin): speed = track_item.playbackSpeed() timeline = phiero.get_current_sequence() frame_start = int(track_item.timelineIn()) - frame_duration = int(track_item.sourceDuration() / speed) + frame_duration = int((track_item.duration() - 1) / speed) fps = timeline.framerate().toFloat() return hiero_export.create_otio_time_range( diff --git a/openpype/hosts/hiero/plugins/publish/precollect_workfile.py b/openpype/hosts/hiero/plugins/publish/precollect_workfile.py index 29c0397f79..c9bfb86810 100644 --- a/openpype/hosts/hiero/plugins/publish/precollect_workfile.py +++ b/openpype/hosts/hiero/plugins/publish/precollect_workfile.py @@ -16,7 +16,7 @@ class PrecollectWorkfile(pyblish.api.ContextPlugin): """Inject the current working file into context""" label = "Precollect Workfile" - order = pyblish.api.CollectorOrder - 0.5 + order = pyblish.api.CollectorOrder - 0.491 def process(self, context): @@ -68,6 +68,7 @@ class PrecollectWorkfile(pyblish.api.ContextPlugin): "subset": "{}{}".format(asset, subset.capitalize()), "item": project, "family": "workfile", + "families": [], "representations": [workfile_representation, thumb_representation] } @@ -77,11 +78,13 @@ class PrecollectWorkfile(pyblish.api.ContextPlugin): # update context with main project attributes context_data = { "activeProject": project, + "activeTimeline": active_timeline, "otioTimeline": otio_timeline, "currentFile": curent_file, "colorspace": self.get_colorspace(project), "fps": fps } + self.log.debug("__ context_data: {}".format(pformat(context_data))) context.data.update(context_data) self.log.info("Creating instance: {}".format(instance)) diff --git a/openpype/hosts/hiero/plugins/publish_old_workflow/collect_clip_resolution.py b/openpype/hosts/hiero/plugins/publish_old_workflow/collect_clip_resolution.py deleted file mode 100644 index 1d0727d0af..0000000000 --- a/openpype/hosts/hiero/plugins/publish_old_workflow/collect_clip_resolution.py +++ /dev/null @@ -1,38 +0,0 @@ -import pyblish.api - - -class CollectClipResolution(pyblish.api.InstancePlugin): - """Collect clip geometry resolution""" - - order = pyblish.api.CollectorOrder - 0.1 - label = "Collect Clip Resolution" - hosts = ["hiero"] - families = ["clip"] - - def process(self, instance): - sequence = instance.context.data['activeSequence'] - item = instance.data["item"] - source_resolution = instance.data.get("sourceResolution", None) - - resolution_width = int(sequence.format().width()) - resolution_height = int(sequence.format().height()) - pixel_aspect = sequence.format().pixelAspect() - - # source exception - if source_resolution: - resolution_width = int(item.source().mediaSource().width()) - resolution_height = int(item.source().mediaSource().height()) - pixel_aspect = item.source().mediaSource().pixelAspect() - - resolution_data = { - "resolutionWidth": resolution_width, - "resolutionHeight": resolution_height, - "pixelAspect": pixel_aspect - } - # add to instacne data - instance.data.update(resolution_data) - - self.log.info("Resolution of instance '{}' is: {}".format( - instance, - resolution_data - )) diff --git a/openpype/hosts/hiero/plugins/publish_old_workflow/collect_host_version.py b/openpype/hosts/hiero/plugins/publish_old_workflow/collect_host_version.py deleted file mode 100644 index 76e5bd11d5..0000000000 --- a/openpype/hosts/hiero/plugins/publish_old_workflow/collect_host_version.py +++ /dev/null @@ -1,15 +0,0 @@ -import pyblish.api - - -class CollectHostVersion(pyblish.api.ContextPlugin): - """Inject the hosts version into context""" - - label = "Collect Host and HostVersion" - order = pyblish.api.CollectorOrder - 0.5 - - def process(self, context): - import nuke - import pyblish.api - - context.set_data("host", pyblish.api.current_host()) - context.set_data('hostVersion', value=nuke.NUKE_VERSION_STRING) diff --git a/openpype/hosts/hiero/plugins/publish_old_workflow/collect_tag_retime.py b/openpype/hosts/hiero/plugins/publish_old_workflow/collect_tag_retime.py deleted file mode 100644 index 0634130976..0000000000 --- a/openpype/hosts/hiero/plugins/publish_old_workflow/collect_tag_retime.py +++ /dev/null @@ -1,32 +0,0 @@ -from pyblish import api - - -class CollectTagRetime(api.InstancePlugin): - """Collect Retiming from Tags of selected track items.""" - - order = api.CollectorOrder + 0.014 - label = "Collect Retiming Tag" - hosts = ["hiero"] - families = ['clip'] - - def process(self, instance): - # gets tags - tags = instance.data["tags"] - - for t in tags: - t_metadata = dict(t["metadata"]) - t_family = t_metadata.get("tag.family", "") - - # gets only task family tags and collect labels - if "retiming" in t_family: - margin_in = t_metadata.get("tag.marginIn", "") - margin_out = t_metadata.get("tag.marginOut", "") - - instance.data["retimeMarginIn"] = int(margin_in) - instance.data["retimeMarginOut"] = int(margin_out) - instance.data["retime"] = True - - self.log.info("retimeMarginIn: `{}`".format(margin_in)) - self.log.info("retimeMarginOut: `{}`".format(margin_out)) - - instance.data["families"] += ["retime"] diff --git a/openpype/hosts/hiero/plugins/publish_old_workflow/precollect_instances.py b/openpype/hosts/hiero/plugins/publish_old_workflow/precollect_instances.py deleted file mode 100644 index f9cc158e79..0000000000 --- a/openpype/hosts/hiero/plugins/publish_old_workflow/precollect_instances.py +++ /dev/null @@ -1,223 +0,0 @@ -from compiler.ast import flatten -from pyblish import api -from openpype.hosts.hiero import api as phiero -import hiero -# from openpype.hosts.hiero.api import lib -# reload(lib) -# reload(phiero) - - -class PreCollectInstances(api.ContextPlugin): - """Collect all Track items selection.""" - - order = api.CollectorOrder - 0.509 - label = "Pre-collect Instances" - hosts = ["hiero"] - - def process(self, context): - track_items = phiero.get_track_items( - selected=True, check_tagged=True, check_enabled=True) - # only return enabled track items - if not track_items: - track_items = phiero.get_track_items( - check_enabled=True, check_tagged=True) - # get sequence and video tracks - sequence = context.data["activeSequence"] - tracks = sequence.videoTracks() - - # add collection to context - tracks_effect_items = self.collect_sub_track_items(tracks) - - context.data["tracksEffectItems"] = tracks_effect_items - - self.log.info( - "Processing enabled track items: {}".format(len(track_items))) - - for _ti in track_items: - data = {} - clip = _ti.source() - - # get clips subtracks and anotations - annotations = self.clip_annotations(clip) - subtracks = self.clip_subtrack(_ti) - self.log.debug("Annotations: {}".format(annotations)) - self.log.debug(">> Subtracks: {}".format(subtracks)) - - # get pype tag data - tag_parsed_data = phiero.get_track_item_pype_data(_ti) - # self.log.debug(pformat(tag_parsed_data)) - - if not tag_parsed_data: - continue - - if tag_parsed_data.get("id") != "pyblish.avalon.instance": - continue - # add tag data to instance data - data.update({ - k: v for k, v in tag_parsed_data.items() - if k not in ("id", "applieswhole", "label") - }) - - asset = tag_parsed_data["asset"] - subset = tag_parsed_data["subset"] - review_track = tag_parsed_data.get("reviewTrack") - hiero_track = tag_parsed_data.get("heroTrack") - audio = tag_parsed_data.get("audio") - - # remove audio attribute from data - data.pop("audio") - - # insert family into families - family = tag_parsed_data["family"] - families = [str(f) for f in tag_parsed_data["families"]] - families.insert(0, str(family)) - - track = _ti.parent() - media_source = _ti.source().mediaSource() - source_path = media_source.firstpath() - file_head = media_source.filenameHead() - file_info = media_source.fileinfos().pop() - source_first_frame = int(file_info.startFrame()) - - # apply only for review and master track instance - if review_track and hiero_track: - families += ["review", "ftrack"] - - data.update({ - "name": "{} {} {}".format(asset, subset, families), - "asset": asset, - "item": _ti, - "families": families, - - # tags - "tags": _ti.tags(), - - # track item attributes - "track": track.name(), - "trackItem": track, - "reviewTrack": review_track, - - # version data - "versionData": { - "colorspace": _ti.sourceMediaColourTransform() - }, - - # source attribute - "source": source_path, - "sourceMedia": media_source, - "sourcePath": source_path, - "sourceFileHead": file_head, - "sourceFirst": source_first_frame, - - # clip's effect - "clipEffectItems": subtracks - }) - - instance = context.create_instance(**data) - - self.log.info("Creating instance.data: {}".format(instance.data)) - - if audio: - a_data = dict() - - # add tag data to instance data - a_data.update({ - k: v for k, v in tag_parsed_data.items() - if k not in ("id", "applieswhole", "label") - }) - - # create main attributes - subset = "audioMain" - family = "audio" - families = ["clip", "ftrack"] - families.insert(0, str(family)) - - name = "{} {} {}".format(asset, subset, families) - - a_data.update({ - "name": name, - "subset": subset, - "asset": asset, - "family": family, - "families": families, - "item": _ti, - - # tags - "tags": _ti.tags(), - }) - - a_instance = context.create_instance(**a_data) - self.log.info("Creating audio instance: {}".format(a_instance)) - - @staticmethod - def clip_annotations(clip): - """ - Returns list of Clip's hiero.core.Annotation - """ - annotations = [] - subTrackItems = flatten(clip.subTrackItems()) - annotations += [item for item in subTrackItems if isinstance( - item, hiero.core.Annotation)] - return annotations - - @staticmethod - def clip_subtrack(clip): - """ - Returns list of Clip's hiero.core.SubTrackItem - """ - subtracks = [] - subTrackItems = flatten(clip.parent().subTrackItems()) - for item in subTrackItems: - # avoid all anotation - if isinstance(item, hiero.core.Annotation): - continue - # # avoid all not anaibled - if not item.isEnabled(): - continue - subtracks.append(item) - return subtracks - - @staticmethod - def collect_sub_track_items(tracks): - """ - Returns dictionary with track index as key and list of subtracks - """ - # collect all subtrack items - sub_track_items = dict() - for track in tracks: - items = track.items() - - # skip if no clips on track > need track with effect only - if items: - continue - - # skip all disabled tracks - if not track.isEnabled(): - continue - - track_index = track.trackIndex() - _sub_track_items = flatten(track.subTrackItems()) - - # continue only if any subtrack items are collected - if len(_sub_track_items) < 1: - continue - - enabled_sti = list() - # loop all found subtrack items and check if they are enabled - for _sti in _sub_track_items: - # checking if not enabled - if not _sti.isEnabled(): - continue - if isinstance(_sti, hiero.core.Annotation): - continue - # collect the subtrack item - enabled_sti.append(_sti) - - # continue only if any subtrack items are collected - if len(enabled_sti) < 1: - continue - - # add collection of subtrackitems to dict - sub_track_items[track_index] = enabled_sti - - return sub_track_items diff --git a/openpype/hosts/hiero/plugins/publish_old_workflow/precollect_workfile.py b/openpype/hosts/hiero/plugins/publish_old_workflow/precollect_workfile.py deleted file mode 100644 index 693e151f6f..0000000000 --- a/openpype/hosts/hiero/plugins/publish_old_workflow/precollect_workfile.py +++ /dev/null @@ -1,74 +0,0 @@ -import os -import pyblish.api -from openpype.hosts.hiero import api as phiero -from openpype.pipeline import legacy_io - - -class PreCollectWorkfile(pyblish.api.ContextPlugin): - """Inject the current working file into context""" - - label = "Pre-collect Workfile" - order = pyblish.api.CollectorOrder - 0.51 - - def process(self, context): - asset = legacy_io.Session["AVALON_ASSET"] - subset = "workfile" - - project = phiero.get_current_project() - active_sequence = phiero.get_current_sequence() - video_tracks = active_sequence.videoTracks() - audio_tracks = active_sequence.audioTracks() - current_file = project.path() - staging_dir = os.path.dirname(current_file) - base_name = os.path.basename(current_file) - - # get workfile's colorspace properties - _clrs = {} - _clrs["useOCIOEnvironmentOverride"] = project.useOCIOEnvironmentOverride() # noqa - _clrs["lutSetting16Bit"] = project.lutSetting16Bit() - _clrs["lutSetting8Bit"] = project.lutSetting8Bit() - _clrs["lutSettingFloat"] = project.lutSettingFloat() - _clrs["lutSettingLog"] = project.lutSettingLog() - _clrs["lutSettingViewer"] = project.lutSettingViewer() - _clrs["lutSettingWorkingSpace"] = project.lutSettingWorkingSpace() - _clrs["lutUseOCIOForExport"] = project.lutUseOCIOForExport() - _clrs["ocioConfigName"] = project.ocioConfigName() - _clrs["ocioConfigPath"] = project.ocioConfigPath() - - # set main project attributes to context - context.data["activeProject"] = project - context.data["activeSequence"] = active_sequence - context.data["videoTracks"] = video_tracks - context.data["audioTracks"] = audio_tracks - context.data["currentFile"] = current_file - context.data["colorspace"] = _clrs - - self.log.info("currentFile: {}".format(current_file)) - - # creating workfile representation - representation = { - 'name': 'hrox', - 'ext': 'hrox', - 'files': base_name, - "stagingDir": staging_dir, - } - - instance_data = { - "name": "{}_{}".format(asset, subset), - "asset": asset, - "subset": "{}{}".format(asset, subset.capitalize()), - "item": project, - "family": "workfile", - - # version data - "versionData": { - "colorspace": _clrs - }, - - # source attribute - "sourcePath": current_file, - "representations": [representation] - } - - instance = context.create_instance(**instance_data) - self.log.info("Creating instance: {}".format(instance)) diff --git a/openpype/hosts/houdini/plugins/load/actions.py b/openpype/hosts/houdini/plugins/load/actions.py index 63d74c39a5..637be1513d 100644 --- a/openpype/hosts/houdini/plugins/load/actions.py +++ b/openpype/hosts/houdini/plugins/load/actions.py @@ -6,7 +6,7 @@ from openpype.pipeline import load class SetFrameRangeLoader(load.LoaderPlugin): - """Set Houdini frame range""" + """Set frame range excluding pre- and post-handles""" families = [ "animation", @@ -44,7 +44,7 @@ class SetFrameRangeLoader(load.LoaderPlugin): class SetFrameRangeWithHandlesLoader(load.LoaderPlugin): - """Set Maya frame range including pre- and post-handles""" + """Set frame range including pre- and post-handles""" families = [ "animation", diff --git a/openpype/hosts/houdini/plugins/load/load_alembic.py b/openpype/hosts/houdini/plugins/load/load_alembic.py index 0214229d5a..96e666b255 100644 --- a/openpype/hosts/houdini/plugins/load/load_alembic.py +++ b/openpype/hosts/houdini/plugins/load/load_alembic.py @@ -7,7 +7,7 @@ from openpype.hosts.houdini.api import pipeline class AbcLoader(load.LoaderPlugin): - """Specific loader of Alembic for the avalon.animation family""" + """Load Alembic""" families = ["model", "animation", "pointcache", "gpuCache"] label = "Load Alembic" diff --git a/openpype/hosts/houdini/plugins/load/load_alembic_archive.py b/openpype/hosts/houdini/plugins/load/load_alembic_archive.py new file mode 100644 index 0000000000..b960073e12 --- /dev/null +++ b/openpype/hosts/houdini/plugins/load/load_alembic_archive.py @@ -0,0 +1,75 @@ +import os +from openpype.pipeline import ( + load, + get_representation_path, +) +from openpype.hosts.houdini.api import pipeline + + +class AbcArchiveLoader(load.LoaderPlugin): + """Load Alembic as full geometry network hierarchy """ + + families = ["model", "animation", "pointcache", "gpuCache"] + label = "Load Alembic as Archive" + representations = ["abc"] + order = -5 + icon = "code-fork" + color = "orange" + + def load(self, context, name=None, namespace=None, data=None): + + import hou + + # Format file name, Houdini only wants forward slashes + file_path = os.path.normpath(self.fname) + file_path = file_path.replace("\\", "/") + + # Get the root node + obj = hou.node("/obj") + + # Define node name + namespace = namespace if namespace else context["asset"]["name"] + node_name = "{}_{}".format(namespace, name) if namespace else name + + # Create an Alembic archive node + node = obj.createNode("alembicarchive", node_name=node_name) + node.moveToGoodPosition() + + # TODO: add FPS of project / asset + node.setParms({"fileName": file_path, + "channelRef": True}) + + # Apply some magic + node.parm("buildHierarchy").pressButton() + node.moveToGoodPosition() + + nodes = [node] + + self[:] = nodes + + return pipeline.containerise(node_name, + namespace, + nodes, + context, + self.__class__.__name__, + suffix="") + + def update(self, container, representation): + + node = container["node"] + + # Update the file path + file_path = get_representation_path(representation) + file_path = file_path.replace("\\", "/") + + # Update attributes + node.setParms({"fileName": file_path, + "representation": str(representation["_id"])}) + + # Rebuild + node.parm("buildHierarchy").pressButton() + + def remove(self, container): + + node = container["node"] + node.destroy() diff --git a/openpype/hosts/houdini/plugins/load/load_bgeo.py b/openpype/hosts/houdini/plugins/load/load_bgeo.py new file mode 100644 index 0000000000..a463d51383 --- /dev/null +++ b/openpype/hosts/houdini/plugins/load/load_bgeo.py @@ -0,0 +1,107 @@ +# -*- coding: utf-8 -*- +import os +import re + +from openpype.pipeline import ( + load, + get_representation_path, +) +from openpype.hosts.houdini.api import pipeline + + +class BgeoLoader(load.LoaderPlugin): + """Load bgeo files to Houdini.""" + + label = "Load bgeo" + families = ["model", "pointcache", "bgeo"] + representations = [ + "bgeo", "bgeosc", "bgeogz", + "bgeo.sc", "bgeo.gz", "bgeo.lzma", "bgeo.bz2"] + order = -10 + icon = "code-fork" + color = "orange" + + def load(self, context, name=None, namespace=None, data=None): + + import hou + + # Get the root node + obj = hou.node("/obj") + + # Define node name + namespace = namespace if namespace else context["asset"]["name"] + node_name = "{}_{}".format(namespace, name) if namespace else name + + # 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 + file_node = container.node("file1") + if file_node: + file_node.destroy() + + # Explicitly create a file node + file_node = container.createNode("file", node_name=node_name) + file_node.setParms({"file": self.format_path(self.fname, is_sequence)}) + + # Set display on last node + file_node.setDisplayFlag(True) + + nodes = [container, file_node] + self[:] = nodes + + return pipeline.containerise( + node_name, + namespace, + nodes, + context, + self.__class__.__name__, + suffix="", + ) + + @staticmethod + def format_path(path, is_sequence): + """Format file path correctly for single bgeo or bgeo sequence.""" + if not os.path.exists(path): + raise RuntimeError("Path does not exist: %s" % path) + + # The path is either a single file or sequence in a folder. + if not is_sequence: + filename = path + print("single") + else: + filename = re.sub(r"(.*)\.(\d+)\.(bgeo.*)", "\\1.$F4.\\3", path) + + filename = os.path.join(path, filename) + + filename = os.path.normpath(filename) + filename = filename.replace("\\", "/") + + return filename + + def update(self, container, representation): + + node = container["node"] + try: + file_node = next( + n for n in node.children() if n.type().name() == "file" + ) + except StopIteration: + self.log.error("Could not find node of type `alembic`") + return + + # Update the file path + file_path = get_representation_path(representation) + file_path = self.format_path(file_path) + + file_node.setParms({"fileName": file_path}) + + # Update attribute + node.setParms({"representation": str(representation["_id"])}) + + def remove(self, container): + + node = container["node"] + node.destroy() diff --git a/openpype/hosts/houdini/plugins/load/load_camera.py b/openpype/hosts/houdini/plugins/load/load_camera.py index ef57d115da..059ad11a76 100644 --- a/openpype/hosts/houdini/plugins/load/load_camera.py +++ b/openpype/hosts/houdini/plugins/load/load_camera.py @@ -78,7 +78,7 @@ def transfer_non_default_values(src, dest, ignore=None): class CameraLoader(load.LoaderPlugin): - """Specific loader of Alembic for the avalon.animation family""" + """Load camera from an Alembic file""" families = ["camera"] label = "Load Camera (abc)" diff --git a/openpype/hosts/houdini/plugins/load/load_image.py b/openpype/hosts/houdini/plugins/load/load_image.py index 671f08f18f..928c2ee734 100644 --- a/openpype/hosts/houdini/plugins/load/load_image.py +++ b/openpype/hosts/houdini/plugins/load/load_image.py @@ -42,9 +42,9 @@ def get_image_avalon_container(): class ImageLoader(load.LoaderPlugin): - """Specific loader of Alembic for the avalon.animation family""" + """Load images into COP2""" - families = ["colorbleed.imagesequence"] + families = ["imagesequence"] label = "Load Image (COP2)" representations = ["*"] order = -10 diff --git a/openpype/hosts/houdini/plugins/load/load_vdb.py b/openpype/hosts/houdini/plugins/load/load_vdb.py index 06bb9e45e4..bff0f8b0bf 100644 --- a/openpype/hosts/houdini/plugins/load/load_vdb.py +++ b/openpype/hosts/houdini/plugins/load/load_vdb.py @@ -9,7 +9,7 @@ from openpype.hosts.houdini.api import pipeline class VdbLoader(load.LoaderPlugin): - """Specific loader of Alembic for the avalon.animation family""" + """Load VDB""" families = ["vdbcache"] label = "Load VDB" diff --git a/openpype/hosts/houdini/plugins/load/show_usdview.py b/openpype/hosts/houdini/plugins/load/show_usdview.py index 8066615181..2737bc40fa 100644 --- a/openpype/hosts/houdini/plugins/load/show_usdview.py +++ b/openpype/hosts/houdini/plugins/load/show_usdview.py @@ -1,3 +1,7 @@ +import os +import subprocess + +from openpype.lib.vendor_bin_utils import find_executable from openpype.pipeline import load @@ -14,12 +18,7 @@ class ShowInUsdview(load.LoaderPlugin): def load(self, context, name=None, namespace=None, data=None): - import os - import subprocess - - import avalon.lib as lib - - usdview = lib.which("usdview") + usdview = find_executable("usdview") filepath = os.path.normpath(self.fname) filepath = filepath.replace("\\", "/") diff --git a/openpype/hosts/maya/api/lib_renderproducts.py b/openpype/hosts/maya/api/lib_renderproducts.py index 5956cc482c..2d3bda5245 100644 --- a/openpype/hosts/maya/api/lib_renderproducts.py +++ b/openpype/hosts/maya/api/lib_renderproducts.py @@ -77,6 +77,7 @@ IMAGE_PREFIXES = { "arnold": "defaultRenderGlobals.imageFilePrefix", "renderman": "rmanGlobals.imageFileFormat", "redshift": "defaultRenderGlobals.imageFilePrefix", + "mayahardware2": "defaultRenderGlobals.imageFilePrefix" } RENDERMAN_IMAGE_DIR = "maya//" @@ -155,7 +156,8 @@ def get(layer, render_instance=None): "arnold": RenderProductsArnold, "vray": RenderProductsVray, "redshift": RenderProductsRedshift, - "renderman": RenderProductsRenderman + "renderman": RenderProductsRenderman, + "mayahardware2": RenderProductsMayaHardware }.get(renderer_name.lower(), None) if renderer is None: raise UnsupportedRendererException( @@ -1091,6 +1093,11 @@ class RenderProductsRenderman(ARenderProducts): if not enabled: continue + # Skip display types not producing any file output. + # Is there a better way to do it? + if not display_types.get(display["driverNode"]["type"]): + continue + aov_name = name if aov_name == "rmanDefaultDisplay": aov_name = "beauty" @@ -1124,6 +1131,67 @@ class RenderProductsRenderman(ARenderProducts): return new_files +class RenderProductsMayaHardware(ARenderProducts): + """Expected files for MayaHardware renderer.""" + + renderer = "mayahardware2" + + extensions = [ + {"label": "JPEG", "index": 8, "extension": "jpg"}, + {"label": "PNG", "index": 32, "extension": "png"}, + {"label": "EXR(exr)", "index": 40, "extension": "exr"} + ] + + def _get_extension(self, value): + result = None + if isinstance(value, int): + extensions = { + extension["index"]: extension["extension"] + for extension in self.extensions + } + try: + result = extensions[value] + except KeyError: + raise NotImplementedError( + "Could not find extension for {}".format(value) + ) + + if isinstance(value, six.string_types): + extensions = { + extension["label"]: extension["extension"] + for extension in self.extensions + } + try: + result = extensions[value] + except KeyError: + raise NotImplementedError( + "Could not find extension for {}".format(value) + ) + + if not result: + raise NotImplementedError( + "Could not find extension for {}".format(value) + ) + + return result + + def get_render_products(self): + """Get all AOVs. + See Also: + :func:`ARenderProducts.get_render_products()` + """ + ext = self._get_extension( + self._get_attr("defaultRenderGlobals.imageFormat") + ) + + products = [] + for cam in self.get_renderable_cameras(): + product = RenderProduct(productName="beauty", ext=ext, camera=cam) + products.append(product) + + return products + + class AOVError(Exception): """Custom exception for determining AOVs.""" diff --git a/openpype/hosts/maya/plugins/create/create_render.py b/openpype/hosts/maya/plugins/create/create_render.py index 8e9bd0e22b..93ee6679e5 100644 --- a/openpype/hosts/maya/plugins/create/create_render.py +++ b/openpype/hosts/maya/plugins/create/create_render.py @@ -77,7 +77,8 @@ class CreateRender(plugin.Creator): 'vray': 'vraySettings.fileNamePrefix', 'arnold': 'defaultRenderGlobals.imageFilePrefix', 'renderman': 'rmanGlobals.imageFileFormat', - 'redshift': 'defaultRenderGlobals.imageFilePrefix' + 'redshift': 'defaultRenderGlobals.imageFilePrefix', + 'mayahardware2': 'defaultRenderGlobals.imageFilePrefix', } _image_prefixes = { @@ -87,7 +88,8 @@ class CreateRender(plugin.Creator): # this needs `imageOutputDir` # (/renders/maya/) set separately 'renderman': '_..', - 'redshift': 'maya///' # noqa + 'redshift': 'maya///', # noqa + 'mayahardware2': 'maya///', # noqa } _aov_chars = { diff --git a/openpype/hosts/maya/plugins/load/_load_animation.py b/openpype/hosts/maya/plugins/load/_load_animation.py index bce1f0fc67..9c37e498ef 100644 --- a/openpype/hosts/maya/plugins/load/_load_animation.py +++ b/openpype/hosts/maya/plugins/load/_load_animation.py @@ -2,7 +2,7 @@ import openpype.hosts.maya.api.plugin class AbcLoader(openpype.hosts.maya.api.plugin.ReferenceLoader): - """Specific loader of Alembic for the avalon.animation family""" + """Loader to reference an Alembic file""" families = ["animation", "camera", diff --git a/openpype/hosts/maya/plugins/load/actions.py b/openpype/hosts/maya/plugins/load/actions.py index 483ad32402..253dae1e43 100644 --- a/openpype/hosts/maya/plugins/load/actions.py +++ b/openpype/hosts/maya/plugins/load/actions.py @@ -1,7 +1,7 @@ """A module containing generic loader actions that will display in the Loader. """ - +import qargparse from openpype.pipeline import load from openpype.hosts.maya.api.lib import ( maintained_selection, @@ -10,7 +10,7 @@ from openpype.hosts.maya.api.lib import ( class SetFrameRangeLoader(load.LoaderPlugin): - """Specific loader of Alembic for the avalon.animation family""" + """Set frame range excluding pre- and post-handles""" families = ["animation", "camera", @@ -44,7 +44,7 @@ class SetFrameRangeLoader(load.LoaderPlugin): class SetFrameRangeWithHandlesLoader(load.LoaderPlugin): - """Specific loader of Alembic for the avalon.animation family""" + """Set frame range including pre- and post-handles""" families = ["animation", "camera", @@ -98,6 +98,15 @@ class ImportMayaLoader(load.LoaderPlugin): icon = "arrow-circle-down" color = "#775555" + options = [ + qargparse.Boolean( + "clean_import", + label="Clean import", + default=False, + help="Should all occurences of cbId be purged?" + ) + ] + def load(self, context, name=None, namespace=None, data=None): import maya.cmds as cmds @@ -114,13 +123,22 @@ class ImportMayaLoader(load.LoaderPlugin): ) with maintained_selection(): - cmds.file(self.fname, - i=True, - preserveReferences=True, - namespace=namespace, - returnNewNodes=True, - groupReference=True, - groupName="{}:{}".format(namespace, name)) + nodes = cmds.file(self.fname, + i=True, + preserveReferences=True, + namespace=namespace, + returnNewNodes=True, + groupReference=True, + groupName="{}:{}".format(namespace, name)) + + if data.get("clean_import", False): + remove_attributes = ["cbId"] + for node in nodes: + for attr in remove_attributes: + if cmds.attributeQuery(attr, node=node, exists=True): + full_attr = "{}.{}".format(node, attr) + print("Removing {}".format(full_attr)) + cmds.deleteAttr(full_attr) # We do not containerize imported content, it remains unmanaged return diff --git a/openpype/hosts/maya/plugins/load/load_ass.py b/openpype/hosts/maya/plugins/load/load_ass.py index 18de4df3b1..a284b7ec1f 100644 --- a/openpype/hosts/maya/plugins/load/load_ass.py +++ b/openpype/hosts/maya/plugins/load/load_ass.py @@ -16,7 +16,7 @@ from openpype.hosts.maya.api.pipeline import containerise class AssProxyLoader(openpype.hosts.maya.api.plugin.ReferenceLoader): - """Load the Proxy""" + """Load Arnold Proxy as reference""" families = ["ass"] representations = ["ass"] diff --git a/openpype/hosts/maya/plugins/load/load_gpucache.py b/openpype/hosts/maya/plugins/load/load_gpucache.py index 591e568e4c..6d5e945508 100644 --- a/openpype/hosts/maya/plugins/load/load_gpucache.py +++ b/openpype/hosts/maya/plugins/load/load_gpucache.py @@ -8,7 +8,7 @@ from openpype.api import get_project_settings class GpuCacheLoader(load.LoaderPlugin): - """Load model Alembic as gpuCache""" + """Load Alembic as gpuCache""" families = ["model"] representations = ["abc"] diff --git a/openpype/hosts/maya/plugins/load/load_image_plane.py b/openpype/hosts/maya/plugins/load/load_image_plane.py index b67c2cb209..5e44917f28 100644 --- a/openpype/hosts/maya/plugins/load/load_image_plane.py +++ b/openpype/hosts/maya/plugins/load/load_image_plane.py @@ -83,7 +83,7 @@ class ImagePlaneLoader(load.LoaderPlugin): families = ["image", "plate", "render"] label = "Load imagePlane" - representations = ["mov", "exr", "preview", "png"] + representations = ["mov", "exr", "preview", "png", "jpg"] icon = "image" color = "orange" diff --git a/openpype/hosts/maya/plugins/load/load_reference.py b/openpype/hosts/maya/plugins/load/load_reference.py index a8875cf216..d65b5a2c1e 100644 --- a/openpype/hosts/maya/plugins/load/load_reference.py +++ b/openpype/hosts/maya/plugins/load/load_reference.py @@ -12,7 +12,7 @@ from openpype.hosts.maya.api.lib import maintained_selection class ReferenceLoader(openpype.hosts.maya.api.plugin.ReferenceLoader): - """Load the model""" + """Reference file""" families = ["model", "pointcache", diff --git a/openpype/hosts/maya/plugins/load/load_vdb_to_vray.py b/openpype/hosts/maya/plugins/load/load_vdb_to_vray.py index 4f14235bfb..3a16264ec0 100644 --- a/openpype/hosts/maya/plugins/load/load_vdb_to_vray.py +++ b/openpype/hosts/maya/plugins/load/load_vdb_to_vray.py @@ -74,6 +74,7 @@ def _fix_duplicate_vvg_callbacks(): class LoadVDBtoVRay(load.LoaderPlugin): + """Load OpenVDB in a V-Ray Volume Grid""" families = ["vdbcache"] representations = ["vdb"] diff --git a/openpype/hosts/maya/plugins/publish/collect_fbx_camera.py b/openpype/hosts/maya/plugins/publish/collect_fbx_camera.py new file mode 100644 index 0000000000..bfa5bccbb9 --- /dev/null +++ b/openpype/hosts/maya/plugins/publish/collect_fbx_camera.py @@ -0,0 +1,20 @@ +# -*- coding: utf-8 -*- +from maya import cmds # noqa +import pyblish.api + + +class CollectFbxCamera(pyblish.api.InstancePlugin): + """Collect Camera for FBX export.""" + + order = pyblish.api.CollectorOrder + 0.2 + label = "Collect Camera for FBX export" + families = ["camera"] + + def process(self, instance): + if not instance.data.get("families"): + instance.data["families"] = [] + + if "fbx" not in instance.data["families"]: + instance.data["families"].append("fbx") + + instance.data["cameras"] = True diff --git a/openpype/hosts/maya/plugins/publish/collect_look.py b/openpype/hosts/maya/plugins/publish/collect_look.py index b6a76f1e21..323bede761 100644 --- a/openpype/hosts/maya/plugins/publish/collect_look.py +++ b/openpype/hosts/maya/plugins/publish/collect_look.py @@ -22,10 +22,46 @@ RENDERER_NODE_TYPES = [ # redshift "RedshiftMeshParameters" ] - SHAPE_ATTRS = set(SHAPE_ATTRS) +def get_pxr_multitexture_file_attrs(node): + attrs = [] + for i in range(9): + if cmds.attributeQuery("filename{}".format(i), node=node, ex=True): + file = cmds.getAttr("{}.filename{}".format(node, i)) + if file: + attrs.append("filename{}".format(i)) + return attrs + + +FILE_NODES = { + "file": "fileTextureName", + + "aiImage": "filename", + + "RedshiftNormalMap": "text0", + + "PxrBump": "filename", + "PxrNormalMap": "filename", + "PxrMultiTexture": get_pxr_multitexture_file_attrs, + "PxrPtexture": "filename", + "PxrTexture": "filename" +} + + +def get_attributes(dictionary, attr, node=None): + # type: (dict, str, str) -> list + if callable(dictionary[attr]): + val = dictionary[attr](node) + else: + val = dictionary.get(attr, []) + + if not isinstance(val, list): + return [val] + return val + + def get_look_attrs(node): """Returns attributes of a node that are important for the look. @@ -51,15 +87,14 @@ def get_look_attrs(node): if cmds.objectType(node, isAType="shape"): attrs = cmds.listAttr(node, changedSinceFileOpen=True) or [] for attr in attrs: - if attr in SHAPE_ATTRS: + if attr in SHAPE_ATTRS or \ + attr not in SHAPE_ATTRS and attr.startswith('ai'): result.append(attr) - elif attr.startswith('ai'): - result.append(attr) - return result -def node_uses_image_sequence(node): +def node_uses_image_sequence(node, node_path): + # type: (str) -> bool """Return whether file node uses an image sequence or single image. Determine if a node uses an image sequence or just a single image, @@ -74,12 +109,15 @@ def node_uses_image_sequence(node): """ # useFrameExtension indicates an explicit image sequence - node_path = get_file_node_path(node).lower() - # The following tokens imply a sequence - patterns = ["", "", "", "u_v", "", "", "", + "u_v", ""] + try: + use_frame_extension = cmds.getAttr('%s.useFrameExtension' % node) + except ValueError: + use_frame_extension = False - return (cmds.getAttr('%s.useFrameExtension' % node) or + return (use_frame_extension or any(pattern in node_path for pattern in patterns)) @@ -137,14 +175,15 @@ def seq_to_glob(path): return path -def get_file_node_path(node): +def get_file_node_paths(node): + # type: (str) -> list """Get the file path used by a Maya file node. Args: node (str): Name of the Maya file node Returns: - str: the file path in use + list: the file paths in use """ # if the path appears to be sequence, use computedFileTextureNamePattern, @@ -163,15 +202,20 @@ def get_file_node_path(node): ""] lower = texture_pattern.lower() if any(pattern in lower for pattern in patterns): - return texture_pattern + return [texture_pattern] - if cmds.nodeType(node) == 'aiImage': - return cmds.getAttr('{0}.filename'.format(node)) - if cmds.nodeType(node) == 'RedshiftNormalMap': - return cmds.getAttr('{}.tex0'.format(node)) + try: + file_attributes = get_attributes( + FILE_NODES, cmds.nodeType(node), node) + except AttributeError: + file_attributes = "fileTextureName" - # otherwise use fileTextureName - return cmds.getAttr('{0}.fileTextureName'.format(node)) + files = [] + for file_attr in file_attributes: + if cmds.attributeQuery(file_attr, node=node, exists=True): + files.append(cmds.getAttr("{}.{}".format(node, file_attr))) + + return files def get_file_node_files(node): @@ -185,16 +229,21 @@ def get_file_node_files(node): list: List of full file paths. """ + paths = get_file_node_paths(node) + sequences = [] + replaces = [] + for index, path in enumerate(paths): + if node_uses_image_sequence(node, path): + glob_pattern = seq_to_glob(path) + sequences.extend(glob.glob(glob_pattern)) + replaces.append(index) - path = get_file_node_path(node) - path = cmds.workspace(expandName=path) - if node_uses_image_sequence(node): - glob_pattern = seq_to_glob(path) - return glob.glob(glob_pattern) - elif os.path.exists(path): - return [path] - else: - return [] + for index in replaces: + paths.pop(index) + + paths.extend(sequences) + + return [p for p in paths if os.path.exists(p)] class CollectLook(pyblish.api.InstancePlugin): @@ -238,13 +287,13 @@ class CollectLook(pyblish.api.InstancePlugin): "for %s" % instance.data['name']) # Discover related object sets - self.log.info("Gathering sets..") + self.log.info("Gathering sets ...") sets = self.collect_sets(instance) # Lookup set (optimization) instance_lookup = set(cmds.ls(instance, long=True)) - self.log.info("Gathering set relations..") + self.log.info("Gathering set relations ...") # Ensure iteration happen in a list so we can remove keys from the # dict within the loop @@ -326,7 +375,10 @@ class CollectLook(pyblish.api.InstancePlugin): "volumeShader", "displacementShader", "aiSurfaceShader", - "aiVolumeShader"] + "aiVolumeShader", + "rman__surface", + "rman__displacement" + ] if look_sets: materials = [] @@ -374,15 +426,17 @@ class CollectLook(pyblish.api.InstancePlugin): or [] ) - files = cmds.ls(history, type="file", long=True) - files.extend(cmds.ls(history, type="aiImage", long=True)) - files.extend(cmds.ls(history, type="RedshiftNormalMap", long=True)) + all_supported_nodes = FILE_NODES.keys() + files = [] + for node_type in all_supported_nodes: + files.extend(cmds.ls(history, type=node_type, long=True)) self.log.info("Collected file nodes:\n{}".format(files)) # Collect textures if any file nodes are found instance.data["resources"] = [] for n in files: - instance.data["resources"].append(self.collect_resource(n)) + for res in self.collect_resources(n): + instance.data["resources"].append(res) self.log.info("Collected resources: {}".format(instance.data["resources"])) @@ -502,7 +556,7 @@ class CollectLook(pyblish.api.InstancePlugin): return attributes - def collect_resource(self, node): + def collect_resources(self, node): """Collect the link to the file(s) used (resource) Args: node (str): name of the node @@ -510,68 +564,69 @@ class CollectLook(pyblish.api.InstancePlugin): Returns: dict """ - self.log.debug("processing: {}".format(node)) - if cmds.nodeType(node) not in ["file", "aiImage", "RedshiftNormalMap"]: + all_supported_nodes = FILE_NODES.keys() + if cmds.nodeType(node) not in all_supported_nodes: self.log.error( "Unsupported file node: {}".format(cmds.nodeType(node))) raise AssertionError("Unsupported file node") - if cmds.nodeType(node) == 'file': - self.log.debug(" - file node") - attribute = "{}.fileTextureName".format(node) - computed_attribute = "{}.computedFileTextureNamePattern".format(node) - elif cmds.nodeType(node) == 'aiImage': - self.log.debug("aiImage node") - attribute = "{}.filename".format(node) - computed_attribute = attribute - elif cmds.nodeType(node) == 'RedshiftNormalMap': - self.log.debug("RedshiftNormalMap node") - attribute = "{}.tex0".format(node) - computed_attribute = attribute + self.log.debug(" - got {}".format(cmds.nodeType(node))) - source = cmds.getAttr(attribute) - self.log.info(" - file source: {}".format(source)) - color_space_attr = "{}.colorSpace".format(node) - try: - color_space = cmds.getAttr(color_space_attr) - except ValueError: - # node doesn't have colorspace attribute - color_space = "Raw" - # Compare with the computed file path, e.g. the one with the - # pattern in it, to generate some logging information about this - # difference - # computed_attribute = "{}.computedFileTextureNamePattern".format(node) - computed_source = cmds.getAttr(computed_attribute) - if source != computed_source: - self.log.debug("Detected computed file pattern difference " - "from original pattern: {0} " - "({1} -> {2})".format(node, - source, - computed_source)) + attributes = get_attributes(FILE_NODES, cmds.nodeType(node), node) + for attribute in attributes: + source = cmds.getAttr("{}.{}".format( + node, + attribute + )) + computed_attribute = "{}.{}".format(node, attribute) + if attribute == "fileTextureName": + computed_attribute = node + ".computedFileTextureNamePattern" - # We replace backslashes with forward slashes because V-Ray - # can't handle the UDIM files with the backslashes in the - # paths as the computed patterns - source = source.replace("\\", "/") + self.log.info(" - file source: {}".format(source)) + color_space_attr = "{}.colorSpace".format(node) + try: + color_space = cmds.getAttr(color_space_attr) + except ValueError: + # node doesn't have colorspace attribute + color_space = "Raw" + # Compare with the computed file path, e.g. the one with + # the pattern in it, to generate some logging information + # about this difference + computed_source = cmds.getAttr(computed_attribute) + if source != computed_source: + self.log.debug("Detected computed file pattern difference " + "from original pattern: {0} " + "({1} -> {2})".format(node, + source, + computed_source)) - files = get_file_node_files(node) - if len(files) == 0: - self.log.error("No valid files found from node `%s`" % node) + # We replace backslashes with forward slashes because V-Ray + # can't handle the UDIM files with the backslashes in the + # paths as the computed patterns + source = source.replace("\\", "/") - self.log.info("collection of resource done:") - self.log.info(" - node: {}".format(node)) - self.log.info(" - attribute: {}".format(attribute)) - self.log.info(" - source: {}".format(source)) - self.log.info(" - file: {}".format(files)) - self.log.info(" - color space: {}".format(color_space)) + files = get_file_node_files(node) + if len(files) == 0: + self.log.error("No valid files found from node `%s`" % node) - # Define the resource - return {"node": node, - "attribute": attribute, + self.log.info("collection of resource done:") + self.log.info(" - node: {}".format(node)) + self.log.info(" - attribute: {}".format(attribute)) + self.log.info(" - source: {}".format(source)) + self.log.info(" - file: {}".format(files)) + self.log.info(" - color space: {}".format(color_space)) + + # Define the resource + yield { + "node": node, + # here we are passing not only attribute, but with node again + # this should be simplified and changed extractor. + "attribute": "{}.{}".format(node, attribute), "source": source, # required for resources "files": files, - "color_space": color_space} # required for resources + "color_space": color_space + } # required for resources class CollectModelRenderSets(CollectLook): diff --git a/openpype/hosts/maya/plugins/publish/collect_render.py b/openpype/hosts/maya/plugins/publish/collect_render.py index 912fe179dd..fbd2e81279 100644 --- a/openpype/hosts/maya/plugins/publish/collect_render.py +++ b/openpype/hosts/maya/plugins/publish/collect_render.py @@ -326,8 +326,8 @@ class CollectMayaRender(pyblish.api.ContextPlugin): "byFrameStep": int( self.get_render_attribute("byFrameStep", layer=layer_name)), - "renderer": self.get_render_attribute("currentRenderer", - layer=layer_name), + "renderer": self.get_render_attribute( + "currentRenderer", layer=layer_name).lower(), # instance subset "family": "renderlayer", "families": ["renderlayer"], @@ -339,9 +339,15 @@ class CollectMayaRender(pyblish.api.ContextPlugin): "source": filepath, "expectedFiles": full_exp_files, "publishRenderMetadataFolder": common_publish_meta_path, - "resolutionWidth": cmds.getAttr("defaultResolution.width"), - "resolutionHeight": cmds.getAttr("defaultResolution.height"), - "pixelAspect": cmds.getAttr("defaultResolution.pixelAspect"), + "resolutionWidth": lib.get_attr_in_layer( + "defaultResolution.height", layer=layer_name + ), + "resolutionHeight": lib.get_attr_in_layer( + "defaultResolution.width", layer=layer_name + ), + "pixelAspect": lib.get_attr_in_layer( + "defaultResolution.pixelAspect", layer=layer_name + ), "tileRendering": render_instance.data.get("tileRendering") or False, # noqa: E501 "tilesX": render_instance.data.get("tilesX") or 2, "tilesY": render_instance.data.get("tilesY") or 2, diff --git a/openpype/hosts/maya/plugins/publish/collect_vrayscene.py b/openpype/hosts/maya/plugins/publish/collect_vrayscene.py index afdb570cbc..0bae9656f3 100644 --- a/openpype/hosts/maya/plugins/publish/collect_vrayscene.py +++ b/openpype/hosts/maya/plugins/publish/collect_vrayscene.py @@ -124,9 +124,15 @@ class CollectVrayScene(pyblish.api.InstancePlugin): # Add source to allow tracing back to the scene from # which was submitted originally "source": context.data["currentFile"].replace("\\", "/"), - "resolutionWidth": cmds.getAttr("defaultResolution.width"), - "resolutionHeight": cmds.getAttr("defaultResolution.height"), - "pixelAspect": cmds.getAttr("defaultResolution.pixelAspect"), + "resolutionWidth": lib.get_attr_in_layer( + "defaultResolution.height", layer=layer_name + ), + "resolutionHeight": lib.get_attr_in_layer( + "defaultResolution.width", layer=layer_name + ), + "pixelAspect": lib.get_attr_in_layer( + "defaultResolution.pixelAspect", layer=layer_name + ), "priority": instance.data.get("priority"), "useMultipleSceneFiles": instance.data.get( "vraySceneMultipleFiles") diff --git a/openpype/hosts/maya/plugins/publish/extract_look.py b/openpype/hosts/maya/plugins/publish/extract_look.py index 881705b92c..81d7c31ae7 100644 --- a/openpype/hosts/maya/plugins/publish/extract_look.py +++ b/openpype/hosts/maya/plugins/publish/extract_look.py @@ -372,10 +372,12 @@ class ExtractLook(openpype.api.Extractor): if mode == COPY: transfers.append((source, destination)) - self.log.info('copying') + self.log.info('file will be copied {} -> {}'.format( + source, destination)) elif mode == HARDLINK: hardlinks.append((source, destination)) - self.log.info('hardlinking') + self.log.info('file will be hardlinked {} -> {}'.format( + source, destination)) # Store the hashes from hash to destination to include in the # database diff --git a/openpype/hosts/maya/plugins/publish/validate_render_single_camera.py b/openpype/hosts/maya/plugins/publish/validate_render_single_camera.py index 0838b4fbf8..e6c6ef6c9e 100644 --- a/openpype/hosts/maya/plugins/publish/validate_render_single_camera.py +++ b/openpype/hosts/maya/plugins/publish/validate_render_single_camera.py @@ -12,7 +12,8 @@ ImagePrefixes = { 'vray': 'vraySettings.fileNamePrefix', 'arnold': 'defaultRenderGlobals.imageFilePrefix', 'renderman': 'defaultRenderGlobals.imageFilePrefix', - 'redshift': 'defaultRenderGlobals.imageFilePrefix' + 'redshift': 'defaultRenderGlobals.imageFilePrefix', + 'mayahardware2': 'defaultRenderGlobals.imageFilePrefix', } diff --git a/openpype/hosts/maya/plugins/publish/validate_rendersettings.py b/openpype/hosts/maya/plugins/publish/validate_rendersettings.py index 023e27de17..ba6c1397ab 100644 --- a/openpype/hosts/maya/plugins/publish/validate_rendersettings.py +++ b/openpype/hosts/maya/plugins/publish/validate_rendersettings.py @@ -50,15 +50,17 @@ class ValidateRenderSettings(pyblish.api.InstancePlugin): 'vray': 'vraySettings.fileNamePrefix', 'arnold': 'defaultRenderGlobals.imageFilePrefix', 'renderman': 'rmanGlobals.imageFileFormat', - 'redshift': 'defaultRenderGlobals.imageFilePrefix' + 'redshift': 'defaultRenderGlobals.imageFilePrefix', + 'mayahardware2': 'defaultRenderGlobals.imageFilePrefix', } ImagePrefixTokens = { - - 'arnold': 'maya///{aov_separator}', # noqa + 'mentalray': 'maya///{aov_separator}', # noqa: E501 + 'arnold': 'maya///{aov_separator}', # noqa: E501 'redshift': 'maya///', 'vray': 'maya///', - 'renderman': '{aov_separator}..' # noqa + 'renderman': '{aov_separator}..', + 'mayahardware2': 'maya///', } _aov_chars = { @@ -234,7 +236,7 @@ class ValidateRenderSettings(pyblish.api.InstancePlugin): # load validation definitions from settings validation_settings = ( instance.context.data["project_settings"]["maya"]["publish"]["ValidateRenderSettings"].get( # noqa: E501 - "{}_render_attributes".format(renderer)) + "{}_render_attributes".format(renderer)) or [] ) # go through definitions and test if such node.attribute exists. diff --git a/openpype/hosts/nuke/api/lib.py b/openpype/hosts/nuke/api/lib.py index 4e38f811c9..f40425eefc 100644 --- a/openpype/hosts/nuke/api/lib.py +++ b/openpype/hosts/nuke/api/lib.py @@ -1,4 +1,5 @@ import os +from pprint import pformat import re import six import platform @@ -193,7 +194,7 @@ def imprint(node, data, tab=None): Examples: ``` import nuke - from avalon.nuke import lib + from openpype.hosts.nuke.api import lib node = nuke.createNode("NoOp") data = { @@ -364,17 +365,15 @@ def fix_data_for_node_create(data): return data -def add_write_node(name, **kwarg): +def add_write_node_legacy(name, **kwarg): """Adding nuke write node - Arguments: name (str): nuke node name kwarg (attrs): data for nuke knobs - Returns: node (obj): nuke write node """ - frame_range = kwarg.get("frame_range", None) + use_range_limit = kwarg.get("use_range_limit", None) w = nuke.createNode( "Write", @@ -392,15 +391,44 @@ def add_write_node(name, **kwarg): log.debug(e) continue - if frame_range: + if use_range_limit: w["use_limit"].setValue(True) - w["first"].setValue(frame_range[0]) - w["last"].setValue(frame_range[1]) + w["first"].setValue(kwarg["frame_range"][0]) + w["last"].setValue(kwarg["frame_range"][1]) return w -def read(node): +def add_write_node(name, file_path, knobs, **kwarg): + """Adding nuke write node + + Arguments: + name (str): nuke node name + kwarg (attrs): data for nuke knobs + + Returns: + node (obj): nuke write node + """ + use_range_limit = kwarg.get("use_range_limit", None) + + w = nuke.createNode( + "Write", + "name {}".format(name)) + + w["file"].setValue(file_path) + + # finally add knob overrides + set_node_knobs_from_settings(w, knobs, **kwarg) + + if use_range_limit: + w["use_limit"].setValue(True) + w["first"].setValue(kwarg["frame_range"][0]) + w["last"].setValue(kwarg["frame_range"][1]) + + return w + + +def read_avalon_data(node): """Return user-defined knobs from given `node` Args: @@ -415,8 +443,6 @@ def read(node): return knob_name[len("avalon:"):] elif knob_name.startswith("ak:"): return knob_name[len("ak:"):] - else: - return knob_name data = dict() @@ -445,7 +471,8 @@ def read(node): (knob_type == 26 and value) ): key = compat_prefixed(knob_name) - data[key] = value + if key is not None: + data[key] = value if knob_name == first_user_knob: break @@ -501,30 +528,171 @@ def get_nuke_imageio_settings(): return get_anatomy_settings(Context.project_name)["imageio"]["nuke"] -def get_created_node_imageio_setting(**kwarg): +def get_created_node_imageio_setting_legacy(nodeclass, creator, subset): ''' Get preset data for dataflow (fileType, compression, bitDepth) ''' - log.debug(kwarg) - nodeclass = kwarg.get("nodeclass", None) - creator = kwarg.get("creator", None) assert any([creator, nodeclass]), nuke.message( "`{}`: Missing mandatory kwargs `host`, `cls`".format(__file__)) - imageio_nodes = get_nuke_imageio_settings()["nodes"]["requiredNodes"] + imageio_nodes = get_nuke_imageio_settings()["nodes"] + required_nodes = imageio_nodes["requiredNodes"] + override_nodes = imageio_nodes["overrideNodes"] imageio_node = None - for node in imageio_nodes: + for node in required_nodes: log.info(node) - if (nodeclass in node["nukeNodeClass"]) and ( - creator in node["plugins"]): + if ( + nodeclass in node["nukeNodeClass"] + and creator in node["plugins"] + ): imageio_node = node break + log.debug("__ imageio_node: {}".format(imageio_node)) + + # find matching override node + override_imageio_node = None + for onode in override_nodes: + log.info(onode) + if nodeclass not in node["nukeNodeClass"]: + continue + + if creator not in node["plugins"]: + continue + + if ( + onode["subsets"] + and not any(re.search(s, subset) for s in onode["subsets"]) + ): + continue + + override_imageio_node = onode + break + + log.debug("__ override_imageio_node: {}".format(override_imageio_node)) + # add overrides to imageio_node + if override_imageio_node: + # get all knob names in imageio_node + knob_names = [k["name"] for k in imageio_node["knobs"]] + + for oknob in override_imageio_node["knobs"]: + for knob in imageio_node["knobs"]: + # override matching knob name + if oknob["name"] == knob["name"]: + log.debug( + "_ overriding knob: `{}` > `{}`".format( + knob, oknob + )) + if not oknob["value"]: + # remove original knob if no value found in oknob + imageio_node["knobs"].remove(knob) + else: + # override knob value with oknob's + knob["value"] = oknob["value"] + + # add missing knobs into imageio_node + if oknob["name"] not in knob_names: + log.debug( + "_ adding knob: `{}`".format(oknob)) + imageio_node["knobs"].append(oknob) + knob_names.append(oknob["name"]) + log.info("ImageIO node: {}".format(imageio_node)) return imageio_node +def get_imageio_node_setting(node_class, plugin_name, subset): + ''' Get preset data for dataflow (fileType, compression, bitDepth) + ''' + imageio_nodes = get_nuke_imageio_settings()["nodes"] + required_nodes = imageio_nodes["requiredNodes"] + + imageio_node = None + for node in required_nodes: + log.info(node) + if ( + node_class in node["nukeNodeClass"] + and plugin_name in node["plugins"] + ): + imageio_node = node + break + + log.debug("__ imageio_node: {}".format(imageio_node)) + + if not imageio_node: + return + + # find overrides and update knobs with them + get_imageio_node_override_setting( + node_class, + plugin_name, + subset, + imageio_node["knobs"] + ) + + log.info("ImageIO node: {}".format(imageio_node)) + return imageio_node + + +def get_imageio_node_override_setting( + node_class, plugin_name, subset, knobs_settings +): + ''' Get imageio node overrides from settings + ''' + imageio_nodes = get_nuke_imageio_settings()["nodes"] + override_nodes = imageio_nodes["overrideNodes"] + + # find matching override node + override_imageio_node = None + for onode in override_nodes: + log.info(onode) + if node_class not in onode["nukeNodeClass"]: + continue + + if plugin_name not in onode["plugins"]: + continue + + if ( + onode["subsets"] + and not any(re.search(s, subset) for s in onode["subsets"]) + ): + continue + + override_imageio_node = onode + break + + log.debug("__ override_imageio_node: {}".format(override_imageio_node)) + # add overrides to imageio_node + if override_imageio_node: + # get all knob names in imageio_node + knob_names = [k["name"] for k in knobs_settings] + + for oknob in override_imageio_node["knobs"]: + for knob in knobs_settings: + # override matching knob name + if oknob["name"] == knob["name"]: + log.debug( + "_ overriding knob: `{}` > `{}`".format( + knob, oknob + )) + if not oknob["value"]: + # remove original knob if no value found in oknob + knobs_settings.remove(knob) + else: + # override knob value with oknob's + knob["value"] = oknob["value"] + + # add missing knobs into imageio_node + if oknob["name"] not in knob_names: + log.debug( + "_ adding knob: `{}`".format(oknob)) + knobs_settings.append(oknob) + knob_names.append(oknob["name"]) + + return knobs_settings + + def get_imageio_input_colorspace(filename): ''' Get input file colorspace based on regex in settings. ''' @@ -542,7 +710,7 @@ def get_imageio_input_colorspace(filename): def on_script_load(): ''' Callback for ffmpeg support ''' - if nuke.env['LINUX']: + if nuke.env["LINUX"]: nuke.tcl('load ffmpegReader') nuke.tcl('load ffmpegWriter') else: @@ -567,7 +735,7 @@ def check_inventory_versions(): if container: node = nuke.toNode(container["objectName"]) - avalon_knob_data = read(node) + avalon_knob_data = read_avalon_data(node) # get representation from io representation = legacy_io.find_one({ @@ -593,7 +761,7 @@ def check_inventory_versions(): versions = legacy_io.find({ "type": "version", "parent": version["parent"] - }).distinct('name') + }).distinct("name") max_version = max(versions) @@ -623,20 +791,20 @@ def writes_version_sync(): if _NODE_TAB_NAME not in each.knobs(): continue - avalon_knob_data = read(each) + avalon_knob_data = read_avalon_data(each) try: - if avalon_knob_data['families'] not in ["render"]: - log.debug(avalon_knob_data['families']) + if avalon_knob_data["families"] not in ["render"]: + log.debug(avalon_knob_data["families"]) continue - node_file = each['file'].value() + node_file = each["file"].value() node_version = "v" + get_version_from_path(node_file) log.debug("node_version: {}".format(node_version)) node_new_file = node_file.replace(node_version, new_version) - each['file'].setValue(node_new_file) + each["file"].setValue(node_new_file) if not os.path.isdir(os.path.dirname(node_new_file)): log.warning("Path does not exist! I am creating it.") os.makedirs(os.path.dirname(node_new_file)) @@ -665,21 +833,21 @@ def check_subsetname_exists(nodes, subset_name): bool: True of False """ return next((True for n in nodes - if subset_name in read(n).get("subset", "")), + if subset_name in read_avalon_data(n).get("subset", "")), False) def get_render_path(node): ''' Generate Render path from presets regarding avalon knob data ''' - data = {'avalon': read(node)} - data_preset = { - "nodeclass": data['avalon']['family'], - "families": [data['avalon']['families']], - "creator": data['avalon']['creator'] - } + avalon_knob_data = read_avalon_data(node) + data = {'avalon': avalon_knob_data} - nuke_imageio_writes = get_created_node_imageio_setting(**data_preset) + nuke_imageio_writes = get_imageio_node_setting( + node_class=avalon_knob_data["family"], + plugin_name=avalon_knob_data["creator"], + subset=avalon_knob_data["subset"] + ) host_name = os.environ.get("AVALON_APP") data.update({ @@ -749,7 +917,7 @@ def format_anatomy(data): def script_name(): ''' Returns nuke script path ''' - return nuke.root().knob('name').value() + return nuke.root().knob("name").value() def add_button_write_to_read(node): @@ -771,8 +939,282 @@ def add_button_clear_rendered(node, path): node.addKnob(knob) -def create_write_node(name, data, input=None, prenodes=None, - review=True, linked_knobs=None, farm=True): +def create_prenodes( + prev_node, + nodes_setting, + plugin_name=None, + subset=None, + **kwargs +): + last_node = None + for_dependency = {} + for name, node in nodes_setting.items(): + # get attributes + nodeclass = node["nodeclass"] + knobs = node["knobs"] + + # create node + now_node = nuke.createNode( + nodeclass, "name {}".format(name)) + now_node.hideControlPanel() + + # add for dependency linking + for_dependency[name] = { + "node": now_node, + "dependent": node["dependent"] + } + + if all([plugin_name, subset]): + # find imageio overrides + get_imageio_node_override_setting( + now_node.Class(), + plugin_name, + subset, + knobs + ) + + # add data to knob + set_node_knobs_from_settings(now_node, knobs, **kwargs) + + # switch actual node to previous + last_node = now_node + + for _node_name, node_prop in for_dependency.items(): + if not node_prop["dependent"]: + node_prop["node"].setInput( + 0, prev_node) + elif node_prop["dependent"] in for_dependency: + _prev_node = for_dependency[node_prop["dependent"]]["node"] + node_prop["node"].setInput( + 0, _prev_node) + else: + log.warning("Dependency has wrong name of node: {}".format( + node_prop + )) + + return last_node + + +def create_write_node( + name, + data, + input=None, + prenodes=None, + review=True, + farm=True, + linked_knobs=None, + **kwargs +): + ''' Creating write node which is group node + + Arguments: + name (str): name of node + data (dict): creator write instance data + input (node)[optional]: selected node to connect to + prenodes (dict)[optional]: + nodes to be created before write with dependency + review (bool)[optional]: adding review knob + farm (bool)[optional]: rendering workflow target + kwargs (dict)[optional]: additional key arguments for formating + + Example: + prenodes = { + "nodeName": { + "nodeclass": "Reformat", + "dependent": [ + following_node_01, + ... + ], + "knobs": [ + { + "type": "text", + "name": "knobname", + "value": "knob value" + }, + ... + ] + }, + ... + } + + + Return: + node (obj): group node with avalon data as Knobs + ''' + prenodes = prenodes or {} + + # group node knob overrides + knob_overrides = data.pop("knobs", []) + + # filtering variables + plugin_name = data["creator"] + subset = data["subset"] + + # get knob settings for write node + imageio_writes = get_imageio_node_setting( + node_class=data["nodeclass"], + plugin_name=plugin_name, + subset=subset + ) + + for knob in imageio_writes["knobs"]: + if knob["name"] == "file_type": + representation = knob["value"] + + host_name = os.environ.get("AVALON_APP") + try: + data.update({ + "app": host_name, + "imageio_writes": imageio_writes, + "representation": representation, + }) + anatomy_filled = format_anatomy(data) + + except Exception as e: + msg = "problem with resolving anatomy template: {}".format(e) + log.error(msg) + nuke.message(msg) + + # build file path to workfiles + fdir = str(anatomy_filled["work"]["folder"]).replace("\\", "/") + fpath = data["fpath_template"].format( + work=fdir, + version=data["version"], + subset=data["subset"], + frame=data["frame"], + ext=representation + ) + + # create directory + if not os.path.isdir(os.path.dirname(fpath)): + log.warning("Path does not exist! I am creating it.") + os.makedirs(os.path.dirname(fpath)) + + GN = nuke.createNode("Group", "name {}".format(name)) + + prev_node = None + with GN: + if input: + input_name = str(input.name()).replace(" ", "") + # if connected input node was defined + prev_node = nuke.createNode( + "Input", "name {}".format(input_name)) + else: + # generic input node connected to nothing + prev_node = nuke.createNode( + "Input", "name {}".format("rgba")) + prev_node.hideControlPanel() + + # creating pre-write nodes `prenodes` + last_prenode = create_prenodes( + prev_node, + prenodes, + plugin_name, + subset, + **kwargs + ) + if last_prenode: + prev_node = last_prenode + + # creating write node + write_node = now_node = add_write_node( + "inside_{}".format(name), + fpath, + imageio_writes["knobs"], + **data + ) + write_node.hideControlPanel() + # connect to previous node + now_node.setInput(0, prev_node) + + # switch actual node to previous + prev_node = now_node + + now_node = nuke.createNode("Output", "name Output1") + now_node.hideControlPanel() + + # connect to previous node + now_node.setInput(0, prev_node) + + # imprinting group node + set_avalon_knob_data(GN, data["avalon"]) + add_publish_knob(GN) + add_rendering_knobs(GN, farm) + + if review: + add_review_knob(GN) + + # add divider + GN.addKnob(nuke.Text_Knob('', 'Rendering')) + + # Add linked knobs. + linked_knob_names = [] + + # add input linked knobs and create group only if any input + if linked_knobs: + linked_knob_names.append("_grp-start_") + linked_knob_names.extend(linked_knobs) + linked_knob_names.append("_grp-end_") + + linked_knob_names.append("Render") + + for _k_name in linked_knob_names: + if "_grp-start_" in _k_name: + knob = nuke.Tab_Knob( + "rnd_attr", "Rendering attributes", nuke.TABBEGINCLOSEDGROUP) + GN.addKnob(knob) + elif "_grp-end_" in _k_name: + knob = nuke.Tab_Knob( + "rnd_attr_end", "Rendering attributes", nuke.TABENDGROUP) + GN.addKnob(knob) + else: + if "___" in _k_name: + # add divider + GN.addKnob(nuke.Text_Knob("")) + else: + # add linked knob by _k_name + link = nuke.Link_Knob("") + link.makeLink(write_node.name(), _k_name) + link.setName(_k_name) + + # make render + if "Render" in _k_name: + link.setLabel("Render Local") + link.setFlag(0x1000) + GN.addKnob(link) + + # adding write to read button + add_button_write_to_read(GN) + + # adding write to read button + add_button_clear_rendered(GN, os.path.dirname(fpath)) + + # Deadline tab. + add_deadline_tab(GN) + + # open the our Tab as default + GN[_NODE_TAB_NAME].setFlag(0) + + # set tile color + tile_color = next( + iter( + k["value"] for k in imageio_writes["knobs"] + if "tile_color" in k["name"] + ), [255, 0, 0, 255] + ) + GN["tile_color"].setValue( + color_gui_to_int(tile_color)) + + # finally add knob overrides + set_node_knobs_from_settings(GN, knob_overrides, **kwargs) + + return GN + + +def create_write_node_legacy( + name, data, input=None, prenodes=None, + review=True, linked_knobs=None, farm=True +): ''' Creating write node which is group node Arguments: @@ -804,8 +1246,14 @@ def create_write_node(name, data, input=None, prenodes=None, Return: node (obj): group node with avalon data as Knobs ''' + knob_overrides = data.get("knobs", []) + nodeclass = data["nodeclass"] + creator = data["creator"] + subset = data["subset"] - imageio_writes = get_created_node_imageio_setting(**data) + imageio_writes = get_created_node_imageio_setting_legacy( + nodeclass, creator, subset + ) for knob in imageio_writes["knobs"]: if knob["name"] == "file_type": representation = knob["value"] @@ -844,7 +1292,7 @@ def create_write_node(name, data, input=None, prenodes=None, # adding dataflow template log.debug("imageio_writes: `{}`".format(imageio_writes)) for knob in imageio_writes["knobs"]: - _data.update({knob["name"]: knob["value"]}) + _data[knob["name"]] = knob["value"] _data = fix_data_for_node_create(_data) @@ -927,7 +1375,8 @@ def create_write_node(name, data, input=None, prenodes=None, prev_node = now_node # creating write node - write_node = now_node = add_write_node( + + write_node = now_node = add_write_node_legacy( "inside_{}".format(name), **_data ) @@ -1007,9 +1456,106 @@ def create_write_node(name, data, input=None, prenodes=None, tile_color = _data.get("tile_color", "0xff0000ff") GN["tile_color"].setValue(tile_color) + # overrie knob values from settings + for knob in knob_overrides: + knob_type = knob["type"] + knob_name = knob["name"] + knob_value = knob["value"] + if knob_name not in GN.knobs(): + continue + if not knob_value: + continue + + # set correctly knob types + if knob_type == "string": + knob_value = str(knob_value) + if knob_type == "number": + knob_value = int(knob_value) + if knob_type == "decimal_number": + knob_value = float(knob_value) + if knob_type == "bool": + knob_value = bool(knob_value) + if knob_type in ["2d_vector", "3d_vector"]: + knob_value = list(knob_value) + + GN[knob_name].setValue(knob_value) + return GN +def set_node_knobs_from_settings(node, knob_settings, **kwargs): + """ Overriding knob values from settings + + Using `schema_nuke_knob_inputs` for knob type definitions. + + Args: + node (nuke.Node): nuke node + knob_settings (list): list of dict. Keys are `type`, `name`, `value` + kwargs (dict)[optional]: keys for formatable knob settings + """ + for knob in knob_settings: + log.debug("__ knob: {}".format(pformat(knob))) + knob_type = knob["type"] + knob_name = knob["name"] + + if knob_name not in node.knobs(): + continue + + # first deal with formatable knob settings + if knob_type == "formatable": + template = knob["template"] + to_type = knob["to_type"] + try: + _knob_value = template.format( + **kwargs + ) + log.debug("__ knob_value0: {}".format(_knob_value)) + except KeyError as msg: + log.warning("__ msg: {}".format(msg)) + raise KeyError(msg) + + # convert value to correct type + if to_type == "2d_vector": + knob_value = _knob_value.split(";").split(",") + else: + knob_value = _knob_value + + knob_type = to_type + + else: + knob_value = knob["value"] + + if not knob_value: + continue + + # first convert string types to string + # just to ditch unicode + if isinstance(knob_value, six.text_type): + knob_value = str(knob_value) + + # set correctly knob types + if knob_type == "bool": + knob_value = bool(knob_value) + elif knob_type == "decimal_number": + knob_value = float(knob_value) + elif knob_type == "number": + knob_value = int(knob_value) + elif knob_type == "text": + knob_value = knob_value + elif knob_type == "color_gui": + knob_value = color_gui_to_int(knob_value) + elif knob_type in ["2d_vector", "3d_vector", "color"]: + knob_value = [float(v) for v in knob_value] + + node[knob_name].setValue(knob_value) + + +def color_gui_to_int(color_gui): + hex_value = ( + "0x{0:0>2x}{1:0>2x}{2:0>2x}{3:0>2x}").format(*color_gui) + return int(hex_value, 16) + + def add_rendering_knobs(node, farm=True): ''' Adds additional rendering knobs to given node @@ -1193,15 +1739,19 @@ class WorkfileSettings(object): erased_viewers = [] for v in nuke.allNodes(filter="Viewer"): - v['viewerProcess'].setValue(str(viewer_dict["viewerProcess"])) + # set viewProcess to preset from settings + v["viewerProcess"].setValue( + str(viewer_dict["viewerProcess"]) + ) + if str(viewer_dict["viewerProcess"]) \ - not in v['viewerProcess'].value(): + not in v["viewerProcess"].value(): copy_inputs = v.dependencies() copy_knobs = {k: v[k].value() for k in v.knobs() if k not in filter_knobs} # delete viewer with wrong settings - erased_viewers.append(v['name'].value()) + erased_viewers.append(v["name"].value()) nuke.delete(v) # create new viewer @@ -1217,7 +1767,7 @@ class WorkfileSettings(object): nv[k].setValue(v) # set viewerProcess - nv['viewerProcess'].setValue(str(viewer_dict["viewerProcess"])) + nv["viewerProcess"].setValue(str(viewer_dict["viewerProcess"])) if erased_viewers: log.warning( @@ -1293,12 +1843,12 @@ class WorkfileSettings(object): for node in nuke.allNodes(filter="Group"): # get data from avalon knob - avalon_knob_data = read(node) + avalon_knob_data = read_avalon_data(node) - if not avalon_knob_data: + if avalon_knob_data.get("id") != "pyblish.avalon.instance": continue - if avalon_knob_data["id"] != "pyblish.avalon.instance": + if "creator" not in avalon_knob_data: continue # establish families @@ -1306,14 +1856,11 @@ class WorkfileSettings(object): if avalon_knob_data.get("families"): families.append(avalon_knob_data.get("families")) - data_preset = { - "nodeclass": avalon_knob_data["family"], - "families": families, - "creator": avalon_knob_data['creator'] - } - - nuke_imageio_writes = get_created_node_imageio_setting( - **data_preset) + nuke_imageio_writes = get_imageio_node_setting( + node_class=avalon_knob_data["family"], + plugin_name=avalon_knob_data["creator"], + subset=avalon_knob_data["subset"] + ) log.debug("nuke_imageio_writes: `{}`".format(nuke_imageio_writes)) @@ -1342,7 +1889,6 @@ class WorkfileSettings(object): write_node[knob["name"]].setValue(value) - def set_reads_colorspace(self, read_clrs_inputs): """ Setting colorspace to Read nodes @@ -1368,17 +1914,16 @@ class WorkfileSettings(object): current = n["colorspace"].value() future = str(preset_clrsp) if current != future: - changes.update({ - n.name(): { - "from": current, - "to": future - } - }) + changes[n.name()] = { + "from": current, + "to": future + } + log.debug(changes) if changes: msg = "Read nodes are not set to correct colospace:\n\n" for nname, knobs in changes.items(): - msg += str( + msg += ( " - node: '{0}' is now '{1}' but should be '{2}'\n" ).format(nname, knobs["from"], knobs["to"]) @@ -1610,17 +2155,17 @@ def get_hierarchical_attr(entity, attr, default=None): if not value: break - if value or entity['type'].lower() == 'project': + if value or entity["type"].lower() == "project": return value - parent_id = entity['parent'] + parent_id = entity["parent"] if ( - entity['type'].lower() == 'asset' - and entity.get('data', {}).get('visualParent') + entity["type"].lower() == "asset" + and entity.get("data", {}).get("visualParent") ): - parent_id = entity['data']['visualParent'] + parent_id = entity["data"]["visualParent"] - parent = legacy_io.find_one({'_id': parent_id}) + parent = legacy_io.find_one({"_id": parent_id}) return get_hierarchical_attr(parent, attr) @@ -1630,26 +2175,24 @@ def get_write_node_template_attr(node): ''' # get avalon data from node - data = dict() - data['avalon'] = read(node) - data_preset = { - "nodeclass": data['avalon']['family'], - "families": [data['avalon']['families']], - "creator": data['avalon']['creator'] - } - + avalon_knob_data = read_avalon_data(node) # get template data - nuke_imageio_writes = get_created_node_imageio_setting(**data_preset) + nuke_imageio_writes = get_imageio_node_setting( + node_class=avalon_knob_data["family"], + plugin_name=avalon_knob_data["creator"], + subset=avalon_knob_data["subset"] + ) # collecting correct data correct_data = OrderedDict({ "file": get_render_path(node) }) - # adding imageio template - {correct_data.update({k: v}) - for k, v in nuke_imageio_writes.items() - if k not in ["_id", "_previous"]} + # adding imageio knob presets + for k, v in nuke_imageio_writes.items(): + if k in ["_id", "_previous"]: + continue + correct_data[k] = v # fix badly encoded data return fix_data_for_node_create(correct_data) @@ -1765,8 +2308,8 @@ def maintained_selection(): Example: >>> with maintained_selection(): - ... node['selected'].setValue(True) - >>> print(node['selected'].value()) + ... node["selected"].setValue(True) + >>> print(node["selected"].value()) False """ previous_selection = nuke.selectedNodes() @@ -1774,11 +2317,11 @@ def maintained_selection(): yield finally: # unselect all selection in case there is some - current_seletion = nuke.selectedNodes() - [n['selected'].setValue(False) for n in current_seletion] + reset_selection() + # and select all previously selected nodes if previous_selection: - [n['selected'].setValue(True) for n in previous_selection] + select_nodes(previous_selection) def reset_selection(): diff --git a/openpype/hosts/nuke/api/pipeline.py b/openpype/hosts/nuke/api/pipeline.py index 0194acd196..2785eb65cd 100644 --- a/openpype/hosts/nuke/api/pipeline.py +++ b/openpype/hosts/nuke/api/pipeline.py @@ -32,7 +32,7 @@ from .lib import ( launch_workfiles_app, check_inventory_versions, set_avalon_knob_data, - read, + read_avalon_data, Context ) @@ -359,7 +359,7 @@ def parse_container(node): dict: The container schema data for this container node. """ - data = read(node) + data = read_avalon_data(node) # (TODO) Remove key validation when `ls` has re-implemented. # diff --git a/openpype/hosts/nuke/api/plugin.py b/openpype/hosts/nuke/api/plugin.py index eaf0ab6911..b8b56ef2b8 100644 --- a/openpype/hosts/nuke/api/plugin.py +++ b/openpype/hosts/nuke/api/plugin.py @@ -17,7 +17,9 @@ from .lib import ( reset_selection, maintained_selection, set_avalon_knob_data, - add_publish_knob + add_publish_knob, + get_nuke_imageio_settings, + set_node_knobs_from_settings ) @@ -27,9 +29,6 @@ class OpenPypeCreator(LegacyCreator): def __init__(self, *args, **kwargs): super(OpenPypeCreator, self).__init__(*args, **kwargs) - self.presets = get_current_project_settings()["nuke"]["create"].get( - self.__class__.__name__, {} - ) if check_subsetname_exists( nuke.allNodes(), self.data["subset"]): @@ -260,8 +259,6 @@ class ExporterReview(object): return nuke_imageio["viewer"]["viewerProcess"] - - class ExporterReviewLut(ExporterReview): """ Generator object for review lut from Nuke @@ -501,16 +498,7 @@ class ExporterReviewMov(ExporterReview): add_tags.append("reformated") rf_node = nuke.createNode("Reformat") - for kn_conf in reformat_node_config: - _type = kn_conf["type"] - k_name = str(kn_conf["name"]) - k_value = kn_conf["value"] - - # to remove unicode as nuke doesn't like it - if _type == "string": - k_value = str(kn_conf["value"]) - - rf_node[k_name].setValue(k_value) + set_node_knobs_from_settings(rf_node, reformat_node_config) # connect rf_node.setInput(0, self.previous_node) @@ -607,6 +595,8 @@ class AbstractWriteRender(OpenPypeCreator): family = "render" icon = "sign-out" defaults = ["Main", "Mask"] + knobs = [] + prenodes = {} def __init__(self, *args, **kwargs): super(AbstractWriteRender, self).__init__(*args, **kwargs) @@ -673,7 +663,9 @@ class AbstractWriteRender(OpenPypeCreator): write_data = { "nodeclass": self.n_class, "families": [self.family], - "avalon": self.data + "avalon": self.data, + "subset": self.data["subset"], + "knobs": self.knobs } # add creator data @@ -681,21 +673,12 @@ class AbstractWriteRender(OpenPypeCreator): self.data.update(creator_data) write_data.update(creator_data) - if self.presets.get('fpath_template'): - self.log.info("Adding template path from preset") - write_data.update( - {"fpath_template": self.presets["fpath_template"]} - ) - else: - self.log.info("Adding template path from plugin") - write_data.update({ - "fpath_template": - ("{work}/" + self.family + "s/nuke/{subset}" - "/{subset}.{frame}.{ext}")}) - - write_node = self._create_write_node(selected_node, - inputs, outputs, - write_data) + write_node = self._create_write_node( + selected_node, + inputs, + outputs, + write_data + ) # relinking to collected connections for i, input in enumerate(inputs): @@ -710,6 +693,28 @@ class AbstractWriteRender(OpenPypeCreator): return write_node + def is_legacy(self): + """Check if it needs to run legacy code + + In case where `type` key is missing in singe + knob it is legacy project anatomy. + + Returns: + bool: True if legacy + """ + imageio_nodes = get_nuke_imageio_settings()["nodes"] + node = imageio_nodes["requiredNodes"][0] + if "type" not in node["knobs"][0]: + # if type is not yet in project anatomy + return True + elif next(iter( + _k for _k in node["knobs"] + if _k.get("type") == "__legacy__" + ), None): + # in case someone re-saved anatomy + # with old configuration + return True + @abstractmethod def _create_write_node(self, selected_node, inputs, outputs, write_data): """Family dependent implementation of Write node creation diff --git a/openpype/hosts/nuke/plugins/create/create_write_prerender.py b/openpype/hosts/nuke/plugins/create/create_write_prerender.py index 7297f74c13..fec97167fb 100644 --- a/openpype/hosts/nuke/plugins/create/create_write_prerender.py +++ b/openpype/hosts/nuke/plugins/create/create_write_prerender.py @@ -1,7 +1,8 @@ import nuke from openpype.hosts.nuke.api import plugin -from openpype.hosts.nuke.api.lib import create_write_node +from openpype.hosts.nuke.api.lib import ( + create_write_node, create_write_node_legacy) class CreateWritePrerender(plugin.AbstractWriteRender): @@ -12,22 +13,41 @@ class CreateWritePrerender(plugin.AbstractWriteRender): n_class = "Write" family = "prerender" icon = "sign-out" + + # settings + fpath_template = "{work}/render/nuke/{subset}/{subset}.{frame}.{ext}" defaults = ["Key01", "Bg01", "Fg01", "Branch01", "Part01"] + reviewable = False + use_range_limit = True def __init__(self, *args, **kwargs): super(CreateWritePrerender, self).__init__(*args, **kwargs) def _create_write_node(self, selected_node, inputs, outputs, write_data): - reviewable = self.presets.get("reviewable") - write_node = create_write_node( - self.data["subset"], - write_data, - input=selected_node, - prenodes=[], - review=reviewable, - linked_knobs=["channels", "___", "first", "last", "use_limit"]) + # add fpath_template + write_data["fpath_template"] = self.fpath_template + write_data["use_range_limit"] = self.use_range_limit + write_data["frame_range"] = ( + nuke.root()["first_frame"].value(), + nuke.root()["last_frame"].value() + ) - return write_node + if not self.is_legacy(): + return create_write_node( + self.data["subset"], + write_data, + input=selected_node, + review=self.reviewable, + linked_knobs=["channels", "___", "first", "last", "use_limit"] + ) + else: + return create_write_node_legacy( + self.data["subset"], + write_data, + input=selected_node, + review=self.reviewable, + linked_knobs=["channels", "___", "first", "last", "use_limit"] + ) def _modify_write_node(self, write_node): # open group node @@ -38,7 +58,7 @@ class CreateWritePrerender(plugin.AbstractWriteRender): w_node = n write_node.end() - if self.presets.get("use_range_limit"): + if self.use_range_limit: w_node["use_limit"].setValue(True) w_node["first"].setValue(nuke.root()["first_frame"].value()) w_node["last"].setValue(nuke.root()["last_frame"].value()) diff --git a/openpype/hosts/nuke/plugins/create/create_write_render.py b/openpype/hosts/nuke/plugins/create/create_write_render.py index 18a101546f..23846c0332 100644 --- a/openpype/hosts/nuke/plugins/create/create_write_render.py +++ b/openpype/hosts/nuke/plugins/create/create_write_render.py @@ -1,7 +1,8 @@ import nuke from openpype.hosts.nuke.api import plugin -from openpype.hosts.nuke.api.lib import create_write_node +from openpype.hosts.nuke.api.lib import ( + create_write_node, create_write_node_legacy) class CreateWriteRender(plugin.AbstractWriteRender): @@ -12,12 +13,36 @@ class CreateWriteRender(plugin.AbstractWriteRender): n_class = "Write" family = "render" icon = "sign-out" + + # settings + fpath_template = "{work}/render/nuke/{subset}/{subset}.{frame}.{ext}" defaults = ["Main", "Mask"] + prenodes = { + "Reformat01": { + "nodeclass": "Reformat", + "dependent": None, + "knobs": [ + { + "type": "text", + "name": "resize", + "value": "none" + }, + { + "type": "bool", + "name": "black_outside", + "value": True + } + ] + } + } def __init__(self, *args, **kwargs): super(CreateWriteRender, self).__init__(*args, **kwargs) def _create_write_node(self, selected_node, inputs, outputs, write_data): + # add fpath_template + write_data["fpath_template"] = self.fpath_template + # add reformat node to cut off all outside of format bounding box # get width and height try: @@ -26,25 +51,36 @@ class CreateWriteRender(plugin.AbstractWriteRender): actual_format = nuke.root().knob('format').value() width, height = (actual_format.width(), actual_format.height()) - _prenodes = [ - { - "name": "Reformat01", - "class": "Reformat", - "knobs": [ - ("resize", 0), - ("black_outside", 1), - ], - "dependent": None - } - ] + if not self.is_legacy(): + return create_write_node( + self.data["subset"], + write_data, + input=selected_node, + prenodes=self.prenodes, + **{ + "width": width, + "height": height + } + ) + else: + _prenodes = [ + { + "name": "Reformat01", + "class": "Reformat", + "knobs": [ + ("resize", 0), + ("black_outside", 1), + ], + "dependent": None + } + ] - write_node = create_write_node( - self.data["subset"], - write_data, - input=selected_node, - prenodes=_prenodes) - - return write_node + return create_write_node_legacy( + self.data["subset"], + write_data, + input=selected_node, + prenodes=_prenodes + ) def _modify_write_node(self, write_node): return write_node diff --git a/openpype/hosts/nuke/plugins/create/create_write_still.py b/openpype/hosts/nuke/plugins/create/create_write_still.py index d22b5eab3f..4007ccf51e 100644 --- a/openpype/hosts/nuke/plugins/create/create_write_still.py +++ b/openpype/hosts/nuke/plugins/create/create_write_still.py @@ -1,7 +1,8 @@ import nuke from openpype.hosts.nuke.api import plugin -from openpype.hosts.nuke.api.lib import create_write_node +from openpype.hosts.nuke.api.lib import ( + create_write_node, create_write_node_legacy) class CreateWriteStill(plugin.AbstractWriteRender): @@ -12,42 +13,69 @@ class CreateWriteStill(plugin.AbstractWriteRender): n_class = "Write" family = "still" icon = "image" + + # settings + fpath_template = "{work}/render/nuke/{subset}/{subset}.{ext}" defaults = [ - "ImageFrame{:0>4}".format(nuke.frame()), - "MPFrame{:0>4}".format(nuke.frame()), - "LayoutFrame{:0>4}".format(nuke.frame()) + "ImageFrame", + "MPFrame", + "LayoutFrame" ] + prenodes = { + "FrameHold01": { + "nodeclass": "FrameHold", + "dependent": None, + "knobs": [ + { + "type": "formatable", + "name": "first_frame", + "template": "{frame}", + "to_type": "number" + } + ] + } + } def __init__(self, *args, **kwargs): super(CreateWriteStill, self).__init__(*args, **kwargs) def _create_write_node(self, selected_node, inputs, outputs, write_data): - # explicitly reset template to 'renders', not same as other 2 writes - write_data.update({ - "fpath_template": ( - "{work}/renders/nuke/{subset}/{subset}.{ext}")}) + # add fpath_template + write_data["fpath_template"] = self.fpath_template - _prenodes = [ - { - "name": "FrameHold01", - "class": "FrameHold", - "knobs": [ - ("first_frame", nuke.frame()) - ], - "dependent": None - } - ] - - write_node = create_write_node( - self.name, - write_data, - input=selected_node, - review=False, - prenodes=_prenodes, - farm=False, - linked_knobs=["channels", "___", "first", "last", "use_limit"]) - - return write_node + if not self.is_legacy(): + return create_write_node( + self.name, + write_data, + input=selected_node, + review=False, + prenodes=self.prenodes, + farm=False, + linked_knobs=["channels", "___", "first", "last", "use_limit"], + **{ + "frame": nuke.frame() + } + ) + else: + _prenodes = [ + { + "name": "FrameHold01", + "class": "FrameHold", + "knobs": [ + ("first_frame", nuke.frame()) + ], + "dependent": None + } + ] + return create_write_node_legacy( + self.name, + write_data, + input=selected_node, + review=False, + prenodes=_prenodes, + farm=False, + linked_knobs=["channels", "___", "first", "last", "use_limit"] + ) def _modify_write_node(self, write_node): write_node.begin() diff --git a/openpype/hosts/nuke/plugins/load/actions.py b/openpype/hosts/nuke/plugins/load/actions.py index 81840b3a38..d364a4f3a1 100644 --- a/openpype/hosts/nuke/plugins/load/actions.py +++ b/openpype/hosts/nuke/plugins/load/actions.py @@ -9,7 +9,7 @@ log = Logger().get_logger(__name__) class SetFrameRangeLoader(load.LoaderPlugin): - """Specific loader of Alembic for the avalon.animation family""" + """Set frame range excluding pre- and post-handles""" families = ["animation", "camera", @@ -43,7 +43,7 @@ class SetFrameRangeLoader(load.LoaderPlugin): class SetFrameRangeWithHandlesLoader(load.LoaderPlugin): - """Specific loader of Alembic for the avalon.animation family""" + """Set frame range including pre- and post-handles""" families = ["animation", "camera", diff --git a/openpype/hosts/nuke/plugins/load/load_model.py b/openpype/hosts/nuke/plugins/load/load_model.py index 9788bb25d2..2f54595cb0 100644 --- a/openpype/hosts/nuke/plugins/load/load_model.py +++ b/openpype/hosts/nuke/plugins/load/load_model.py @@ -15,13 +15,13 @@ from openpype.hosts.nuke.api import ( class AlembicModelLoader(load.LoaderPlugin): """ - This will load alembic model into script. + This will load alembic model or anim into script. """ - families = ["model"] + families = ["model", "pointcache", "animation"] representations = ["abc"] - label = "Load Alembic Model" + label = "Load Alembic" icon = "cube" color = "orange" node_color = "0x4ecd91ff" diff --git a/openpype/hosts/nuke/plugins/publish/extract_review_data_mov.py b/openpype/hosts/nuke/plugins/publish/extract_review_data_mov.py index 2e8843d2e0..2a79d600ba 100644 --- a/openpype/hosts/nuke/plugins/publish/extract_review_data_mov.py +++ b/openpype/hosts/nuke/plugins/publish/extract_review_data_mov.py @@ -52,7 +52,7 @@ class ExtractReviewDataMov(openpype.api.Extractor): for o_name, o_data in self.outputs.items(): f_families = o_data["filter"]["families"] f_task_types = o_data["filter"]["task_types"] - f_subsets = o_data["filter"]["sebsets"] + f_subsets = o_data["filter"]["subsets"] self.log.debug( "f_families `{}` > families: {}".format( diff --git a/openpype/hosts/nuke/startup/menu.py b/openpype/hosts/nuke/startup/menu.py index 9ed43b2110..49edb22a89 100644 --- a/openpype/hosts/nuke/startup/menu.py +++ b/openpype/hosts/nuke/startup/menu.py @@ -1,4 +1,5 @@ import nuke +import os from openpype.api import Logger from openpype.pipeline import install_host @@ -9,6 +10,7 @@ from openpype.hosts.nuke.api.lib import ( WorkfileSettings, dirmap_file_name_filter ) +from openpype.settings import get_project_settings log = Logger.get_logger(__name__) @@ -28,3 +30,32 @@ nuke.addOnScriptLoad(WorkfileSettings().set_context_settings) nuke.addFilenameFilter(dirmap_file_name_filter) log.info('Automatic syncing of write file knob to script version') + + +def add_scripts_menu(): + try: + from scriptsmenu import launchfornuke + except ImportError: + log.warning( + "Skipping studio.menu install, because " + "'scriptsmenu' module seems unavailable." + ) + return + + # load configuration of custom menu + project_settings = get_project_settings(os.getenv("AVALON_PROJECT")) + config = project_settings["nuke"]["scriptsmenu"]["definition"] + _menu = project_settings["nuke"]["scriptsmenu"]["name"] + + if not config: + log.warning("Skipping studio menu, no definition found.") + return + + # run the launcher for Maya menu + studio_menu = launchfornuke.main(title=_menu.title()) + + # apply configuration + studio_menu.build_from_configuration(studio_menu, config) + + +add_scripts_menu() diff --git a/openpype/hosts/photoshop/api/ws_stub.py b/openpype/hosts/photoshop/api/ws_stub.py index fa076ecc7e..b49bf1c73f 100644 --- a/openpype/hosts/photoshop/api/ws_stub.py +++ b/openpype/hosts/photoshop/api/ws_stub.py @@ -29,6 +29,16 @@ class PSItem(object): color_code = attr.ib(default=None) # color code of layer instance_id = attr.ib(default=None) + @property + def clean_name(self): + """Returns layer name without publish icon highlight + + Returns: + (str) + """ + return (self.name.replace(PhotoshopServerStub.PUBLISH_ICON, '') + .replace(PhotoshopServerStub.LOADED_ICON, '')) + class PhotoshopServerStub: """ diff --git a/openpype/hosts/photoshop/plugins/publish/collect_batch_data.py b/openpype/hosts/photoshop/plugins/publish/collect_batch_data.py index 448493d370..2881ef0ea6 100644 --- a/openpype/hosts/photoshop/plugins/publish/collect_batch_data.py +++ b/openpype/hosts/photoshop/plugins/publish/collect_batch_data.py @@ -39,6 +39,9 @@ class CollectBatchData(pyblish.api.ContextPlugin): def process(self, context): self.log.info("CollectBatchData") batch_dir = os.environ.get("OPENPYPE_PUBLISH_DATA") + if os.environ.get("IS_TEST"): + self.log.debug("Automatic testing, no batch data, skipping") + return assert batch_dir, ( "Missing `OPENPYPE_PUBLISH_DATA`") diff --git a/openpype/hosts/photoshop/plugins/publish/collect_color_coded_instances.py b/openpype/hosts/photoshop/plugins/publish/collect_color_coded_instances.py index 122428eea0..71bd2cd854 100644 --- a/openpype/hosts/photoshop/plugins/publish/collect_color_coded_instances.py +++ b/openpype/hosts/photoshop/plugins/publish/collect_color_coded_instances.py @@ -5,6 +5,7 @@ import pyblish.api from openpype.lib import prepare_template_data from openpype.hosts.photoshop import api as photoshop +from openpype.settings import get_project_settings class CollectColorCodedInstances(pyblish.api.ContextPlugin): @@ -49,6 +50,12 @@ class CollectColorCodedInstances(pyblish.api.ContextPlugin): asset_name = context.data["asset"] task_name = context.data["task"] variant = context.data["variant"] + project_name = context.data["projectEntity"]["name"] + + naming_conventions = get_project_settings(project_name).get( + "photoshop", {}).get( + "publish", {}).get( + "ValidateNaming", {}) stub = photoshop.stub() layers = stub.get_layers() @@ -77,12 +84,15 @@ class CollectColorCodedInstances(pyblish.api.ContextPlugin): "variant": variant, "family": resolved_family, "task": task_name, - "layer": layer.name + "layer": layer.clean_name } subset = resolved_subset_template.format( **prepare_template_data(fill_pairs)) + subset = self._clean_subset_name(stub, naming_conventions, + subset, layer) + if subset in existing_subset_names: self.log.info( "Subset {} already created, skipping.".format(subset)) @@ -141,6 +151,7 @@ class CollectColorCodedInstances(pyblish.api.ContextPlugin): instance.data["task"] = task_name instance.data["subset"] = subset instance.data["layer"] = layer + instance.data["families"] = [] return instance @@ -186,3 +197,21 @@ class CollectColorCodedInstances(pyblish.api.ContextPlugin): self.log.debug("resolved_subset_template {}".format( resolved_subset_template)) return family, resolved_subset_template + + def _clean_subset_name(self, stub, naming_conventions, subset, layer): + """Cleans invalid characters from subset name and layer name.""" + if re.search(naming_conventions["invalid_chars"], subset): + subset = re.sub( + naming_conventions["invalid_chars"], + naming_conventions["replace_char"], + subset + ) + layer_name = re.sub( + naming_conventions["invalid_chars"], + naming_conventions["replace_char"], + layer.clean_name + ) + layer.name = layer_name + stub.rename_layer(layer.id, layer_name) + + return subset diff --git a/openpype/hosts/photoshop/plugins/publish/validate_naming.py b/openpype/hosts/photoshop/plugins/publish/validate_naming.py index bcae24108c..b53f4e8198 100644 --- a/openpype/hosts/photoshop/plugins/publish/validate_naming.py +++ b/openpype/hosts/photoshop/plugins/publish/validate_naming.py @@ -42,7 +42,8 @@ class ValidateNamingRepair(pyblish.api.Action): layer_name = re.sub(invalid_chars, replace_char, - current_layer_state.name) + current_layer_state.clean_name) + layer_name = stub.PUBLISH_ICON + layer_name stub.rename_layer(current_layer_state.id, layer_name) @@ -73,13 +74,17 @@ class ValidateNaming(pyblish.api.InstancePlugin): def process(self, instance): help_msg = ' Use Repair action (A) in Pyblish to fix it.' - msg = "Name \"{}\" is not allowed.{}".format(instance.data["name"], - help_msg) - formatting_data = {"msg": msg} - if re.search(self.invalid_chars, instance.data["name"]): - raise PublishXmlValidationError(self, msg, - formatting_data=formatting_data) + layer = instance.data.get("layer") + if layer: + msg = "Name \"{}\" is not allowed.{}".format(layer.clean_name, + help_msg) + + formatting_data = {"msg": msg} + if re.search(self.invalid_chars, layer.clean_name): + raise PublishXmlValidationError(self, msg, + formatting_data=formatting_data + ) msg = "Subset \"{}\" is not allowed.{}".format(instance.data["subset"], help_msg) diff --git a/openpype/hosts/standalonepublisher/plugins/publish/collect_batch_instances.py b/openpype/hosts/standalonepublisher/plugins/publish/collect_batch_instances.py deleted file mode 100644 index 4ca1f72cc4..0000000000 --- a/openpype/hosts/standalonepublisher/plugins/publish/collect_batch_instances.py +++ /dev/null @@ -1,70 +0,0 @@ -import copy -import pyblish.api -from pprint import pformat - - -class CollectBatchInstances(pyblish.api.InstancePlugin): - """Collect all available instances for batch publish.""" - - label = "Collect Batch Instances" - order = pyblish.api.CollectorOrder + 0.489 - hosts = ["standalonepublisher"] - families = ["background_batch"] - - # presets - default_subset_task = { - "background_batch": "background" - } - subsets = { - "background_batch": { - "backgroundLayout": { - "task": "background", - "family": "backgroundLayout" - }, - "backgroundComp": { - "task": "background", - "family": "backgroundComp" - }, - "workfileBackground": { - "task": "background", - "family": "workfile" - } - } - } - unchecked_by_default = [] - - def process(self, instance): - context = instance.context - asset_name = instance.data["asset"] - family = instance.data["family"] - - default_task_name = self.default_subset_task.get(family) - for subset_name, subset_data in self.subsets[family].items(): - instance_name = f"{asset_name}_{subset_name}" - task_name = subset_data.get("task") or default_task_name - - # create new instance - new_instance = context.create_instance(instance_name) - - # add original instance data except name key - for key, value in instance.data.items(): - if key not in ["name"]: - # Make sure value is copy since value may be object which - # can be shared across all new created objects - new_instance.data[key] = copy.deepcopy(value) - - # add subset data from preset - new_instance.data.update(subset_data) - - new_instance.data["label"] = instance_name - new_instance.data["subset"] = subset_name - new_instance.data["task"] = task_name - - if subset_name in self.unchecked_by_default: - new_instance.data["publish"] = False - - self.log.info(f"Created new instance: {instance_name}") - self.log.debug(f"_ inst_data: {pformat(new_instance.data)}") - - # delete original instance - context.remove(instance) diff --git a/openpype/hosts/standalonepublisher/plugins/publish/extract_bg_for_compositing.py b/openpype/hosts/standalonepublisher/plugins/publish/extract_bg_for_compositing.py deleted file mode 100644 index 9621d70739..0000000000 --- a/openpype/hosts/standalonepublisher/plugins/publish/extract_bg_for_compositing.py +++ /dev/null @@ -1,243 +0,0 @@ -import os -import json -import copy - -import openpype.api -from openpype.pipeline import legacy_io - -PSDImage = None - - -class ExtractBGForComp(openpype.api.Extractor): - label = "Extract Background for Compositing" - families = ["backgroundComp"] - hosts = ["standalonepublisher"] - - new_instance_family = "background" - - # Presetable - allowed_group_names = [ - "OL", "BG", "MG", "FG", "SB", "UL", "SKY", "Field Guide", "Field_Guide", - "ANIM" - ] - - def process(self, instance): - # Check if python module `psd_tools` is installed - try: - global PSDImage - from psd_tools import PSDImage - except Exception: - raise AssertionError( - "BUG: Python module `psd-tools` is not installed!" - ) - - self.allowed_group_names = [ - name.lower() - for name in self.allowed_group_names - ] - - self.redo_global_plugins(instance) - - repres = instance.data.get("representations") - if not repres: - self.log.info("There are no representations on instance.") - return - - if not instance.data.get("transfers"): - instance.data["transfers"] = [] - - # Prepare staging dir - staging_dir = self.staging_dir(instance) - if not os.path.exists(staging_dir): - os.makedirs(staging_dir) - - for repre in tuple(repres): - # Skip all files without .psd extension - repre_ext = repre["ext"].lower() - if repre_ext.startswith("."): - repre_ext = repre_ext[1:] - - if repre_ext != "psd": - continue - - # Prepare publish dir for transfers - publish_dir = instance.data["publishDir"] - - # Prepare json filepath where extracted metadata are stored - json_filename = "{}.json".format(instance.name) - json_full_path = os.path.join(staging_dir, json_filename) - - self.log.debug(f"`staging_dir` is \"{staging_dir}\"") - - # Prepare new repre data - new_repre = { - "name": "json", - "ext": "json", - "files": json_filename, - "stagingDir": staging_dir - } - - # TODO add check of list - psd_filename = repre["files"] - psd_folder_path = repre["stagingDir"] - psd_filepath = os.path.join(psd_folder_path, psd_filename) - self.log.debug(f"psd_filepath: \"{psd_filepath}\"") - psd_object = PSDImage.open(psd_filepath) - - json_data, transfers = self.export_compositing_images( - psd_object, staging_dir, publish_dir - ) - self.log.info("Json file path: {}".format(json_full_path)) - with open(json_full_path, "w") as json_filestream: - json.dump(json_data, json_filestream, indent=4) - - instance.data["transfers"].extend(transfers) - instance.data["representations"].remove(repre) - instance.data["representations"].append(new_repre) - - def export_compositing_images(self, psd_object, output_dir, publish_dir): - json_data = { - "__schema_version__": 1, - "children": [] - } - transfers = [] - for main_idx, main_layer in enumerate(psd_object): - if ( - not main_layer.is_visible() - or main_layer.name.lower() not in self.allowed_group_names - or not main_layer.is_group - ): - continue - - export_layers = [] - layers_idx = 0 - for layer in main_layer: - # TODO this way may be added also layers next to "ADJ" - if layer.name.lower() == "adj": - for _layer in layer: - export_layers.append((layers_idx, _layer)) - layers_idx += 1 - - else: - export_layers.append((layers_idx, layer)) - layers_idx += 1 - - if not export_layers: - continue - - main_layer_data = { - "index": main_idx, - "name": main_layer.name, - "children": [] - } - - for layer_idx, layer in export_layers: - has_size = layer.width > 0 and layer.height > 0 - if not has_size: - self.log.debug(( - "Skipping layer \"{}\" because does " - "not have any content." - ).format(layer.name)) - continue - - main_layer_name = main_layer.name.replace(" ", "_") - layer_name = layer.name.replace(" ", "_") - - filename = "{:0>2}_{}_{:0>2}_{}.png".format( - main_idx + 1, main_layer_name, layer_idx + 1, layer_name - ) - layer_data = { - "index": layer_idx, - "name": layer.name, - "filename": filename - } - output_filepath = os.path.join(output_dir, filename) - dst_filepath = os.path.join(publish_dir, filename) - transfers.append((output_filepath, dst_filepath)) - - pil_object = layer.composite(viewport=psd_object.viewbox) - pil_object.save(output_filepath, "PNG") - - main_layer_data["children"].append(layer_data) - - if main_layer_data["children"]: - json_data["children"].append(main_layer_data) - - return json_data, transfers - - def redo_global_plugins(self, instance): - # TODO do this in collection phase - # Copy `families` and check if `family` is not in current families - families = instance.data.get("families") or list() - if families: - families = list(set(families)) - - if self.new_instance_family in families: - families.remove(self.new_instance_family) - - self.log.debug( - "Setting new instance families {}".format(str(families)) - ) - instance.data["families"] = families - - # Override instance data with new information - instance.data["family"] = self.new_instance_family - - subset_name = instance.data["anatomyData"]["subset"] - asset_doc = instance.data["assetEntity"] - latest_version = self.find_last_version(subset_name, asset_doc) - version_number = 1 - if latest_version is not None: - version_number += latest_version - - instance.data["latestVersion"] = latest_version - instance.data["version"] = version_number - - # Same data apply to anatomy data - instance.data["anatomyData"].update({ - "family": self.new_instance_family, - "version": version_number - }) - - # Redo publish and resources dir - anatomy = instance.context.data["anatomy"] - template_data = copy.deepcopy(instance.data["anatomyData"]) - template_data.update({ - "frame": "FRAME_TEMP", - "representation": "TEMP" - }) - anatomy_filled = anatomy.format(template_data) - if "folder" in anatomy.templates["publish"]: - publish_folder = anatomy_filled["publish"]["folder"] - else: - publish_folder = os.path.dirname(anatomy_filled["publish"]["path"]) - - publish_folder = os.path.normpath(publish_folder) - resources_folder = os.path.join(publish_folder, "resources") - - instance.data["publishDir"] = publish_folder - instance.data["resourcesDir"] = resources_folder - - self.log.debug("publishDir: \"{}\"".format(publish_folder)) - self.log.debug("resourcesDir: \"{}\"".format(resources_folder)) - - def find_last_version(self, subset_name, asset_doc): - subset_doc = legacy_io.find_one({ - "type": "subset", - "name": subset_name, - "parent": asset_doc["_id"] - }) - - if subset_doc is None: - self.log.debug("Subset entity does not exist yet.") - else: - version_doc = legacy_io.find_one( - { - "type": "version", - "parent": subset_doc["_id"] - }, - sort=[("name", -1)] - ) - if version_doc: - return int(version_doc["name"]) - return None diff --git a/openpype/hosts/standalonepublisher/plugins/publish/extract_bg_main_groups.py b/openpype/hosts/standalonepublisher/plugins/publish/extract_bg_main_groups.py deleted file mode 100644 index b45f04e574..0000000000 --- a/openpype/hosts/standalonepublisher/plugins/publish/extract_bg_main_groups.py +++ /dev/null @@ -1,248 +0,0 @@ -import os -import copy -import json - -import pyblish.api - -import openpype.api -from openpype.pipeline import legacy_io - -PSDImage = None - - -class ExtractBGMainGroups(openpype.api.Extractor): - label = "Extract Background Layout" - order = pyblish.api.ExtractorOrder + 0.02 - families = ["backgroundLayout"] - hosts = ["standalonepublisher"] - - new_instance_family = "background" - - # Presetable - allowed_group_names = [ - "OL", "BG", "MG", "FG", "UL", "SB", "SKY", "Field Guide", "Field_Guide", - "ANIM" - ] - - def process(self, instance): - # Check if python module `psd_tools` is installed - try: - global PSDImage - from psd_tools import PSDImage - except Exception: - raise AssertionError( - "BUG: Python module `psd-tools` is not installed!" - ) - - self.allowed_group_names = [ - name.lower() - for name in self.allowed_group_names - ] - repres = instance.data.get("representations") - if not repres: - self.log.info("There are no representations on instance.") - return - - self.redo_global_plugins(instance) - - repres = instance.data.get("representations") - if not repres: - self.log.info("There are no representations on instance.") - return - - if not instance.data.get("transfers"): - instance.data["transfers"] = [] - - # Prepare staging dir - staging_dir = self.staging_dir(instance) - if not os.path.exists(staging_dir): - os.makedirs(staging_dir) - - # Prepare publish dir for transfers - publish_dir = instance.data["publishDir"] - - for repre in tuple(repres): - # Skip all files without .psd extension - repre_ext = repre["ext"].lower() - if repre_ext.startswith("."): - repre_ext = repre_ext[1:] - - if repre_ext != "psd": - continue - - # Prepare json filepath where extracted metadata are stored - json_filename = "{}.json".format(instance.name) - json_full_path = os.path.join(staging_dir, json_filename) - - self.log.debug(f"`staging_dir` is \"{staging_dir}\"") - - # Prepare new repre data - new_repre = { - "name": "json", - "ext": "json", - "files": json_filename, - "stagingDir": staging_dir - } - - # TODO add check of list - psd_filename = repre["files"] - psd_folder_path = repre["stagingDir"] - psd_filepath = os.path.join(psd_folder_path, psd_filename) - self.log.debug(f"psd_filepath: \"{psd_filepath}\"") - psd_object = PSDImage.open(psd_filepath) - - json_data, transfers = self.export_compositing_images( - psd_object, staging_dir, publish_dir - ) - self.log.info("Json file path: {}".format(json_full_path)) - with open(json_full_path, "w") as json_filestream: - json.dump(json_data, json_filestream, indent=4) - - instance.data["transfers"].extend(transfers) - instance.data["representations"].remove(repre) - instance.data["representations"].append(new_repre) - - def export_compositing_images(self, psd_object, output_dir, publish_dir): - json_data = { - "__schema_version__": 1, - "children": [] - } - output_ext = ".png" - - to_export = [] - for layer_idx, layer in enumerate(psd_object): - layer_name = layer.name.replace(" ", "_") - if ( - not layer.is_visible() - or layer_name.lower() not in self.allowed_group_names - ): - continue - - has_size = layer.width > 0 and layer.height > 0 - if not has_size: - self.log.debug(( - "Skipping layer \"{}\" because does not have any content." - ).format(layer.name)) - continue - - filebase = "{:0>2}_{}".format(layer_idx, layer_name) - if layer_name.lower() == "anim": - if not layer.is_group: - self.log.warning("ANIM layer is not a group layer.") - continue - - children = [] - for anim_idx, anim_layer in enumerate(layer): - anim_layer_name = anim_layer.name.replace(" ", "_") - filename = "{}_{:0>2}_{}{}".format( - filebase, anim_idx, anim_layer_name, output_ext - ) - children.append({ - "index": anim_idx, - "name": anim_layer.name, - "filename": filename - }) - to_export.append((anim_layer, filename)) - - json_data["children"].append({ - "index": layer_idx, - "name": layer.name, - "children": children - }) - continue - - filename = filebase + output_ext - json_data["children"].append({ - "index": layer_idx, - "name": layer.name, - "filename": filename - }) - to_export.append((layer, filename)) - - transfers = [] - for layer, filename in to_export: - output_filepath = os.path.join(output_dir, filename) - dst_filepath = os.path.join(publish_dir, filename) - transfers.append((output_filepath, dst_filepath)) - - pil_object = layer.composite(viewport=psd_object.viewbox) - pil_object.save(output_filepath, "PNG") - - return json_data, transfers - - def redo_global_plugins(self, instance): - # TODO do this in collection phase - # Copy `families` and check if `family` is not in current families - families = instance.data.get("families") or list() - if families: - families = list(set(families)) - - if self.new_instance_family in families: - families.remove(self.new_instance_family) - - self.log.debug( - "Setting new instance families {}".format(str(families)) - ) - instance.data["families"] = families - - # Override instance data with new information - instance.data["family"] = self.new_instance_family - - subset_name = instance.data["anatomyData"]["subset"] - asset_doc = instance.data["assetEntity"] - latest_version = self.find_last_version(subset_name, asset_doc) - version_number = 1 - if latest_version is not None: - version_number += latest_version - - instance.data["latestVersion"] = latest_version - instance.data["version"] = version_number - - # Same data apply to anatomy data - instance.data["anatomyData"].update({ - "family": self.new_instance_family, - "version": version_number - }) - - # Redo publish and resources dir - anatomy = instance.context.data["anatomy"] - template_data = copy.deepcopy(instance.data["anatomyData"]) - template_data.update({ - "frame": "FRAME_TEMP", - "representation": "TEMP" - }) - anatomy_filled = anatomy.format(template_data) - if "folder" in anatomy.templates["publish"]: - publish_folder = anatomy_filled["publish"]["folder"] - else: - publish_folder = os.path.dirname(anatomy_filled["publish"]["path"]) - - publish_folder = os.path.normpath(publish_folder) - resources_folder = os.path.join(publish_folder, "resources") - - instance.data["publishDir"] = publish_folder - instance.data["resourcesDir"] = resources_folder - - self.log.debug("publishDir: \"{}\"".format(publish_folder)) - self.log.debug("resourcesDir: \"{}\"".format(resources_folder)) - - def find_last_version(self, subset_name, asset_doc): - subset_doc = legacy_io.find_one({ - "type": "subset", - "name": subset_name, - "parent": asset_doc["_id"] - }) - - if subset_doc is None: - self.log.debug("Subset entity does not exist yet.") - else: - version_doc = legacy_io.find_one( - { - "type": "version", - "parent": subset_doc["_id"] - }, - sort=[("name", -1)] - ) - if version_doc: - return int(version_doc["name"]) - return None diff --git a/openpype/hosts/standalonepublisher/plugins/publish/extract_images_from_psd.py b/openpype/hosts/standalonepublisher/plugins/publish/extract_images_from_psd.py deleted file mode 100644 index 8485fa0915..0000000000 --- a/openpype/hosts/standalonepublisher/plugins/publish/extract_images_from_psd.py +++ /dev/null @@ -1,171 +0,0 @@ -import os -import copy -import pyblish.api - -import openpype.api -from openpype.pipeline import legacy_io - -PSDImage = None - - -class ExtractImagesFromPSD(openpype.api.Extractor): - # PLUGIN is not currently enabled because was decided to use different - # approach - enabled = False - active = False - label = "Extract Images from PSD" - order = pyblish.api.ExtractorOrder + 0.02 - families = ["backgroundLayout"] - hosts = ["standalonepublisher"] - - new_instance_family = "image" - ignored_instance_data_keys = ("name", "label", "stagingDir", "version") - # Presetable - allowed_group_names = [ - "OL", "BG", "MG", "FG", "UL", "SKY", "Field Guide", "Field_Guide", - "ANIM" - ] - - def process(self, instance): - # Check if python module `psd_tools` is installed - try: - global PSDImage - from psd_tools import PSDImage - except Exception: - raise AssertionError( - "BUG: Python module `psd-tools` is not installed!" - ) - - self.allowed_group_names = [ - name.lower() - for name in self.allowed_group_names - ] - repres = instance.data.get("representations") - if not repres: - self.log.info("There are no representations on instance.") - return - - for repre in tuple(repres): - # Skip all files without .psd extension - repre_ext = repre["ext"].lower() - if repre_ext.startswith("."): - repre_ext = repre_ext[1:] - - if repre_ext != "psd": - continue - - # TODO add check of list of "files" value - psd_filename = repre["files"] - psd_folder_path = repre["stagingDir"] - psd_filepath = os.path.join(psd_folder_path, psd_filename) - self.log.debug(f"psd_filepath: \"{psd_filepath}\"") - psd_object = PSDImage.open(psd_filepath) - - self.create_new_instances(instance, psd_object) - - # Remove the instance from context - instance.context.remove(instance) - - def create_new_instances(self, instance, psd_object): - asset_doc = instance.data["assetEntity"] - for layer in psd_object: - if ( - not layer.is_visible() - or layer.name.lower() not in self.allowed_group_names - ): - continue - - has_size = layer.width > 0 and layer.height > 0 - if not has_size: - self.log.debug(( - "Skipping layer \"{}\" because does " - "not have any content." - ).format(layer.name)) - continue - - layer_name = layer.name.replace(" ", "_") - instance_name = subset_name = f"image{layer_name}" - self.log.info( - f"Creating new instance with name \"{instance_name}\"" - ) - new_instance = instance.context.create_instance(instance_name) - for key, value in instance.data.items(): - if key not in self.ignored_instance_data_keys: - new_instance.data[key] = copy.deepcopy(value) - - new_instance.data["label"] = " ".join( - (new_instance.data["asset"], instance_name) - ) - - # Find latest version - latest_version = self.find_last_version(subset_name, asset_doc) - version_number = 1 - if latest_version is not None: - version_number += latest_version - - self.log.info( - "Next version of instance \"{}\" will be {}".format( - instance_name, version_number - ) - ) - - # Set family and subset - new_instance.data["family"] = self.new_instance_family - new_instance.data["subset"] = subset_name - new_instance.data["version"] = version_number - new_instance.data["latestVersion"] = latest_version - - new_instance.data["anatomyData"].update({ - "subset": subset_name, - "family": self.new_instance_family, - "version": version_number - }) - - # Copy `families` and check if `family` is not in current families - families = new_instance.data.get("families") or list() - if families: - families = list(set(families)) - - if self.new_instance_family in families: - families.remove(self.new_instance_family) - new_instance.data["families"] = families - - # Prepare staging dir for new instance - staging_dir = self.staging_dir(new_instance) - - output_filename = "{}.png".format(layer_name) - output_filepath = os.path.join(staging_dir, output_filename) - pil_object = layer.composite(viewport=psd_object.viewbox) - pil_object.save(output_filepath, "PNG") - - new_repre = { - "name": "png", - "ext": "png", - "files": output_filename, - "stagingDir": staging_dir - } - self.log.debug( - "Creating new representation: {}".format(new_repre) - ) - new_instance.data["representations"] = [new_repre] - - def find_last_version(self, subset_name, asset_doc): - subset_doc = legacy_io.find_one({ - "type": "subset", - "name": subset_name, - "parent": asset_doc["_id"] - }) - - if subset_doc is None: - self.log.debug("Subset entity does not exist yet.") - else: - version_doc = legacy_io.find_one( - { - "type": "version", - "parent": subset_doc["_id"] - }, - sort=[("name", -1)] - ) - if version_doc: - return int(version_doc["name"]) - return None diff --git a/openpype/hosts/standalonepublisher/plugins/publish/extract_thumbnail.py b/openpype/hosts/standalonepublisher/plugins/publish/extract_thumbnail.py index 23f0b104c8..3ee2f70809 100644 --- a/openpype/hosts/standalonepublisher/plugins/publish/extract_thumbnail.py +++ b/openpype/hosts/standalonepublisher/plugins/publish/extract_thumbnail.py @@ -2,7 +2,11 @@ import os import tempfile import pyblish.api import openpype.api -import openpype.lib +from openpype.lib import ( + get_ffmpeg_tool_path, + get_ffprobe_streams, + path_to_subprocess_arg, +) class ExtractThumbnailSP(pyblish.api.InstancePlugin): @@ -34,85 +38,78 @@ class ExtractThumbnailSP(pyblish.api.InstancePlugin): if not thumbnail_repre: return + thumbnail_repre.pop("thumbnail") files = thumbnail_repre.get("files") if not files: return if isinstance(files, list): - files_len = len(files) - file = str(files[0]) + first_filename = str(files[0]) else: - files_len = 1 - file = files + first_filename = files staging_dir = None - is_jpeg = False - if file.endswith(".jpeg") or file.endswith(".jpg"): - is_jpeg = True - if is_jpeg and files_len == 1: - # skip if already is single jpeg file - return + # Convert to jpeg if not yet + full_input_path = os.path.join( + thumbnail_repre["stagingDir"], first_filename + ) + self.log.info("input {}".format(full_input_path)) + with tempfile.NamedTemporaryFile(suffix=".jpg") as tmp: + full_thumbnail_path = tmp.name - elif is_jpeg: - # use first frame as thumbnail if is sequence of jpegs - full_thumbnail_path = os.path.join( - thumbnail_repre["stagingDir"], file - ) - self.log.info( - "For thumbnail is used file: {}".format(full_thumbnail_path) - ) + self.log.info("output {}".format(full_thumbnail_path)) - else: - # Convert to jpeg if not yet - full_input_path = os.path.join(thumbnail_repre["stagingDir"], file) - self.log.info("input {}".format(full_input_path)) + instance.context.data["cleanupFullPaths"].append(full_thumbnail_path) - full_thumbnail_path = tempfile.mkstemp(suffix=".jpg")[1] - self.log.info("output {}".format(full_thumbnail_path)) + ffmpeg_path = get_ffmpeg_tool_path("ffmpeg") - ffmpeg_path = openpype.lib.get_ffmpeg_tool_path("ffmpeg") + ffmpeg_args = self.ffmpeg_args or {} - ffmpeg_args = self.ffmpeg_args or {} + jpeg_items = [ + path_to_subprocess_arg(ffmpeg_path), + # override file if already exists + "-y" + ] - jpeg_items = [ - "\"{}\"".format(ffmpeg_path), - # override file if already exists - "-y" - ] - - # add input filters from peresets - jpeg_items.extend(ffmpeg_args.get("input") or []) - # input file - jpeg_items.append("-i \"{}\"".format(full_input_path)) + # add input filters from peresets + jpeg_items.extend(ffmpeg_args.get("input") or []) + # input file + jpeg_items.extend([ + "-i", path_to_subprocess_arg(full_input_path), # extract only single file - jpeg_items.append("-frames:v 1") + "-frames:v", "1", # Add black background for transparent images - jpeg_items.append(( - "-filter_complex" - " \"color=black,format=rgb24[c]" + "-filter_complex", ( + "\"color=black,format=rgb24[c]" ";[c][0]scale2ref[c][i]" ";[c][i]overlay=format=auto:shortest=1,setsar=1\"" - )) + ), + ]) - jpeg_items.extend(ffmpeg_args.get("output") or []) + jpeg_items.extend(ffmpeg_args.get("output") or []) - # output file - jpeg_items.append("\"{}\"".format(full_thumbnail_path)) + # output file + jpeg_items.append(path_to_subprocess_arg(full_thumbnail_path)) - subprocess_jpeg = " ".join(jpeg_items) + subprocess_jpeg = " ".join(jpeg_items) - # run subprocess - self.log.debug("Executing: {}".format(subprocess_jpeg)) - openpype.api.run_subprocess( - subprocess_jpeg, shell=True, logger=self.log - ) + # run subprocess + self.log.debug("Executing: {}".format(subprocess_jpeg)) + openpype.api.run_subprocess( + subprocess_jpeg, shell=True, logger=self.log + ) # remove thumbnail key from origin repre - thumbnail_repre.pop("thumbnail") + streams = get_ffprobe_streams(full_thumbnail_path) + width = height = None + for stream in streams: + if "width" in stream and "height" in stream: + width = stream["width"] + height = stream["height"] + break - filename = os.path.basename(full_thumbnail_path) - staging_dir = staging_dir or os.path.dirname(full_thumbnail_path) + staging_dir, filename = os.path.split(full_thumbnail_path) # create new thumbnail representation representation = { @@ -120,12 +117,11 @@ class ExtractThumbnailSP(pyblish.api.InstancePlugin): 'ext': 'jpg', 'files': filename, "stagingDir": staging_dir, - "tags": ["thumbnail"], + "tags": ["thumbnail", "delete"], } - - # # add Delete tag when temp file was rendered - if not is_jpeg: - representation["tags"].append("delete") + if width and height: + representation["width"] = width + representation["height"] = height self.log.info(f"New representation {representation}") instance.data["representations"].append(representation) diff --git a/openpype/hosts/traypublisher/api/plugin.py b/openpype/hosts/traypublisher/api/plugin.py new file mode 100644 index 0000000000..202664cfc6 --- /dev/null +++ b/openpype/hosts/traypublisher/api/plugin.py @@ -0,0 +1,97 @@ +from openpype.pipeline import ( + Creator, + CreatedInstance +) +from openpype.lib import FileDef + +from .pipeline import ( + list_instances, + update_instances, + remove_instances, + HostContext, +) + + +class TrayPublishCreator(Creator): + create_allow_context_change = True + host_name = "traypublisher" + + def collect_instances(self): + for instance_data in list_instances(): + creator_id = instance_data.get("creator_identifier") + if creator_id == self.identifier: + instance = CreatedInstance.from_existing( + instance_data, self + ) + self._add_instance_to_context(instance) + + def update_instances(self, update_list): + update_instances(update_list) + + def remove_instances(self, instances): + remove_instances(instances) + for instance in instances: + self._remove_instance_from_context(instance) + + def get_pre_create_attr_defs(self): + # Use same attributes as for instance attrobites + return self.get_instance_attr_defs() + + +class SettingsCreator(TrayPublishCreator): + create_allow_context_change = True + + extensions = [] + + def collect_instances(self): + for instance_data in list_instances(): + creator_id = instance_data.get("creator_identifier") + if creator_id == self.identifier: + instance = CreatedInstance.from_existing( + instance_data, self + ) + self._add_instance_to_context(instance) + + def create(self, subset_name, data, pre_create_data): + # Pass precreate data to creator attributes + data["creator_attributes"] = pre_create_data + data["settings_creator"] = True + # Create new instance + new_instance = CreatedInstance(self.family, subset_name, data, self) + # Host implementation of storing metadata about instance + HostContext.add_instance(new_instance.data_to_store()) + # Add instance to current context + self._add_instance_to_context(new_instance) + + def get_instance_attr_defs(self): + return [ + FileDef( + "filepath", + folders=False, + extensions=self.extensions, + allow_sequences=self.allow_sequences, + label="Filepath", + ) + ] + + @classmethod + def from_settings(cls, item_data): + identifier = item_data["identifier"] + family = item_data["family"] + if not identifier: + identifier = "settings_{}".format(family) + return type( + "{}{}".format(cls.__name__, identifier), + (cls, ), + { + "family": family, + "identifier": identifier, + "label": item_data["label"].strip(), + "icon": item_data["icon"], + "description": item_data["description"], + "detailed_description": item_data["detailed_description"], + "extensions": item_data["extensions"], + "allow_sequences": item_data["allow_sequences"], + "default_variants": item_data["default_variants"] + } + ) diff --git a/openpype/hosts/traypublisher/plugins/create/create_from_settings.py b/openpype/hosts/traypublisher/plugins/create/create_from_settings.py new file mode 100644 index 0000000000..baca274ea6 --- /dev/null +++ b/openpype/hosts/traypublisher/plugins/create/create_from_settings.py @@ -0,0 +1,20 @@ +import os + +from openpype.api import get_project_settings + + +def initialize(): + from openpype.hosts.traypublisher.api.plugin import SettingsCreator + + project_name = os.environ["AVALON_PROJECT"] + project_settings = get_project_settings(project_name) + + simple_creators = project_settings["traypublisher"]["simple_creators"] + + global_variables = globals() + for item in simple_creators: + dynamic_plugin = SettingsCreator.from_settings(item) + global_variables[dynamic_plugin.__name__] = dynamic_plugin + + +initialize() diff --git a/openpype/hosts/traypublisher/plugins/create/create_workfile.py b/openpype/hosts/traypublisher/plugins/create/create_workfile.py deleted file mode 100644 index 5e0af350f0..0000000000 --- a/openpype/hosts/traypublisher/plugins/create/create_workfile.py +++ /dev/null @@ -1,97 +0,0 @@ -from openpype.hosts.traypublisher.api import pipeline -from openpype.lib import FileDef -from openpype.pipeline import ( - Creator, - CreatedInstance -) - - -class WorkfileCreator(Creator): - identifier = "workfile" - label = "Workfile" - family = "workfile" - description = "Publish backup of workfile" - - create_allow_context_change = True - - extensions = [ - # Maya - ".ma", ".mb", - # Nuke - ".nk", - # Hiero - ".hrox", - # Houdini - ".hip", ".hiplc", ".hipnc", - # Blender - ".blend", - # Celaction - ".scn", - # TVPaint - ".tvpp", - # Fusion - ".comp", - # Harmony - ".zip", - # Premiere - ".prproj", - # Resolve - ".drp", - # Photoshop - ".psd", ".psb", - # Aftereffects - ".aep" - ] - - def get_icon(self): - return "fa.file" - - def collect_instances(self): - for instance_data in pipeline.list_instances(): - creator_id = instance_data.get("creator_identifier") - if creator_id == self.identifier: - instance = CreatedInstance.from_existing( - instance_data, self - ) - self._add_instance_to_context(instance) - - def update_instances(self, update_list): - pipeline.update_instances(update_list) - - def remove_instances(self, instances): - pipeline.remove_instances(instances) - for instance in instances: - self._remove_instance_from_context(instance) - - def create(self, subset_name, data, pre_create_data): - # Pass precreate data to creator attributes - data["creator_attributes"] = pre_create_data - # Create new instance - new_instance = CreatedInstance(self.family, subset_name, data, self) - # Host implementation of storing metadata about instance - pipeline.HostContext.add_instance(new_instance.data_to_store()) - # Add instance to current context - self._add_instance_to_context(new_instance) - - def get_default_variants(self): - return [ - "Main" - ] - - def get_instance_attr_defs(self): - output = [ - FileDef( - "filepath", - folders=False, - extensions=self.extensions, - label="Filepath" - ) - ] - return output - - def get_pre_create_attr_defs(self): - # Use same attributes as for instance attrobites - return self.get_instance_attr_defs() - - def get_detail_description(self): - return """# Publish workfile backup""" diff --git a/openpype/hosts/traypublisher/plugins/publish/collect_review_family.py b/openpype/hosts/traypublisher/plugins/publish/collect_review_family.py new file mode 100644 index 0000000000..965e251527 --- /dev/null +++ b/openpype/hosts/traypublisher/plugins/publish/collect_review_family.py @@ -0,0 +1,31 @@ +import pyblish.api +from openpype.lib import BoolDef +from openpype.pipeline import OpenPypePyblishPluginMixin + + +class CollectReviewFamily( + pyblish.api.InstancePlugin, OpenPypePyblishPluginMixin +): + """Add review family.""" + + label = "Collect Review Family" + order = pyblish.api.CollectorOrder - 0.49 + + hosts = ["traypublisher"] + families = [ + "image", + "render", + "plate", + "review" + ] + + def process(self, instance): + values = self.get_attr_values_from_data(instance.data) + if values.get("add_review_family"): + instance.data["families"].append("review") + + @classmethod + def get_attribute_defs(cls): + return [ + BoolDef("add_review_family", label="Review", default=True) + ] diff --git a/openpype/hosts/traypublisher/plugins/publish/collect_simple_instances.py b/openpype/hosts/traypublisher/plugins/publish/collect_simple_instances.py new file mode 100644 index 0000000000..b2be43c701 --- /dev/null +++ b/openpype/hosts/traypublisher/plugins/publish/collect_simple_instances.py @@ -0,0 +1,50 @@ +import os +import pyblish.api + + +class CollectSettingsSimpleInstances(pyblish.api.InstancePlugin): + """Collect data for instances created by settings creators.""" + + label = "Collect Settings Simple Instances" + order = pyblish.api.CollectorOrder - 0.49 + + hosts = ["traypublisher"] + + def process(self, instance): + if not instance.data.get("settings_creator"): + return + + if "families" not in instance.data: + instance.data["families"] = [] + + if "representations" not in instance.data: + instance.data["representations"] = [] + repres = instance.data["representations"] + + creator_attributes = instance.data["creator_attributes"] + filepath_item = creator_attributes["filepath"] + self.log.info(filepath_item) + filepaths = [ + os.path.join(filepath_item["directory"], filename) + for filename in filepath_item["filenames"] + ] + + instance.data["sourceFilepaths"] = filepaths + instance.data["stagingDir"] = filepath_item["directory"] + + filenames = filepath_item["filenames"] + _, ext = os.path.splitext(filenames[0]) + ext = ext[1:] + if len(filenames) == 1: + filenames = filenames[0] + + repres.append({ + "ext": ext, + "name": ext, + "stagingDir": filepath_item["directory"], + "files": filenames + }) + + self.log.debug("Created Simple Settings instance {}".format( + instance.data + )) diff --git a/openpype/hosts/traypublisher/plugins/publish/collect_workfile.py b/openpype/hosts/traypublisher/plugins/publish/collect_workfile.py deleted file mode 100644 index d48bace047..0000000000 --- a/openpype/hosts/traypublisher/plugins/publish/collect_workfile.py +++ /dev/null @@ -1,31 +0,0 @@ -import os -import pyblish.api - - -class CollectWorkfile(pyblish.api.InstancePlugin): - """Collect representation of workfile instances.""" - - label = "Collect Workfile" - order = pyblish.api.CollectorOrder - 0.49 - families = ["workfile"] - hosts = ["traypublisher"] - - def process(self, instance): - if "representations" not in instance.data: - instance.data["representations"] = [] - repres = instance.data["representations"] - - creator_attributes = instance.data["creator_attributes"] - filepath = creator_attributes["filepath"] - instance.data["sourceFilepath"] = filepath - - staging_dir = os.path.dirname(filepath) - filename = os.path.basename(filepath) - ext = os.path.splitext(filename)[-1] - - repres.append({ - "ext": ext, - "name": ext, - "stagingDir": staging_dir, - "files": filename - }) diff --git a/openpype/hosts/traypublisher/plugins/publish/validate_filepaths.py b/openpype/hosts/traypublisher/plugins/publish/validate_filepaths.py new file mode 100644 index 0000000000..c7302b1005 --- /dev/null +++ b/openpype/hosts/traypublisher/plugins/publish/validate_filepaths.py @@ -0,0 +1,47 @@ +import os +import pyblish.api +from openpype.pipeline import PublishValidationError + + +class ValidateWorkfilePath(pyblish.api.InstancePlugin): + """Validate existence of workfile instance existence.""" + + label = "Validate Workfile" + order = pyblish.api.ValidatorOrder - 0.49 + + hosts = ["traypublisher"] + + def process(self, instance): + if "sourceFilepaths" not in instance.data: + self.log.info(( + "Can't validate source filepaths existence." + " Instance does not have collected 'sourceFilepaths'" + )) + return + + filepaths = instance.data.get("sourceFilepaths") + + not_found_files = [ + filepath + for filepath in filepaths + if not os.path.exists(filepath) + ] + if not_found_files: + joined_paths = "\n".join([ + "- {}".format(filepath) + for filepath in not_found_files + ]) + raise PublishValidationError( + ( + "Filepath of '{}' instance \"{}\" does not exist:\n{}" + ).format( + instance.data["family"], + instance.data["name"], + joined_paths + ), + "File not found", + ( + "## Files were not found\nFiles\n{}" + "\n\nCheck if the path is still available." + ).format(joined_paths) + ) diff --git a/openpype/hosts/traypublisher/plugins/publish/validate_workfile.py b/openpype/hosts/traypublisher/plugins/publish/validate_workfile.py deleted file mode 100644 index 7501051669..0000000000 --- a/openpype/hosts/traypublisher/plugins/publish/validate_workfile.py +++ /dev/null @@ -1,35 +0,0 @@ -import os -import pyblish.api -from openpype.pipeline import PublishValidationError - - -class ValidateWorkfilePath(pyblish.api.InstancePlugin): - """Validate existence of workfile instance existence.""" - - label = "Validate Workfile" - order = pyblish.api.ValidatorOrder - 0.49 - families = ["workfile"] - hosts = ["traypublisher"] - - def process(self, instance): - filepath = instance.data["sourceFilepath"] - if not filepath: - raise PublishValidationError( - ( - "Filepath of 'workfile' instance \"{}\" is not set" - ).format(instance.data["name"]), - "File not filled", - "## Missing file\nYou are supposed to fill the path." - ) - - if not os.path.exists(filepath): - raise PublishValidationError( - ( - "Filepath of 'workfile' instance \"{}\" does not exist: {}" - ).format(instance.data["name"], filepath), - "File not found", - ( - "## File was not found\nFile \"{}\" was not found." - " Check if the path is still available." - ).format(filepath) - ) diff --git a/openpype/hosts/tvpaint/api/lib.py b/openpype/hosts/tvpaint/api/lib.py index 0c63dbe5be..a341f48859 100644 --- a/openpype/hosts/tvpaint/api/lib.py +++ b/openpype/hosts/tvpaint/api/lib.py @@ -165,12 +165,12 @@ def parse_group_data(data): if not group_raw: continue - parts = group_raw.split(" ") + parts = group_raw.split("|") # Check for length and concatenate 2 last items until length match # - this happens if name contain spaces while len(parts) > 6: last_item = parts.pop(-1) - parts[-1] = " ".join([parts[-1], last_item]) + parts[-1] = "|".join([parts[-1], last_item]) clip_id, group_id, red, green, blue, name = parts group = { @@ -201,11 +201,16 @@ def get_groups_data(communicator=None): george_script_lines = ( # Variable containing full path to output file "output_path = \"{}\"".format(output_filepath), - "loop = 1", - "FOR idx = 1 TO 12", + "empty = 0", + # Loop over 100 groups + "FOR idx = 1 TO 100", + # Receive information about groups "tv_layercolor \"getcolor\" 0 idx", - "tv_writetextfile \"strict\" \"append\" '\"'output_path'\"' result", - "END" + "PARSE result clip_id group_index c_red c_green c_blue group_name", + # Create and add line to output file + "line = clip_id'|'group_index'|'c_red'|'c_green'|'c_blue'|'group_name", + "tv_writetextfile \"strict\" \"append\" '\"'output_path'\"' line", + "END", ) george_script = "\n".join(george_script_lines) execute_george_through_file(george_script, communicator) diff --git a/openpype/hosts/tvpaint/lib.py b/openpype/hosts/tvpaint/lib.py index 715ebb4a9d..c67ab1e4fb 100644 --- a/openpype/hosts/tvpaint/lib.py +++ b/openpype/hosts/tvpaint/lib.py @@ -573,7 +573,7 @@ def composite_rendered_layers( layer_ids_by_position[layer_position] = layer["layer_id"] # Sort layer positions - sorted_positions = tuple(sorted(layer_ids_by_position.keys())) + sorted_positions = tuple(reversed(sorted(layer_ids_by_position.keys()))) # Prepare variable where filepaths without any rendered content # - transparent will be created transparent_filepaths = set() diff --git a/openpype/hosts/tvpaint/plugins/create/create_render_layer.py b/openpype/hosts/tvpaint/plugins/create/create_render_layer.py index c1af9632b1..3b5bd47189 100644 --- a/openpype/hosts/tvpaint/plugins/create/create_render_layer.py +++ b/openpype/hosts/tvpaint/plugins/create/create_render_layer.py @@ -24,7 +24,9 @@ class CreateRenderlayer(plugin.Creator): " {clip_id} {group_id} {r} {g} {b} \"{name}\"" ) - dynamic_subset_keys = ["render_pass", "render_layer", "group"] + dynamic_subset_keys = [ + "renderpass", "renderlayer", "render_pass", "render_layer", "group" + ] @classmethod def get_dynamic_data( @@ -34,12 +36,17 @@ class CreateRenderlayer(plugin.Creator): variant, task_name, asset_id, project_name, host_name ) # Use render pass name from creator's plugin - dynamic_data["render_pass"] = cls.render_pass + dynamic_data["renderpass"] = cls.render_pass # Add variant to render layer - dynamic_data["render_layer"] = variant + dynamic_data["renderlayer"] = variant # Change family for subset name fill dynamic_data["family"] = "render" + # TODO remove - Backwards compatibility for old subset name templates + # - added 2022/04/28 + dynamic_data["render_pass"] = dynamic_data["renderpass"] + dynamic_data["render_layer"] = dynamic_data["renderlayer"] + return dynamic_data @classmethod diff --git a/openpype/hosts/tvpaint/plugins/create/create_render_pass.py b/openpype/hosts/tvpaint/plugins/create/create_render_pass.py index a7f717ccec..26fa8ac51a 100644 --- a/openpype/hosts/tvpaint/plugins/create/create_render_pass.py +++ b/openpype/hosts/tvpaint/plugins/create/create_render_pass.py @@ -20,7 +20,9 @@ class CreateRenderPass(plugin.Creator): icon = "cube" defaults = ["Main"] - dynamic_subset_keys = ["render_pass", "render_layer"] + dynamic_subset_keys = [ + "renderpass", "renderlayer", "render_pass", "render_layer" + ] @classmethod def get_dynamic_data( @@ -29,9 +31,13 @@ class CreateRenderPass(plugin.Creator): dynamic_data = super(CreateRenderPass, cls).get_dynamic_data( variant, task_name, asset_id, project_name, host_name ) - dynamic_data["render_pass"] = variant + dynamic_data["renderpass"] = variant dynamic_data["family"] = "render" + # TODO remove - Backwards compatibility for old subset name templates + # - added 2022/04/28 + dynamic_data["render_pass"] = dynamic_data["renderpass"] + return dynamic_data @classmethod @@ -115,6 +121,7 @@ class CreateRenderPass(plugin.Creator): else: render_layer = beauty_instance["variant"] + subset_name_fill_data["renderlayer"] = render_layer subset_name_fill_data["render_layer"] = render_layer # Format dynamic keys in subset name @@ -129,7 +136,7 @@ class CreateRenderPass(plugin.Creator): self.data["group_id"] = group_id self.data["pass"] = variant - self.data["render_layer"] = render_layer + self.data["renderlayer"] = render_layer # Collect selected layer ids to be stored into instance layer_names = [layer["name"] for layer in selected_layers] diff --git a/openpype/hosts/tvpaint/plugins/publish/collect_instances.py b/openpype/hosts/tvpaint/plugins/publish/collect_instances.py index 188aa8c41a..782907b65d 100644 --- a/openpype/hosts/tvpaint/plugins/publish/collect_instances.py +++ b/openpype/hosts/tvpaint/plugins/publish/collect_instances.py @@ -45,6 +45,21 @@ class CollectInstances(pyblish.api.ContextPlugin): for instance_data in filtered_instance_data: instance_data["fps"] = context.data["sceneFps"] + # Conversion from older instances + # - change 'render_layer' to 'renderlayer' + render_layer = instance_data.get("instance_data") + if not render_layer: + # Render Layer has only variant + if instance_data["family"] == "renderLayer": + render_layer = instance_data.get("variant") + + # Backwards compatibility for renderPasses + elif "render_layer" in instance_data: + render_layer = instance_data["render_layer"] + + if render_layer: + instance_data["renderlayer"] = render_layer + # Store workfile instance data to instance data instance_data["originData"] = copy.deepcopy(instance_data) # Global instance data modifications @@ -191,7 +206,7 @@ class CollectInstances(pyblish.api.ContextPlugin): "Creating render pass instance. \"{}\"".format(pass_name) ) # Change label - render_layer = instance_data["render_layer"] + render_layer = instance_data["renderlayer"] # Backwards compatibility # - subset names were not stored as final subset names during creation diff --git a/openpype/hosts/tvpaint/plugins/publish/collect_scene_render.py b/openpype/hosts/tvpaint/plugins/publish/collect_scene_render.py index 1c042a62fb..2b8dbdc5b4 100644 --- a/openpype/hosts/tvpaint/plugins/publish/collect_scene_render.py +++ b/openpype/hosts/tvpaint/plugins/publish/collect_scene_render.py @@ -69,9 +69,13 @@ class CollectRenderScene(pyblish.api.ContextPlugin): # Variant is using render pass name variant = self.render_layer dynamic_data = { - "render_layer": self.render_layer, - "render_pass": self.render_pass + "renderlayer": self.render_layer, + "renderpass": self.render_pass, } + # TODO remove - Backwards compatibility for old subset name templates + # - added 2022/04/28 + dynamic_data["render_layer"] = dynamic_data["renderlayer"] + dynamic_data["render_pass"] = dynamic_data["renderpass"] task_name = workfile_context["task"] subset_name = get_subset_name_with_asset_doc( @@ -100,7 +104,9 @@ class CollectRenderScene(pyblish.api.ContextPlugin): "representations": [], "layers": copy.deepcopy(context.data["layersData"]), "asset": asset_name, - "task": task_name + "task": task_name, + # Add render layer to instance data + "renderlayer": self.render_layer } instance = context.create_instance(**instance_data) diff --git a/openpype/hosts/tvpaint/worker/init_file.tvpp b/openpype/hosts/tvpaint/worker/init_file.tvpp index 572d278fdb..22170b45bc 100644 Binary files a/openpype/hosts/tvpaint/worker/init_file.tvpp and b/openpype/hosts/tvpaint/worker/init_file.tvpp differ diff --git a/openpype/hosts/unreal/__init__.py b/openpype/hosts/unreal/__init__.py index 533f315df3..10e9c5100e 100644 --- a/openpype/hosts/unreal/__init__.py +++ b/openpype/hosts/unreal/__init__.py @@ -1,15 +1,19 @@ import os import openpype.hosts +from openpype.lib.applications import Application -def add_implementation_envs(env, _app): +def add_implementation_envs(env: dict, _app: Application) -> None: """Modify environments to contain all required for implementation.""" # Set OPENPYPE_UNREAL_PLUGIN required for Unreal implementation + + ue_plugin = "UE_5.0" if _app.name[:1] == "5" else "UE_4.7" unreal_plugin_path = os.path.join( os.path.dirname(os.path.abspath(openpype.hosts.__file__)), - "unreal", "integration" + "unreal", "integration", ue_plugin ) - env["OPENPYPE_UNREAL_PLUGIN"] = unreal_plugin_path + if not env.get("OPENPYPE_UNREAL_PLUGIN"): + env["OPENPYPE_UNREAL_PLUGIN"] = unreal_plugin_path # Set default environments if are not set via settings defaults = { diff --git a/openpype/hosts/unreal/api/pipeline.py b/openpype/hosts/unreal/api/pipeline.py index f2c264e5a4..bbca7916d3 100644 --- a/openpype/hosts/unreal/api/pipeline.py +++ b/openpype/hosts/unreal/api/pipeline.py @@ -47,6 +47,7 @@ def install(): print("installing OpenPype for Unreal ...") print("-=" * 40) logger.info("installing OpenPype for Unreal") + pyblish.api.register_host("unreal") pyblish.api.register_plugin_path(str(PUBLISH_PATH)) register_loader_plugin_path(str(LOAD_PATH)) register_creator_plugin_path(str(CREATE_PATH)) @@ -392,3 +393,24 @@ def cast_map_to_str_dict(umap) -> dict: """ return {str(key): str(value) for (key, value) in umap.items()} + + +def get_subsequences(sequence: unreal.LevelSequence): + """Get list of subsequences from sequence. + + Args: + sequence (unreal.LevelSequence): Sequence + + Returns: + list(unreal.LevelSequence): List of subsequences + + """ + tracks = sequence.get_master_tracks() + subscene_track = None + for t in tracks: + if t.get_class() == unreal.MovieSceneSubTrack.static_class(): + subscene_track = t + break + if subscene_track is not None and subscene_track.get_sections(): + return subscene_track.get_sections() + return [] diff --git a/openpype/hosts/unreal/api/rendering.py b/openpype/hosts/unreal/api/rendering.py new file mode 100644 index 0000000000..b2732506fc --- /dev/null +++ b/openpype/hosts/unreal/api/rendering.py @@ -0,0 +1,137 @@ +import os + +import unreal + +from openpype.api import Anatomy +from openpype.hosts.unreal.api import pipeline + + +queue = None +executor = None + + +def _queue_finish_callback(exec, success): + unreal.log("Render completed. Success: " + str(success)) + + # Delete our reference so we don't keep it alive. + global executor + global queue + del executor + del queue + + +def _job_finish_callback(job, success): + # You can make any edits you want to the editor world here, and the world + # will be duplicated when the next render happens. Make sure you undo your + # edits in OnQueueFinishedCallback if you don't want to leak state changes + # into the editor world. + unreal.log("Individual job completed.") + + +def start_rendering(): + """ + Start the rendering process. + """ + print("Starting rendering...") + + # Get selected sequences + assets = unreal.EditorUtilityLibrary.get_selected_assets() + + # instances = pipeline.ls_inst() + instances = [ + a for a in assets + if a.get_class().get_name() == "OpenPypePublishInstance"] + + inst_data = [] + + for i in instances: + data = pipeline.parse_container(i.get_path_name()) + if data["family"] == "render": + inst_data.append(data) + + try: + project = os.environ.get("AVALON_PROJECT") + anatomy = Anatomy(project) + root = anatomy.roots['renders'] + except Exception: + raise Exception("Could not find render root in anatomy settings.") + + render_dir = f"{root}/{project}" + + # subsystem = unreal.get_editor_subsystem( + # unreal.MoviePipelineQueueSubsystem) + # queue = subsystem.get_queue() + global queue + queue = unreal.MoviePipelineQueue() + + ar = unreal.AssetRegistryHelpers.get_asset_registry() + + for i in inst_data: + sequence = ar.get_asset_by_object_path(i["sequence"]).get_asset() + + sequences = [{ + "sequence": sequence, + "output": f"{i['output']}", + "frame_range": ( + int(float(i["frameStart"])), + int(float(i["frameEnd"])) + 1) + }] + render_list = [] + + # Get all the sequences to render. If there are subsequences, + # add them and their frame ranges to the render list. We also + # use the names for the output paths. + for s in sequences: + subscenes = pipeline.get_subsequences(s.get('sequence')) + + if subscenes: + for ss in subscenes: + sequences.append({ + "sequence": ss.get_sequence(), + "output": (f"{s.get('output')}/" + f"{ss.get_sequence().get_name()}"), + "frame_range": ( + ss.get_start_frame(), ss.get_end_frame()) + }) + else: + # Avoid rendering camera sequences + if "_camera" not in s.get('sequence').get_name(): + render_list.append(s) + + # Create the rendering jobs and add them to the queue. + for r in render_list: + job = queue.allocate_new_job(unreal.MoviePipelineExecutorJob) + job.sequence = unreal.SoftObjectPath(i["master_sequence"]) + job.map = unreal.SoftObjectPath(i["master_level"]) + job.author = "OpenPype" + + # User data could be used to pass data to the job, that can be + # read in the job's OnJobFinished callback. We could, + # for instance, pass the AvalonPublishInstance's path to the job. + # job.user_data = "" + + settings = job.get_configuration().find_or_add_setting_by_class( + unreal.MoviePipelineOutputSetting) + settings.output_resolution = unreal.IntPoint(1920, 1080) + settings.custom_start_frame = r.get("frame_range")[0] + settings.custom_end_frame = r.get("frame_range")[1] + settings.use_custom_playback_range = True + settings.file_name_format = "{sequence_name}.{frame_number}" + settings.output_directory.path = f"{render_dir}/{r.get('output')}" + + renderPass = job.get_configuration().find_or_add_setting_by_class( + unreal.MoviePipelineDeferredPassBase) + renderPass.disable_multisample_effects = True + + job.get_configuration().find_or_add_setting_by_class( + unreal.MoviePipelineImageSequenceOutput_PNG) + + # If there are jobs in the queue, start the rendering process. + if queue.get_jobs(): + global executor + executor = unreal.MoviePipelinePIEExecutor() + executor.on_executor_finished_delegate.add_callable_unique( + _queue_finish_callback) + executor.on_individual_job_finished_delegate.add_callable_unique( + _job_finish_callback) # Only available on PIE Executor + executor.execute(queue) diff --git a/openpype/hosts/unreal/api/tools_ui.py b/openpype/hosts/unreal/api/tools_ui.py index 93361c3574..2500f8495f 100644 --- a/openpype/hosts/unreal/api/tools_ui.py +++ b/openpype/hosts/unreal/api/tools_ui.py @@ -7,6 +7,7 @@ from openpype import ( ) from openpype.tools.utils import host_tools from openpype.tools.utils.lib import qt_app_context +from openpype.hosts.unreal.api import rendering class ToolsBtnsWidget(QtWidgets.QWidget): @@ -20,6 +21,7 @@ class ToolsBtnsWidget(QtWidgets.QWidget): load_btn = QtWidgets.QPushButton("Load...", self) publish_btn = QtWidgets.QPushButton("Publish...", self) manage_btn = QtWidgets.QPushButton("Manage...", self) + render_btn = QtWidgets.QPushButton("Render...", self) experimental_tools_btn = QtWidgets.QPushButton( "Experimental tools...", self ) @@ -30,6 +32,7 @@ class ToolsBtnsWidget(QtWidgets.QWidget): layout.addWidget(load_btn, 0) layout.addWidget(publish_btn, 0) layout.addWidget(manage_btn, 0) + layout.addWidget(render_btn, 0) layout.addWidget(experimental_tools_btn, 0) layout.addStretch(1) @@ -37,6 +40,7 @@ class ToolsBtnsWidget(QtWidgets.QWidget): load_btn.clicked.connect(self._on_load) publish_btn.clicked.connect(self._on_publish) manage_btn.clicked.connect(self._on_manage) + render_btn.clicked.connect(self._on_render) experimental_tools_btn.clicked.connect(self._on_experimental) def _on_create(self): @@ -51,6 +55,9 @@ class ToolsBtnsWidget(QtWidgets.QWidget): def _on_manage(self): self.tool_required.emit("sceneinventory") + def _on_render(self): + rendering.start_rendering() + def _on_experimental(self): self.tool_required.emit("experimental_tools") diff --git a/openpype/hosts/unreal/hooks/pre_workfile_preparation.py b/openpype/hosts/unreal/hooks/pre_workfile_preparation.py index f07e96551c..5be04fc841 100644 --- a/openpype/hosts/unreal/hooks/pre_workfile_preparation.py +++ b/openpype/hosts/unreal/hooks/pre_workfile_preparation.py @@ -25,7 +25,7 @@ class UnrealPrelaunchHook(PreLaunchHook): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) - self.signature = "( {} )".format(self.__class__.__name__) + self.signature = f"( {self.__class__.__name__} )" def _get_work_filename(self): # Use last workfile if was found @@ -71,7 +71,7 @@ class UnrealPrelaunchHook(PreLaunchHook): if int(engine_version.split(".")[0]) < 4 and \ int(engine_version.split(".")[1]) < 26: raise ApplicationLaunchFailed(( - f"{self.signature} Old unsupported version of UE4 " + f"{self.signature} Old unsupported version of UE " f"detected - {engine_version}")) except ValueError: # there can be string in minor version and in that case @@ -99,18 +99,19 @@ class UnrealPrelaunchHook(PreLaunchHook): f"character ({unreal_project_name}). Appending 'P'" )) unreal_project_name = f"P{unreal_project_name}" + unreal_project_filename = f'{unreal_project_name}.uproject' project_path = Path(os.path.join(workdir, unreal_project_name)) self.log.info(( - f"{self.signature} requested UE4 version: " + f"{self.signature} requested UE version: " f"[ {engine_version} ]" )) detected = unreal_lib.get_engine_versions(self.launch_context.env) detected_str = ', '.join(detected.keys()) or 'none' self.log.info(( - f"{self.signature} detected UE4 versions: " + f"{self.signature} detected UE versions: " f"[ {detected_str} ]" )) if not detected: @@ -123,10 +124,10 @@ class UnrealPrelaunchHook(PreLaunchHook): f"detected [ {engine_version} ]" )) - ue4_path = unreal_lib.get_editor_executable_path( - Path(detected[engine_version])) + ue_path = unreal_lib.get_editor_executable_path( + Path(detected[engine_version]), engine_version) - self.launch_context.launch_args = [ue4_path.as_posix()] + self.launch_context.launch_args = [ue_path.as_posix()] project_path.mkdir(parents=True, exist_ok=True) project_file = project_path / unreal_project_filename @@ -138,6 +139,11 @@ class UnrealPrelaunchHook(PreLaunchHook): )) # Set "OPENPYPE_UNREAL_PLUGIN" to current process environment for # execution of `create_unreal_project` + if self.launch_context.env.get("OPENPYPE_UNREAL_PLUGIN"): + self.log.info(( + f"{self.signature} using OpenPype plugin from " + f"{self.launch_context.env.get('OPENPYPE_UNREAL_PLUGIN')}" + )) env_key = "OPENPYPE_UNREAL_PLUGIN" if self.launch_context.env.get(env_key): os.environ[env_key] = self.launch_context.env[env_key] diff --git a/openpype/hosts/unreal/integration/.gitignore b/openpype/hosts/unreal/integration/UE_4.7/.gitignore similarity index 100% rename from openpype/hosts/unreal/integration/.gitignore rename to openpype/hosts/unreal/integration/UE_4.7/.gitignore diff --git a/openpype/hosts/unreal/integration/Content/Python/init_unreal.py b/openpype/hosts/unreal/integration/UE_4.7/Content/Python/init_unreal.py similarity index 100% rename from openpype/hosts/unreal/integration/Content/Python/init_unreal.py rename to openpype/hosts/unreal/integration/UE_4.7/Content/Python/init_unreal.py diff --git a/openpype/hosts/unreal/integration/OpenPype.uplugin b/openpype/hosts/unreal/integration/UE_4.7/OpenPype.uplugin similarity index 100% rename from openpype/hosts/unreal/integration/OpenPype.uplugin rename to openpype/hosts/unreal/integration/UE_4.7/OpenPype.uplugin diff --git a/openpype/hosts/unreal/integration/README.md b/openpype/hosts/unreal/integration/UE_4.7/README.md similarity index 91% rename from openpype/hosts/unreal/integration/README.md rename to openpype/hosts/unreal/integration/UE_4.7/README.md index a32d89aab8..a08c1ada39 100644 --- a/openpype/hosts/unreal/integration/README.md +++ b/openpype/hosts/unreal/integration/UE_4.7/README.md @@ -1,4 +1,4 @@ -# OpenPype Unreal Integration plugin +# OpenPype Unreal Integration plugin - UE 4.x This is plugin for Unreal Editor, creating menu for [OpenPype](https://github.com/getavalon) tools to run. diff --git a/openpype/hosts/unreal/integration/Resources/openpype128.png b/openpype/hosts/unreal/integration/UE_4.7/Resources/openpype128.png similarity index 100% rename from openpype/hosts/unreal/integration/Resources/openpype128.png rename to openpype/hosts/unreal/integration/UE_4.7/Resources/openpype128.png diff --git a/openpype/hosts/unreal/integration/Resources/openpype40.png b/openpype/hosts/unreal/integration/UE_4.7/Resources/openpype40.png similarity index 100% rename from openpype/hosts/unreal/integration/Resources/openpype40.png rename to openpype/hosts/unreal/integration/UE_4.7/Resources/openpype40.png diff --git a/openpype/hosts/unreal/integration/Resources/openpype512.png b/openpype/hosts/unreal/integration/UE_4.7/Resources/openpype512.png similarity index 100% rename from openpype/hosts/unreal/integration/Resources/openpype512.png rename to openpype/hosts/unreal/integration/UE_4.7/Resources/openpype512.png diff --git a/openpype/hosts/unreal/integration/Source/OpenPype/OpenPype.Build.cs b/openpype/hosts/unreal/integration/UE_4.7/Source/OpenPype/OpenPype.Build.cs similarity index 100% rename from openpype/hosts/unreal/integration/Source/OpenPype/OpenPype.Build.cs rename to openpype/hosts/unreal/integration/UE_4.7/Source/OpenPype/OpenPype.Build.cs diff --git a/openpype/hosts/unreal/integration/Source/OpenPype/Private/AssetContainer.cpp b/openpype/hosts/unreal/integration/UE_4.7/Source/OpenPype/Private/AssetContainer.cpp similarity index 100% rename from openpype/hosts/unreal/integration/Source/OpenPype/Private/AssetContainer.cpp rename to openpype/hosts/unreal/integration/UE_4.7/Source/OpenPype/Private/AssetContainer.cpp diff --git a/openpype/hosts/unreal/integration/Source/OpenPype/Private/AssetContainerFactory.cpp b/openpype/hosts/unreal/integration/UE_4.7/Source/OpenPype/Private/AssetContainerFactory.cpp similarity index 100% rename from openpype/hosts/unreal/integration/Source/OpenPype/Private/AssetContainerFactory.cpp rename to openpype/hosts/unreal/integration/UE_4.7/Source/OpenPype/Private/AssetContainerFactory.cpp diff --git a/openpype/hosts/unreal/integration/Source/OpenPype/Private/OpenPype.cpp b/openpype/hosts/unreal/integration/UE_4.7/Source/OpenPype/Private/OpenPype.cpp similarity index 100% rename from openpype/hosts/unreal/integration/Source/OpenPype/Private/OpenPype.cpp rename to openpype/hosts/unreal/integration/UE_4.7/Source/OpenPype/Private/OpenPype.cpp diff --git a/openpype/hosts/unreal/integration/Source/OpenPype/Private/OpenPypeLib.cpp b/openpype/hosts/unreal/integration/UE_4.7/Source/OpenPype/Private/OpenPypeLib.cpp similarity index 100% rename from openpype/hosts/unreal/integration/Source/OpenPype/Private/OpenPypeLib.cpp rename to openpype/hosts/unreal/integration/UE_4.7/Source/OpenPype/Private/OpenPypeLib.cpp diff --git a/openpype/hosts/unreal/integration/Source/OpenPype/Private/OpenPypePublishInstance.cpp b/openpype/hosts/unreal/integration/UE_4.7/Source/OpenPype/Private/OpenPypePublishInstance.cpp similarity index 100% rename from openpype/hosts/unreal/integration/Source/OpenPype/Private/OpenPypePublishInstance.cpp rename to openpype/hosts/unreal/integration/UE_4.7/Source/OpenPype/Private/OpenPypePublishInstance.cpp diff --git a/openpype/hosts/unreal/integration/Source/OpenPype/Private/OpenPypePublishInstanceFactory.cpp b/openpype/hosts/unreal/integration/UE_4.7/Source/OpenPype/Private/OpenPypePublishInstanceFactory.cpp similarity index 100% rename from openpype/hosts/unreal/integration/Source/OpenPype/Private/OpenPypePublishInstanceFactory.cpp rename to openpype/hosts/unreal/integration/UE_4.7/Source/OpenPype/Private/OpenPypePublishInstanceFactory.cpp diff --git a/openpype/hosts/unreal/integration/Source/OpenPype/Private/OpenPypePythonBridge.cpp b/openpype/hosts/unreal/integration/UE_4.7/Source/OpenPype/Private/OpenPypePythonBridge.cpp similarity index 100% rename from openpype/hosts/unreal/integration/Source/OpenPype/Private/OpenPypePythonBridge.cpp rename to openpype/hosts/unreal/integration/UE_4.7/Source/OpenPype/Private/OpenPypePythonBridge.cpp diff --git a/openpype/hosts/unreal/integration/Source/OpenPype/Private/OpenPypeStyle.cpp b/openpype/hosts/unreal/integration/UE_4.7/Source/OpenPype/Private/OpenPypeStyle.cpp similarity index 100% rename from openpype/hosts/unreal/integration/Source/OpenPype/Private/OpenPypeStyle.cpp rename to openpype/hosts/unreal/integration/UE_4.7/Source/OpenPype/Private/OpenPypeStyle.cpp diff --git a/openpype/hosts/unreal/integration/Source/OpenPype/Public/AssetContainer.h b/openpype/hosts/unreal/integration/UE_4.7/Source/OpenPype/Public/AssetContainer.h similarity index 100% rename from openpype/hosts/unreal/integration/Source/OpenPype/Public/AssetContainer.h rename to openpype/hosts/unreal/integration/UE_4.7/Source/OpenPype/Public/AssetContainer.h diff --git a/openpype/hosts/unreal/integration/Source/OpenPype/Public/AssetContainerFactory.h b/openpype/hosts/unreal/integration/UE_4.7/Source/OpenPype/Public/AssetContainerFactory.h similarity index 100% rename from openpype/hosts/unreal/integration/Source/OpenPype/Public/AssetContainerFactory.h rename to openpype/hosts/unreal/integration/UE_4.7/Source/OpenPype/Public/AssetContainerFactory.h diff --git a/openpype/hosts/unreal/integration/Source/OpenPype/Public/OpenPype.h b/openpype/hosts/unreal/integration/UE_4.7/Source/OpenPype/Public/OpenPype.h similarity index 100% rename from openpype/hosts/unreal/integration/Source/OpenPype/Public/OpenPype.h rename to openpype/hosts/unreal/integration/UE_4.7/Source/OpenPype/Public/OpenPype.h diff --git a/openpype/hosts/unreal/integration/Source/OpenPype/Public/OpenPypeLib.h b/openpype/hosts/unreal/integration/UE_4.7/Source/OpenPype/Public/OpenPypeLib.h similarity index 100% rename from openpype/hosts/unreal/integration/Source/OpenPype/Public/OpenPypeLib.h rename to openpype/hosts/unreal/integration/UE_4.7/Source/OpenPype/Public/OpenPypeLib.h diff --git a/openpype/hosts/unreal/integration/Source/OpenPype/Public/OpenPypePublishInstance.h b/openpype/hosts/unreal/integration/UE_4.7/Source/OpenPype/Public/OpenPypePublishInstance.h similarity index 100% rename from openpype/hosts/unreal/integration/Source/OpenPype/Public/OpenPypePublishInstance.h rename to openpype/hosts/unreal/integration/UE_4.7/Source/OpenPype/Public/OpenPypePublishInstance.h diff --git a/openpype/hosts/unreal/integration/Source/OpenPype/Public/OpenPypePublishInstanceFactory.h b/openpype/hosts/unreal/integration/UE_4.7/Source/OpenPype/Public/OpenPypePublishInstanceFactory.h similarity index 100% rename from openpype/hosts/unreal/integration/Source/OpenPype/Public/OpenPypePublishInstanceFactory.h rename to openpype/hosts/unreal/integration/UE_4.7/Source/OpenPype/Public/OpenPypePublishInstanceFactory.h diff --git a/openpype/hosts/unreal/integration/Source/OpenPype/Public/OpenPypePythonBridge.h b/openpype/hosts/unreal/integration/UE_4.7/Source/OpenPype/Public/OpenPypePythonBridge.h similarity index 100% rename from openpype/hosts/unreal/integration/Source/OpenPype/Public/OpenPypePythonBridge.h rename to openpype/hosts/unreal/integration/UE_4.7/Source/OpenPype/Public/OpenPypePythonBridge.h diff --git a/openpype/hosts/unreal/integration/Source/OpenPype/Public/OpenPypeStyle.h b/openpype/hosts/unreal/integration/UE_4.7/Source/OpenPype/Public/OpenPypeStyle.h similarity index 100% rename from openpype/hosts/unreal/integration/Source/OpenPype/Public/OpenPypeStyle.h rename to openpype/hosts/unreal/integration/UE_4.7/Source/OpenPype/Public/OpenPypeStyle.h diff --git a/openpype/hosts/unreal/integration/UE_5.0/.gitignore b/openpype/hosts/unreal/integration/UE_5.0/.gitignore new file mode 100644 index 0000000000..b32a6f55e5 --- /dev/null +++ b/openpype/hosts/unreal/integration/UE_5.0/.gitignore @@ -0,0 +1,35 @@ +# Prerequisites +*.d + +# Compiled Object files +*.slo +*.lo +*.o +*.obj + +# Precompiled Headers +*.gch +*.pch + +# Compiled Dynamic libraries +*.so +*.dylib +*.dll + +# Fortran module files +*.mod +*.smod + +# Compiled Static libraries +*.lai +*.la +*.a +*.lib + +# Executables +*.exe +*.out +*.app + +/Binaries +/Intermediate diff --git a/openpype/hosts/unreal/integration/UE_5.0/Content/Python/__init__.py b/openpype/hosts/unreal/integration/UE_5.0/Content/Python/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/openpype/hosts/unreal/integration/UE_5.0/Content/Python/init_unreal.py b/openpype/hosts/unreal/integration/UE_5.0/Content/Python/init_unreal.py new file mode 100644 index 0000000000..4bb03b07ed --- /dev/null +++ b/openpype/hosts/unreal/integration/UE_5.0/Content/Python/init_unreal.py @@ -0,0 +1,28 @@ +import unreal + +openpype_detected = True +try: + from openpype.pipeline import install_host + from openpype.hosts.unreal import api as openpype_host +except ImportError as exc: + openpype_host = None + openpype_detected = False + unreal.log_error("OpenPype: cannot load OpenPype [ {} ]".format(exc)) + +if openpype_detected: + install_host(openpype_host) + + +@unreal.uclass() +class OpenPypeIntegration(unreal.OpenPypePythonBridge): + @unreal.ufunction(override=True) + def RunInPython_Popup(self): + unreal.log_warning("OpenPype: showing tools popup") + if openpype_detected: + openpype_host.show_tools_popup() + + @unreal.ufunction(override=True) + def RunInPython_Dialog(self): + unreal.log_warning("OpenPype: showing tools dialog") + if openpype_detected: + openpype_host.show_tools_dialog() diff --git a/openpype/hosts/unreal/integration/UE_5.0/Content/__init__.py b/openpype/hosts/unreal/integration/UE_5.0/Content/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/openpype/hosts/unreal/integration/UE_5.0/OpenPype.uplugin b/openpype/hosts/unreal/integration/UE_5.0/OpenPype.uplugin new file mode 100644 index 0000000000..4c7a74403c --- /dev/null +++ b/openpype/hosts/unreal/integration/UE_5.0/OpenPype.uplugin @@ -0,0 +1,24 @@ +{ + "FileVersion": 3, + "Version": 1, + "VersionName": "1.0", + "FriendlyName": "OpenPype", + "Description": "OpenPype Integration", + "Category": "OpenPype.Integration", + "CreatedBy": "Ondrej Samohel", + "CreatedByURL": "https://openpype.io", + "DocsURL": "https://openpype.io/docs/artist_hosts_unreal", + "MarketplaceURL": "", + "SupportURL": "https://pype.club/", + "CanContainContent": true, + "IsBetaVersion": true, + "IsExperimentalVersion": false, + "Installed": false, + "Modules": [ + { + "Name": "OpenPype", + "Type": "Editor", + "LoadingPhase": "Default" + } + ] +} \ No newline at end of file diff --git a/openpype/hosts/unreal/integration/UE_5.0/README.md b/openpype/hosts/unreal/integration/UE_5.0/README.md new file mode 100644 index 0000000000..cf0aa622c2 --- /dev/null +++ b/openpype/hosts/unreal/integration/UE_5.0/README.md @@ -0,0 +1,11 @@ +# OpenPype Unreal Integration plugin - UE 5.x + +This is plugin for Unreal Editor, creating menu for [OpenPype](https://github.com/getavalon) tools to run. + +## How does this work + +Plugin is creating basic menu items in **Window/OpenPype** section of Unreal Editor main menu and a button +on the main toolbar with associated menu. Clicking on those menu items is calling callbacks that are +declared in C++ but needs to be implemented during Unreal Editor +startup in `Plugins/OpenPype/Content/Python/init_unreal.py` - this should be executed by Unreal Editor +automatically. diff --git a/openpype/hosts/unreal/integration/UE_5.0/Resources/openpype128.png b/openpype/hosts/unreal/integration/UE_5.0/Resources/openpype128.png new file mode 100644 index 0000000000..abe8a807ef Binary files /dev/null and b/openpype/hosts/unreal/integration/UE_5.0/Resources/openpype128.png differ diff --git a/openpype/hosts/unreal/integration/UE_5.0/Resources/openpype40.png b/openpype/hosts/unreal/integration/UE_5.0/Resources/openpype40.png new file mode 100644 index 0000000000..f983e7a1f2 Binary files /dev/null and b/openpype/hosts/unreal/integration/UE_5.0/Resources/openpype40.png differ diff --git a/openpype/hosts/unreal/integration/UE_5.0/Resources/openpype512.png b/openpype/hosts/unreal/integration/UE_5.0/Resources/openpype512.png new file mode 100644 index 0000000000..97c4d4326b Binary files /dev/null and b/openpype/hosts/unreal/integration/UE_5.0/Resources/openpype512.png differ diff --git a/openpype/hosts/unreal/integration/UE_5.0/Source/OpenPype/OpenPype.Build.cs b/openpype/hosts/unreal/integration/UE_5.0/Source/OpenPype/OpenPype.Build.cs new file mode 100644 index 0000000000..fcfd268234 --- /dev/null +++ b/openpype/hosts/unreal/integration/UE_5.0/Source/OpenPype/OpenPype.Build.cs @@ -0,0 +1,59 @@ +// Copyright 1998-2019 Epic Games, Inc. All Rights Reserved. + +using UnrealBuildTool; + +public class OpenPype : ModuleRules +{ + public OpenPype(ReadOnlyTargetRules Target) : base(Target) + { + PCHUsage = ModuleRules.PCHUsageMode.UseExplicitOrSharedPCHs; + + PublicIncludePaths.AddRange( + new string[] { + // ... add public include paths required here ... + } + ); + + + PrivateIncludePaths.AddRange( + new string[] { + // ... add other private include paths required here ... + } + ); + + + PublicDependencyModuleNames.AddRange( + new string[] + { + "Core", + // ... add other public dependencies that you statically link with here ... + } + ); + + + PrivateDependencyModuleNames.AddRange( + new string[] + { + "Projects", + "InputCore", + "EditorFramework", + "UnrealEd", + "ToolMenus", + "LevelEditor", + "CoreUObject", + "Engine", + "Slate", + "SlateCore", + // ... add private dependencies that you statically link with here ... + } + ); + + + DynamicallyLoadedModuleNames.AddRange( + new string[] + { + // ... add any modules that your module loads dynamically here ... + } + ); + } +} diff --git a/openpype/hosts/unreal/integration/UE_5.0/Source/OpenPype/Private/AssetContainer.cpp b/openpype/hosts/unreal/integration/UE_5.0/Source/OpenPype/Private/AssetContainer.cpp new file mode 100644 index 0000000000..c766f87a8e --- /dev/null +++ b/openpype/hosts/unreal/integration/UE_5.0/Source/OpenPype/Private/AssetContainer.cpp @@ -0,0 +1,115 @@ +// Fill out your copyright notice in the Description page of Project Settings. + +#include "AssetContainer.h" +#include "AssetRegistryModule.h" +#include "Misc/PackageName.h" +#include "Engine.h" +#include "Containers/UnrealString.h" + +UAssetContainer::UAssetContainer(const FObjectInitializer& ObjectInitializer) +: UAssetUserData(ObjectInitializer) +{ + FAssetRegistryModule& AssetRegistryModule = FModuleManager::LoadModuleChecked("AssetRegistry"); + FString path = UAssetContainer::GetPathName(); + UE_LOG(LogTemp, Warning, TEXT("UAssetContainer %s"), *path); + FARFilter Filter; + Filter.PackagePaths.Add(FName(*path)); + + AssetRegistryModule.Get().OnAssetAdded().AddUObject(this, &UAssetContainer::OnAssetAdded); + AssetRegistryModule.Get().OnAssetRemoved().AddUObject(this, &UAssetContainer::OnAssetRemoved); + AssetRegistryModule.Get().OnAssetRenamed().AddUObject(this, &UAssetContainer::OnAssetRenamed); +} + +void UAssetContainer::OnAssetAdded(const FAssetData& AssetData) +{ + TArray split; + + // get directory of current container + FString selfFullPath = UAssetContainer::GetPathName(); + FString selfDir = FPackageName::GetLongPackagePath(*selfFullPath); + + // get asset path and class + FString assetPath = AssetData.GetFullName(); + FString assetFName = AssetData.AssetClass.ToString(); + + // split path + assetPath.ParseIntoArray(split, TEXT(" "), true); + + FString assetDir = FPackageName::GetLongPackagePath(*split[1]); + + // take interest only in paths starting with path of current container + if (assetDir.StartsWith(*selfDir)) + { + // exclude self + if (assetFName != "AssetContainer") + { + assets.Add(assetPath); + assetsData.Add(AssetData); + UE_LOG(LogTemp, Log, TEXT("%s: asset added to %s"), *selfFullPath, *selfDir); + } + } +} + +void UAssetContainer::OnAssetRemoved(const FAssetData& AssetData) +{ + TArray split; + + // get directory of current container + FString selfFullPath = UAssetContainer::GetPathName(); + FString selfDir = FPackageName::GetLongPackagePath(*selfFullPath); + + // get asset path and class + FString assetPath = AssetData.GetFullName(); + FString assetFName = AssetData.AssetClass.ToString(); + + // split path + assetPath.ParseIntoArray(split, TEXT(" "), true); + + FString assetDir = FPackageName::GetLongPackagePath(*split[1]); + + // take interest only in paths starting with path of current container + FString path = UAssetContainer::GetPathName(); + FString lpp = FPackageName::GetLongPackagePath(*path); + + if (assetDir.StartsWith(*selfDir)) + { + // exclude self + if (assetFName != "AssetContainer") + { + // UE_LOG(LogTemp, Warning, TEXT("%s: asset removed"), *lpp); + assets.Remove(assetPath); + assetsData.Remove(AssetData); + } + } +} + +void UAssetContainer::OnAssetRenamed(const FAssetData& AssetData, const FString& str) +{ + TArray split; + + // get directory of current container + FString selfFullPath = UAssetContainer::GetPathName(); + FString selfDir = FPackageName::GetLongPackagePath(*selfFullPath); + + // get asset path and class + FString assetPath = AssetData.GetFullName(); + FString assetFName = AssetData.AssetClass.ToString(); + + // split path + assetPath.ParseIntoArray(split, TEXT(" "), true); + + FString assetDir = FPackageName::GetLongPackagePath(*split[1]); + if (assetDir.StartsWith(*selfDir)) + { + // exclude self + if (assetFName != "AssetContainer") + { + + assets.Remove(str); + assets.Add(assetPath); + assetsData.Remove(AssetData); + // UE_LOG(LogTemp, Warning, TEXT("%s: asset renamed %s"), *lpp, *str); + } + } +} + diff --git a/openpype/hosts/unreal/integration/UE_5.0/Source/OpenPype/Private/AssetContainerFactory.cpp b/openpype/hosts/unreal/integration/UE_5.0/Source/OpenPype/Private/AssetContainerFactory.cpp new file mode 100644 index 0000000000..b943150bdd --- /dev/null +++ b/openpype/hosts/unreal/integration/UE_5.0/Source/OpenPype/Private/AssetContainerFactory.cpp @@ -0,0 +1,20 @@ +#include "AssetContainerFactory.h" +#include "AssetContainer.h" + +UAssetContainerFactory::UAssetContainerFactory(const FObjectInitializer& ObjectInitializer) + : UFactory(ObjectInitializer) +{ + SupportedClass = UAssetContainer::StaticClass(); + bCreateNew = false; + bEditorImport = true; +} + +UObject* UAssetContainerFactory::FactoryCreateNew(UClass* Class, UObject* InParent, FName Name, EObjectFlags Flags, UObject* Context, FFeedbackContext* Warn) +{ + UAssetContainer* AssetContainer = NewObject(InParent, Class, Name, Flags); + return AssetContainer; +} + +bool UAssetContainerFactory::ShouldShowInNewMenu() const { + return false; +} diff --git a/openpype/hosts/unreal/integration/UE_5.0/Source/OpenPype/Private/OpenPype.cpp b/openpype/hosts/unreal/integration/UE_5.0/Source/OpenPype/Private/OpenPype.cpp new file mode 100644 index 0000000000..b3bd9a81b3 --- /dev/null +++ b/openpype/hosts/unreal/integration/UE_5.0/Source/OpenPype/Private/OpenPype.cpp @@ -0,0 +1,85 @@ +#include "OpenPype.h" +#include "OpenPypeStyle.h" +#include "OpenPypeCommands.h" +#include "OpenPypePythonBridge.h" +#include "LevelEditor.h" +#include "Misc/MessageDialog.h" +#include "ToolMenus.h" + + +static const FName OpenPypeTabName("OpenPype"); + +#define LOCTEXT_NAMESPACE "FOpenPypeModule" + +// This function is triggered when the plugin is staring up +void FOpenPypeModule::StartupModule() +{ + FOpenPypeStyle::Initialize(); + FOpenPypeStyle::ReloadTextures(); + FOpenPypeCommands::Register(); + + PluginCommands = MakeShareable(new FUICommandList); + + PluginCommands->MapAction( + FOpenPypeCommands::Get().OpenPypeTools, + FExecuteAction::CreateRaw(this, &FOpenPypeModule::MenuPopup), + FCanExecuteAction()); + PluginCommands->MapAction( + FOpenPypeCommands::Get().OpenPypeToolsDialog, + FExecuteAction::CreateRaw(this, &FOpenPypeModule::MenuDialog), + FCanExecuteAction()); + + UToolMenus::RegisterStartupCallback(FSimpleMulticastDelegate::FDelegate::CreateRaw(this, &FOpenPypeModule::RegisterMenus)); +} + +void FOpenPypeModule::ShutdownModule() +{ + UToolMenus::UnRegisterStartupCallback(this); + + UToolMenus::UnregisterOwner(this); + + FOpenPypeStyle::Shutdown(); + + FOpenPypeCommands::Unregister(); +} + +void FOpenPypeModule::RegisterMenus() +{ + // Owner will be used for cleanup in call to UToolMenus::UnregisterOwner + FToolMenuOwnerScoped OwnerScoped(this); + + { + UToolMenu* Menu = UToolMenus::Get()->ExtendMenu("LevelEditor.MainMenu.Tools"); + { + // FToolMenuSection& Section = Menu->FindOrAddSection("OpenPype"); + FToolMenuSection& Section = Menu->AddSection( + "OpenPype", + TAttribute(FText::FromString("OpenPype")), + FToolMenuInsert("Programming", EToolMenuInsertType::Before) + ); + Section.AddMenuEntryWithCommandList(FOpenPypeCommands::Get().OpenPypeTools, PluginCommands); + Section.AddMenuEntryWithCommandList(FOpenPypeCommands::Get().OpenPypeToolsDialog, PluginCommands); + } + UToolMenu* ToolbarMenu = UToolMenus::Get()->ExtendMenu("LevelEditor.LevelEditorToolBar.PlayToolBar"); + { + FToolMenuSection& Section = ToolbarMenu->FindOrAddSection("PluginTools"); + { + FToolMenuEntry& Entry = Section.AddEntry(FToolMenuEntry::InitToolBarButton(FOpenPypeCommands::Get().OpenPypeTools)); + Entry.SetCommandList(PluginCommands); + } + } + } +} + + +void FOpenPypeModule::MenuPopup() { + UOpenPypePythonBridge* bridge = UOpenPypePythonBridge::Get(); + bridge->RunInPython_Popup(); +} + +void FOpenPypeModule::MenuDialog() { + UOpenPypePythonBridge* bridge = UOpenPypePythonBridge::Get(); + bridge->RunInPython_Dialog(); +} + +IMPLEMENT_MODULE(FOpenPypeModule, OpenPype) diff --git a/openpype/hosts/unreal/integration/UE_5.0/Source/OpenPype/Private/OpenPypeCommands.cpp b/openpype/hosts/unreal/integration/UE_5.0/Source/OpenPype/Private/OpenPypeCommands.cpp new file mode 100644 index 0000000000..6187bd7c7e --- /dev/null +++ b/openpype/hosts/unreal/integration/UE_5.0/Source/OpenPype/Private/OpenPypeCommands.cpp @@ -0,0 +1,13 @@ +// Copyright Epic Games, Inc. All Rights Reserved. + +#include "OpenPypeCommands.h" + +#define LOCTEXT_NAMESPACE "FOpenPypeModule" + +void FOpenPypeCommands::RegisterCommands() +{ + UI_COMMAND(OpenPypeTools, "OpenPype Tools", "Pipeline tools", EUserInterfaceActionType::Button, FInputChord()); + UI_COMMAND(OpenPypeToolsDialog, "OpenPype Tools Dialog", "Pipeline tools dialog", EUserInterfaceActionType::Button, FInputChord()); +} + +#undef LOCTEXT_NAMESPACE diff --git a/openpype/hosts/unreal/integration/UE_5.0/Source/OpenPype/Private/OpenPypeLib.cpp b/openpype/hosts/unreal/integration/UE_5.0/Source/OpenPype/Private/OpenPypeLib.cpp new file mode 100644 index 0000000000..5facab7b8b --- /dev/null +++ b/openpype/hosts/unreal/integration/UE_5.0/Source/OpenPype/Private/OpenPypeLib.cpp @@ -0,0 +1,48 @@ +#include "OpenPypeLib.h" +#include "Misc/Paths.h" +#include "Misc/ConfigCacheIni.h" +#include "UObject/UnrealType.h" + +/** + * Sets color on folder icon on given path + * @param InPath - path to folder + * @param InFolderColor - color of the folder + * @warning This color will appear only after Editor restart. Is there a better way? + */ + +void UOpenPypeLib::CSetFolderColor(FString FolderPath, FLinearColor FolderColor, bool bForceAdd) +{ + auto SaveColorInternal = [](FString InPath, FLinearColor InFolderColor) + { + // Saves the color of the folder to the config + if (FPaths::FileExists(GEditorPerProjectIni)) + { + GConfig->SetString(TEXT("PathColor"), *InPath, *InFolderColor.ToString(), GEditorPerProjectIni); + } + + }; + + SaveColorInternal(FolderPath, FolderColor); + +} +/** + * Returns all poperties on given object + * @param cls - class + * @return TArray of properties + */ +TArray UOpenPypeLib::GetAllProperties(UClass* cls) +{ + TArray Ret; + if (cls != nullptr) + { + for (TFieldIterator It(cls); It; ++It) + { + FProperty* Property = *It; + if (Property->HasAnyPropertyFlags(EPropertyFlags::CPF_Edit)) + { + Ret.Add(Property->GetName()); + } + } + } + return Ret; +} diff --git a/openpype/hosts/unreal/integration/UE_5.0/Source/OpenPype/Private/OpenPypePublishInstance.cpp b/openpype/hosts/unreal/integration/UE_5.0/Source/OpenPype/Private/OpenPypePublishInstance.cpp new file mode 100644 index 0000000000..4f1e846c0b --- /dev/null +++ b/openpype/hosts/unreal/integration/UE_5.0/Source/OpenPype/Private/OpenPypePublishInstance.cpp @@ -0,0 +1,108 @@ +#pragma once + +#include "OpenPypePublishInstance.h" +#include "AssetRegistryModule.h" + + +UOpenPypePublishInstance::UOpenPypePublishInstance(const FObjectInitializer& ObjectInitializer) + : UObject(ObjectInitializer) +{ + FAssetRegistryModule& AssetRegistryModule = FModuleManager::LoadModuleChecked("AssetRegistry"); + FString path = UOpenPypePublishInstance::GetPathName(); + FARFilter Filter; + Filter.PackagePaths.Add(FName(*path)); + + AssetRegistryModule.Get().OnAssetAdded().AddUObject(this, &UOpenPypePublishInstance::OnAssetAdded); + AssetRegistryModule.Get().OnAssetRemoved().AddUObject(this, &UOpenPypePublishInstance::OnAssetRemoved); + AssetRegistryModule.Get().OnAssetRenamed().AddUObject(this, &UOpenPypePublishInstance::OnAssetRenamed); +} + +void UOpenPypePublishInstance::OnAssetAdded(const FAssetData& AssetData) +{ + TArray split; + + // get directory of current container + FString selfFullPath = UOpenPypePublishInstance::GetPathName(); + FString selfDir = FPackageName::GetLongPackagePath(*selfFullPath); + + // get asset path and class + FString assetPath = AssetData.GetFullName(); + FString assetFName = AssetData.AssetClass.ToString(); + + // split path + assetPath.ParseIntoArray(split, TEXT(" "), true); + + FString assetDir = FPackageName::GetLongPackagePath(*split[1]); + + // take interest only in paths starting with path of current container + if (assetDir.StartsWith(*selfDir)) + { + // exclude self + if (assetFName != "OpenPypePublishInstance") + { + assets.Add(assetPath); + UE_LOG(LogTemp, Log, TEXT("%s: asset added to %s"), *selfFullPath, *selfDir); + } + } +} + +void UOpenPypePublishInstance::OnAssetRemoved(const FAssetData& AssetData) +{ + TArray split; + + // get directory of current container + FString selfFullPath = UOpenPypePublishInstance::GetPathName(); + FString selfDir = FPackageName::GetLongPackagePath(*selfFullPath); + + // get asset path and class + FString assetPath = AssetData.GetFullName(); + FString assetFName = AssetData.AssetClass.ToString(); + + // split path + assetPath.ParseIntoArray(split, TEXT(" "), true); + + FString assetDir = FPackageName::GetLongPackagePath(*split[1]); + + // take interest only in paths starting with path of current container + FString path = UOpenPypePublishInstance::GetPathName(); + FString lpp = FPackageName::GetLongPackagePath(*path); + + if (assetDir.StartsWith(*selfDir)) + { + // exclude self + if (assetFName != "OpenPypePublishInstance") + { + // UE_LOG(LogTemp, Warning, TEXT("%s: asset removed"), *lpp); + assets.Remove(assetPath); + } + } +} + +void UOpenPypePublishInstance::OnAssetRenamed(const FAssetData& AssetData, const FString& str) +{ + TArray split; + + // get directory of current container + FString selfFullPath = UOpenPypePublishInstance::GetPathName(); + FString selfDir = FPackageName::GetLongPackagePath(*selfFullPath); + + // get asset path and class + FString assetPath = AssetData.GetFullName(); + FString assetFName = AssetData.AssetClass.ToString(); + + // split path + assetPath.ParseIntoArray(split, TEXT(" "), true); + + FString assetDir = FPackageName::GetLongPackagePath(*split[1]); + if (assetDir.StartsWith(*selfDir)) + { + // exclude self + if (assetFName != "AssetContainer") + { + + assets.Remove(str); + assets.Add(assetPath); + // UE_LOG(LogTemp, Warning, TEXT("%s: asset renamed %s"), *lpp, *str); + } + } +} diff --git a/openpype/hosts/unreal/integration/UE_5.0/Source/OpenPype/Private/OpenPypePublishInstanceFactory.cpp b/openpype/hosts/unreal/integration/UE_5.0/Source/OpenPype/Private/OpenPypePublishInstanceFactory.cpp new file mode 100644 index 0000000000..e61964c689 --- /dev/null +++ b/openpype/hosts/unreal/integration/UE_5.0/Source/OpenPype/Private/OpenPypePublishInstanceFactory.cpp @@ -0,0 +1,20 @@ +#include "OpenPypePublishInstanceFactory.h" +#include "OpenPypePublishInstance.h" + +UOpenPypePublishInstanceFactory::UOpenPypePublishInstanceFactory(const FObjectInitializer& ObjectInitializer) + : UFactory(ObjectInitializer) +{ + SupportedClass = UOpenPypePublishInstance::StaticClass(); + bCreateNew = false; + bEditorImport = true; +} + +UObject* UOpenPypePublishInstanceFactory::FactoryCreateNew(UClass* Class, UObject* InParent, FName Name, EObjectFlags Flags, UObject* Context, FFeedbackContext* Warn) +{ + UOpenPypePublishInstance* OpenPypePublishInstance = NewObject(InParent, Class, Name, Flags); + return OpenPypePublishInstance; +} + +bool UOpenPypePublishInstanceFactory::ShouldShowInNewMenu() const { + return false; +} diff --git a/openpype/hosts/unreal/integration/UE_5.0/Source/OpenPype/Private/OpenPypePythonBridge.cpp b/openpype/hosts/unreal/integration/UE_5.0/Source/OpenPype/Private/OpenPypePythonBridge.cpp new file mode 100644 index 0000000000..8113231503 --- /dev/null +++ b/openpype/hosts/unreal/integration/UE_5.0/Source/OpenPype/Private/OpenPypePythonBridge.cpp @@ -0,0 +1,13 @@ +#include "OpenPypePythonBridge.h" + +UOpenPypePythonBridge* UOpenPypePythonBridge::Get() +{ + TArray OpenPypePythonBridgeClasses; + GetDerivedClasses(UOpenPypePythonBridge::StaticClass(), OpenPypePythonBridgeClasses); + int32 NumClasses = OpenPypePythonBridgeClasses.Num(); + if (NumClasses > 0) + { + return Cast(OpenPypePythonBridgeClasses[NumClasses - 1]->GetDefaultObject()); + } + return nullptr; +}; \ No newline at end of file diff --git a/openpype/hosts/unreal/integration/UE_5.0/Source/OpenPype/Private/OpenPypeStyle.cpp b/openpype/hosts/unreal/integration/UE_5.0/Source/OpenPype/Private/OpenPypeStyle.cpp new file mode 100644 index 0000000000..4a53af26b5 --- /dev/null +++ b/openpype/hosts/unreal/integration/UE_5.0/Source/OpenPype/Private/OpenPypeStyle.cpp @@ -0,0 +1,61 @@ +#include "OpenPype.h" +#include "OpenPypeStyle.h" +#include "Framework/Application/SlateApplication.h" +#include "Styling/SlateStyleRegistry.h" +#include "Slate/SlateGameResources.h" +#include "Interfaces/IPluginManager.h" +#include "Styling/SlateStyleMacros.h" + +#define RootToContentDir Style->RootToContentDir + +TSharedPtr FOpenPypeStyle::OpenPypeStyleInstance = nullptr; + +void FOpenPypeStyle::Initialize() +{ + if (!OpenPypeStyleInstance.IsValid()) + { + OpenPypeStyleInstance = Create(); + FSlateStyleRegistry::RegisterSlateStyle(*OpenPypeStyleInstance); + } +} + +void FOpenPypeStyle::Shutdown() +{ + FSlateStyleRegistry::UnRegisterSlateStyle(*OpenPypeStyleInstance); + ensure(OpenPypeStyleInstance.IsUnique()); + OpenPypeStyleInstance.Reset(); +} + +FName FOpenPypeStyle::GetStyleSetName() +{ + static FName StyleSetName(TEXT("OpenPypeStyle")); + return StyleSetName; +} + +const FVector2D Icon16x16(16.0f, 16.0f); +const FVector2D Icon20x20(20.0f, 20.0f); +const FVector2D Icon40x40(40.0f, 40.0f); + +TSharedRef< FSlateStyleSet > FOpenPypeStyle::Create() +{ + TSharedRef< FSlateStyleSet > Style = MakeShareable(new FSlateStyleSet("OpenPypeStyle")); + Style->SetContentRoot(IPluginManager::Get().FindPlugin("OpenPype")->GetBaseDir() / TEXT("Resources")); + + Style->Set("OpenPype.OpenPypeTools", new IMAGE_BRUSH(TEXT("openpype40"), Icon40x40)); + Style->Set("OpenPype.OpenPypeToolsDialog", new IMAGE_BRUSH(TEXT("openpype40"), Icon40x40)); + + return Style; +} + +void FOpenPypeStyle::ReloadTextures() +{ + if (FSlateApplication::IsInitialized()) + { + FSlateApplication::Get().GetRenderer()->ReloadTextureResources(); + } +} + +const ISlateStyle& FOpenPypeStyle::Get() +{ + return *OpenPypeStyleInstance; +} diff --git a/openpype/hosts/unreal/integration/UE_5.0/Source/OpenPype/Public/AssetContainer.h b/openpype/hosts/unreal/integration/UE_5.0/Source/OpenPype/Public/AssetContainer.h new file mode 100644 index 0000000000..3c2a360c78 --- /dev/null +++ b/openpype/hosts/unreal/integration/UE_5.0/Source/OpenPype/Public/AssetContainer.h @@ -0,0 +1,39 @@ +// Fill out your copyright notice in the Description page of Project Settings. + +#pragma once + +#include "CoreMinimal.h" +#include "UObject/NoExportTypes.h" +#include "Engine/AssetUserData.h" +#include "AssetData.h" +#include "AssetContainer.generated.h" + +/** + * + */ +UCLASS(Blueprintable) +class OPENPYPE_API UAssetContainer : public UAssetUserData +{ + GENERATED_BODY() + +public: + + UAssetContainer(const FObjectInitializer& ObjectInitalizer); + // ~UAssetContainer(); + + UPROPERTY(EditAnywhere, BlueprintReadOnly) + TArray assets; + + // There seems to be no reflection option to expose array of FAssetData + /* + UPROPERTY(Transient, BlueprintReadOnly, Category = "Python", meta=(DisplayName="Assets Data")) + TArray assetsData; + */ +private: + TArray assetsData; + void OnAssetAdded(const FAssetData& AssetData); + void OnAssetRemoved(const FAssetData& AssetData); + void OnAssetRenamed(const FAssetData& AssetData, const FString& str); +}; + + diff --git a/openpype/hosts/unreal/integration/UE_5.0/Source/OpenPype/Public/AssetContainerFactory.h b/openpype/hosts/unreal/integration/UE_5.0/Source/OpenPype/Public/AssetContainerFactory.h new file mode 100644 index 0000000000..331ce6bb50 --- /dev/null +++ b/openpype/hosts/unreal/integration/UE_5.0/Source/OpenPype/Public/AssetContainerFactory.h @@ -0,0 +1,21 @@ +// Fill out your copyright notice in the Description page of Project Settings. + +#pragma once + +#include "CoreMinimal.h" +#include "Factories/Factory.h" +#include "AssetContainerFactory.generated.h" + +/** + * + */ +UCLASS() +class OPENPYPE_API UAssetContainerFactory : public UFactory +{ + GENERATED_BODY() + +public: + UAssetContainerFactory(const FObjectInitializer& ObjectInitializer); + virtual UObject* FactoryCreateNew(UClass* Class, UObject* InParent, FName Name, EObjectFlags Flags, UObject* Context, FFeedbackContext* Warn) override; + virtual bool ShouldShowInNewMenu() const override; +}; \ No newline at end of file diff --git a/openpype/hosts/unreal/integration/UE_5.0/Source/OpenPype/Public/OpenPype.h b/openpype/hosts/unreal/integration/UE_5.0/Source/OpenPype/Public/OpenPype.h new file mode 100644 index 0000000000..3ee5eaa65f --- /dev/null +++ b/openpype/hosts/unreal/integration/UE_5.0/Source/OpenPype/Public/OpenPype.h @@ -0,0 +1,23 @@ +// Copyright 1998-2019 Epic Games, Inc. All Rights Reserved. + +#pragma once + +#include "CoreMinimal.h" +#include "Modules/ModuleManager.h" + + +class FOpenPypeModule : public IModuleInterface +{ +public: + virtual void StartupModule() override; + virtual void ShutdownModule() override; + +private: + void RegisterMenus(); + + void MenuPopup(); + void MenuDialog(); + +private: + TSharedPtr PluginCommands; +}; diff --git a/openpype/hosts/unreal/integration/UE_5.0/Source/OpenPype/Public/OpenPypeCommands.h b/openpype/hosts/unreal/integration/UE_5.0/Source/OpenPype/Public/OpenPypeCommands.h new file mode 100644 index 0000000000..62ffb8de33 --- /dev/null +++ b/openpype/hosts/unreal/integration/UE_5.0/Source/OpenPype/Public/OpenPypeCommands.h @@ -0,0 +1,24 @@ +// Copyright Epic Games, Inc. All Rights Reserved. + +#pragma once + +#include "CoreMinimal.h" +#include "Framework/Commands/Commands.h" +#include "OpenPypeStyle.h" + +class FOpenPypeCommands : public TCommands +{ +public: + + FOpenPypeCommands() + : TCommands(TEXT("OpenPype"), NSLOCTEXT("Contexts", "OpenPype", "OpenPype Tools"), NAME_None, FOpenPypeStyle::GetStyleSetName()) + { + } + + // TCommands<> interface + virtual void RegisterCommands() override; + +public: + TSharedPtr< FUICommandInfo > OpenPypeTools; + TSharedPtr< FUICommandInfo > OpenPypeToolsDialog; +}; diff --git a/openpype/hosts/unreal/integration/UE_5.0/Source/OpenPype/Public/OpenPypeLib.h b/openpype/hosts/unreal/integration/UE_5.0/Source/OpenPype/Public/OpenPypeLib.h new file mode 100644 index 0000000000..59e9c8bd76 --- /dev/null +++ b/openpype/hosts/unreal/integration/UE_5.0/Source/OpenPype/Public/OpenPypeLib.h @@ -0,0 +1,19 @@ +#pragma once + +#include "Engine.h" +#include "OpenPypeLib.generated.h" + + +UCLASS(Blueprintable) +class OPENPYPE_API UOpenPypeLib : public UObject +{ + + GENERATED_BODY() + +public: + UFUNCTION(BlueprintCallable, Category = Python) + static void CSetFolderColor(FString FolderPath, FLinearColor FolderColor, bool bForceAdd); + + UFUNCTION(BlueprintCallable, Category = Python) + static TArray GetAllProperties(UClass* cls); +}; \ No newline at end of file diff --git a/openpype/hosts/unreal/integration/UE_5.0/Source/OpenPype/Public/OpenPypePublishInstance.h b/openpype/hosts/unreal/integration/UE_5.0/Source/OpenPype/Public/OpenPypePublishInstance.h new file mode 100644 index 0000000000..0a27a078d7 --- /dev/null +++ b/openpype/hosts/unreal/integration/UE_5.0/Source/OpenPype/Public/OpenPypePublishInstance.h @@ -0,0 +1,21 @@ +#pragma once + +#include "Engine.h" +#include "OpenPypePublishInstance.generated.h" + + +UCLASS(Blueprintable) +class OPENPYPE_API UOpenPypePublishInstance : public UObject +{ + GENERATED_BODY() + +public: + UOpenPypePublishInstance(const FObjectInitializer& ObjectInitalizer); + + UPROPERTY(EditAnywhere, BlueprintReadOnly) + TArray assets; +private: + void OnAssetAdded(const FAssetData& AssetData); + void OnAssetRemoved(const FAssetData& AssetData); + void OnAssetRenamed(const FAssetData& AssetData, const FString& str); +}; \ No newline at end of file diff --git a/openpype/hosts/unreal/integration/UE_5.0/Source/OpenPype/Public/OpenPypePublishInstanceFactory.h b/openpype/hosts/unreal/integration/UE_5.0/Source/OpenPype/Public/OpenPypePublishInstanceFactory.h new file mode 100644 index 0000000000..a2b3abe13e --- /dev/null +++ b/openpype/hosts/unreal/integration/UE_5.0/Source/OpenPype/Public/OpenPypePublishInstanceFactory.h @@ -0,0 +1,19 @@ +#pragma once + +#include "CoreMinimal.h" +#include "Factories/Factory.h" +#include "OpenPypePublishInstanceFactory.generated.h" + +/** + * + */ +UCLASS() +class OPENPYPE_API UOpenPypePublishInstanceFactory : public UFactory +{ + GENERATED_BODY() + +public: + UOpenPypePublishInstanceFactory(const FObjectInitializer& ObjectInitializer); + virtual UObject* FactoryCreateNew(UClass* Class, UObject* InParent, FName Name, EObjectFlags Flags, UObject* Context, FFeedbackContext* Warn) override; + virtual bool ShouldShowInNewMenu() const override; +}; \ No newline at end of file diff --git a/openpype/hosts/unreal/integration/UE_5.0/Source/OpenPype/Public/OpenPypePythonBridge.h b/openpype/hosts/unreal/integration/UE_5.0/Source/OpenPype/Public/OpenPypePythonBridge.h new file mode 100644 index 0000000000..692aab2e5e --- /dev/null +++ b/openpype/hosts/unreal/integration/UE_5.0/Source/OpenPype/Public/OpenPypePythonBridge.h @@ -0,0 +1,20 @@ +#pragma once +#include "Engine.h" +#include "OpenPypePythonBridge.generated.h" + +UCLASS(Blueprintable) +class UOpenPypePythonBridge : public UObject +{ + GENERATED_BODY() + +public: + UFUNCTION(BlueprintCallable, Category = Python) + static UOpenPypePythonBridge* Get(); + + UFUNCTION(BlueprintImplementableEvent, Category = Python) + void RunInPython_Popup() const; + + UFUNCTION(BlueprintImplementableEvent, Category = Python) + void RunInPython_Dialog() const; + +}; diff --git a/openpype/hosts/unreal/integration/UE_5.0/Source/OpenPype/Public/OpenPypeStyle.h b/openpype/hosts/unreal/integration/UE_5.0/Source/OpenPype/Public/OpenPypeStyle.h new file mode 100644 index 0000000000..ae704251e1 --- /dev/null +++ b/openpype/hosts/unreal/integration/UE_5.0/Source/OpenPype/Public/OpenPypeStyle.h @@ -0,0 +1,18 @@ +#pragma once +#include "CoreMinimal.h" +#include "Styling/SlateStyle.h" + +class FOpenPypeStyle +{ +public: + static void Initialize(); + static void Shutdown(); + static void ReloadTextures(); + static const ISlateStyle& Get(); + static FName GetStyleSetName(); + + +private: + static TSharedRef< class FSlateStyleSet > Create(); + static TSharedPtr< class FSlateStyleSet > OpenPypeStyleInstance; +}; \ No newline at end of file diff --git a/openpype/hosts/unreal/lib.py b/openpype/hosts/unreal/lib.py index d4a776e892..8c453b38b9 100644 --- a/openpype/hosts/unreal/lib.py +++ b/openpype/hosts/unreal/lib.py @@ -70,19 +70,22 @@ def get_engine_versions(env=None): return OrderedDict() -def get_editor_executable_path(engine_path: Path) -> Path: - """Get UE4 Editor executable path.""" - ue4_path = engine_path / "Engine/Binaries" +def get_editor_executable_path(engine_path: Path, engine_version: str) -> Path: + """Get UE Editor executable path.""" + ue_path = engine_path / "Engine/Binaries" if platform.system().lower() == "windows": - ue4_path /= "Win64/UE4Editor.exe" + if engine_version.split(".")[0] == "4": + ue_path /= "Win64/UE4Editor.exe" + elif engine_version.split(".")[0] == "5": + ue_path /= "Win64/UnrealEditor.exe" elif platform.system().lower() == "linux": - ue4_path /= "Linux/UE4Editor" + ue_path /= "Linux/UE4Editor" elif platform.system().lower() == "darwin": - ue4_path /= "Mac/UE4Editor" + ue_path /= "Mac/UE4Editor" - return ue4_path + return ue_path def _win_get_engine_versions(): @@ -208,22 +211,26 @@ def create_unreal_project(project_name: str, # created in different UE4 version. When user convert such project # to his UE4 version, Engine ID is replaced in uproject file. If some # other user tries to open it, it will present him with similar error. - ue4_modules = Path() + ue_modules = Path() if platform.system().lower() == "windows": - ue4_modules = Path(os.path.join(engine_path, "Engine", "Binaries", - "Win64", "UE4Editor.modules")) + ue_modules_path = engine_path / "Engine/Binaries/Win64" + if ue_version.split(".")[0] == "4": + ue_modules_path /= "UE4Editor.modules" + elif ue_version.split(".")[0] == "5": + ue_modules_path /= "UnrealEditor.modules" + ue_modules = Path(ue_modules_path) if platform.system().lower() == "linux": - ue4_modules = Path(os.path.join(engine_path, "Engine", "Binaries", + ue_modules = Path(os.path.join(engine_path, "Engine", "Binaries", "Linux", "UE4Editor.modules")) if platform.system().lower() == "darwin": - ue4_modules = Path(os.path.join(engine_path, "Engine", "Binaries", + ue_modules = Path(os.path.join(engine_path, "Engine", "Binaries", "Mac", "UE4Editor.modules")) - if ue4_modules.exists(): + if ue_modules.exists(): print("--- Loading Engine ID from modules file ...") - with open(ue4_modules, "r") as mp: + with open(ue_modules, "r") as mp: loaded_modules = json.load(mp) if loaded_modules.get("BuildId"): @@ -254,6 +261,7 @@ def create_unreal_project(project_name: str, {"Name": "PythonScriptPlugin", "Enabled": True}, {"Name": "EditorScriptingUtilities", "Enabled": True}, {"Name": "SequencerScripting", "Enabled": True}, + {"Name": "MovieRenderPipeline", "Enabled": True}, {"Name": "OpenPype", "Enabled": True} ] } @@ -279,7 +287,7 @@ def create_unreal_project(project_name: str, python_path = None if platform.system().lower() == "windows": python_path = engine_path / ("Engine/Binaries/ThirdParty/" - "Python3/Win64/pythonw.exe") + "Python3/Win64/python.exe") if platform.system().lower() == "linux": python_path = engine_path / ("Engine/Binaries/ThirdParty/" @@ -293,14 +301,15 @@ def create_unreal_project(project_name: str, raise NotImplementedError("Unsupported platform") if not python_path.exists(): raise RuntimeError(f"Unreal Python not found at {python_path}") - subprocess.run( + subprocess.check_call( [python_path.as_posix(), "-m", "pip", "install", "pyside2"]) if dev_mode or preset["dev_mode"]: - _prepare_cpp_project(project_file, engine_path) + _prepare_cpp_project(project_file, engine_path, ue_version) -def _prepare_cpp_project(project_file: Path, engine_path: Path) -> None: +def _prepare_cpp_project( + project_file: Path, engine_path: Path, ue_version: str) -> None: """Prepare CPP Unreal Project. This function will add source files needed for project to be @@ -419,8 +428,12 @@ class {1}_API A{0}GameModeBase : public AGameModeBase with open(sources_dir / f"{project_name}GameModeBase.h", mode="w") as f: f.write(game_mode_h) - u_build_tool = Path( - engine_path / "Engine/Binaries/DotNET/UnrealBuildTool.exe") + u_build_tool_path = engine_path / "Engine/Binaries/DotNET" + if ue_version.split(".")[0] == "4": + u_build_tool_path /= "UnrealBuildTool.exe" + elif ue_version.split(".")[0] == "5": + u_build_tool_path /= "UnrealBuildTool/UnrealBuildTool.exe" + u_build_tool = Path(u_build_tool_path) u_header_tool = None arch = "Win64" diff --git a/openpype/hosts/unreal/plugins/create/create_layout.py b/openpype/hosts/unreal/plugins/create/create_layout.py index 751bece167..5fef08ce2a 100644 --- a/openpype/hosts/unreal/plugins/create/create_layout.py +++ b/openpype/hosts/unreal/plugins/create/create_layout.py @@ -1,5 +1,6 @@ # -*- coding: utf-8 -*- -from unreal import EditorLevelLibrary as ell +from unreal import EditorLevelLibrary + from openpype.hosts.unreal.api import plugin from openpype.hosts.unreal.api.pipeline import instantiate @@ -28,13 +29,13 @@ class CreateLayout(plugin.Creator): # sel_objects = unreal.EditorUtilityLibrary.get_selected_assets() # selection = [a.get_path_name() for a in sel_objects] - data["level"] = ell.get_editor_world().get_path_name() + data["level"] = EditorLevelLibrary.get_editor_world().get_path_name() data["members"] = [] if (self.options or {}).get("useSelection"): # Set as members the selected actors - for actor in ell.get_selected_level_actors(): + for actor in EditorLevelLibrary.get_selected_level_actors(): data["members"].append("{}.{}".format( actor.get_outer().get_name(), actor.get_name())) diff --git a/openpype/hosts/unreal/plugins/create/create_render.py b/openpype/hosts/unreal/plugins/create/create_render.py new file mode 100644 index 0000000000..a3e125a94e --- /dev/null +++ b/openpype/hosts/unreal/plugins/create/create_render.py @@ -0,0 +1,118 @@ +import unreal + +from openpype.pipeline import legacy_io +from openpype.hosts.unreal.api import pipeline +from openpype.hosts.unreal.api.plugin import Creator + + +class CreateRender(Creator): + """Create instance for sequence for rendering""" + + name = "unrealRender" + label = "Unreal - Render" + family = "render" + icon = "cube" + asset_types = ["LevelSequence"] + + root = "/Game/OpenPype/PublishInstances" + suffix = "_INS" + + def process(self): + subset = self.data["subset"] + + ar = unreal.AssetRegistryHelpers.get_asset_registry() + + # The asset name is the the third element of the path which contains + # the map. + # The index of the split path is 3 because the first element is an + # empty string, as the path begins with "/Content". + a = unreal.EditorUtilityLibrary.get_selected_assets()[0] + asset_name = a.get_path_name().split("/")[3] + + # Get the master sequence and the master level. + # There should be only one sequence and one level in the directory. + filter = unreal.ARFilter( + class_names=["LevelSequence"], + package_paths=[f"/Game/OpenPype/{asset_name}"], + recursive_paths=False) + sequences = ar.get_assets(filter) + ms = sequences[0].get_editor_property('object_path') + filter = unreal.ARFilter( + class_names=["World"], + package_paths=[f"/Game/OpenPype/{asset_name}"], + recursive_paths=False) + levels = ar.get_assets(filter) + ml = levels[0].get_editor_property('object_path') + + selection = [] + if (self.options or {}).get("useSelection"): + sel_objects = unreal.EditorUtilityLibrary.get_selected_assets() + selection = [ + a.get_path_name() for a in sel_objects + if a.get_class().get_name() in self.asset_types] + else: + selection.append(self.data['sequence']) + + unreal.log(f"selection: {selection}") + + path = f"{self.root}" + unreal.EditorAssetLibrary.make_directory(path) + + ar = unreal.AssetRegistryHelpers.get_asset_registry() + + for a in selection: + ms_obj = ar.get_asset_by_object_path(ms).get_asset() + + seq_data = None + + if a == ms: + seq_data = { + "sequence": ms_obj, + "output": f"{ms_obj.get_name()}", + "frame_range": ( + ms_obj.get_playback_start(), ms_obj.get_playback_end()) + } + else: + seq_data_list = [{ + "sequence": ms_obj, + "output": f"{ms_obj.get_name()}", + "frame_range": ( + ms_obj.get_playback_start(), ms_obj.get_playback_end()) + }] + + for s in seq_data_list: + subscenes = pipeline.get_subsequences(s.get('sequence')) + + for ss in subscenes: + curr_data = { + "sequence": ss.get_sequence(), + "output": (f"{s.get('output')}/" + f"{ss.get_sequence().get_name()}"), + "frame_range": ( + ss.get_start_frame(), ss.get_end_frame() - 1) + } + + if ss.get_sequence().get_path_name() == a: + seq_data = curr_data + break + seq_data_list.append(curr_data) + + if seq_data is not None: + break + + if not seq_data: + continue + + d = self.data.copy() + d["members"] = [a] + d["sequence"] = a + d["master_sequence"] = ms + d["master_level"] = ml + d["output"] = seq_data.get('output') + d["frameStart"] = seq_data.get('frame_range')[0] + d["frameEnd"] = seq_data.get('frame_range')[1] + + container_name = f"{subset}{self.suffix}" + pipeline.create_publish_instance( + instance=container_name, path=path) + pipeline.imprint(f"{path}/{container_name}", d) diff --git a/openpype/hosts/unreal/plugins/load/load_animation.py b/openpype/hosts/unreal/plugins/load/load_animation.py index c9a1633031..da2830bc52 100644 --- a/openpype/hosts/unreal/plugins/load/load_animation.py +++ b/openpype/hosts/unreal/plugins/load/load_animation.py @@ -3,13 +3,18 @@ import os import json +import unreal +from unreal import EditorAssetLibrary +from unreal import MovieSceneSkeletalAnimationTrack +from unreal import MovieSceneSkeletalAnimationSection + from openpype.pipeline import ( get_representation_path, AVALON_CONTAINER_ID ) from openpype.hosts.unreal.api import plugin from openpype.hosts.unreal.api import pipeline as unreal_pipeline -import unreal # noqa +from openpype.api import get_asset class AnimationFBXLoader(plugin.Loader): @@ -21,59 +26,13 @@ class AnimationFBXLoader(plugin.Loader): icon = "cube" color = "orange" - def load(self, context, name, namespace, options=None): - """ - Load and containerise representation into Content Browser. - - This is two step process. First, import FBX to temporary path and - then call `containerise()` on it - this moves all content to new - directory and then it will create AssetContainer there and imprint it - with metadata. This will mark this path as container. - - Args: - context (dict): application context - name (str): subset name - namespace (str): in Unreal this is basically path to container. - This is not passed here, so namespace is set - by `containerise()` because only then we know - real path. - data (dict): Those would be data to be imprinted. This is not used - now, data are imprinted by `containerise()`. - - Returns: - list(str): list of container content - - """ - # Create directory for asset and OpenPype container - root = "/Game/OpenPype/Assets" - asset = context.get('asset').get('name') - suffix = "_CON" - if asset: - asset_name = "{}_{}".format(asset, name) - else: - asset_name = "{}".format(name) - - tools = unreal.AssetToolsHelpers().get_asset_tools() - asset_dir, container_name = tools.create_unique_asset_name( - "{}/{}/{}".format(root, asset, name), suffix="") - - container_name += suffix - - unreal.EditorAssetLibrary.make_directory(asset_dir) - + def _process(self, asset_dir, asset_name, instance_name): automated = False actor = None task = unreal.AssetImportTask() task.options = unreal.FbxImportUI() - lib_path = self.fname.replace("fbx", "json") - - with open(lib_path, "r") as fp: - data = json.load(fp) - - instance_name = data.get("instance_name") - if instance_name: automated = True # Old method to get the actor @@ -119,18 +78,144 @@ class AnimationFBXLoader(plugin.Loader): task.options.anim_sequence_import_data.set_editor_property( 'import_meshes_in_bone_hierarchy', False) task.options.anim_sequence_import_data.set_editor_property( - 'use_default_sample_rate', True) + 'use_default_sample_rate', False) + task.options.anim_sequence_import_data.set_editor_property( + 'custom_sample_rate', get_asset()["data"].get("fps")) task.options.anim_sequence_import_data.set_editor_property( 'import_custom_attribute', True) task.options.anim_sequence_import_data.set_editor_property( 'import_bone_tracks', True) task.options.anim_sequence_import_data.set_editor_property( - 'remove_redundant_keys', True) + 'remove_redundant_keys', False) task.options.anim_sequence_import_data.set_editor_property( 'convert_scene', True) unreal.AssetToolsHelpers.get_asset_tools().import_asset_tasks([task]) + asset_content = EditorAssetLibrary.list_assets( + asset_dir, recursive=True, include_folder=True + ) + + animation = None + + for a in asset_content: + imported_asset_data = EditorAssetLibrary.find_asset_data(a) + imported_asset = unreal.AssetRegistryHelpers.get_asset( + imported_asset_data) + if imported_asset.__class__ == unreal.AnimSequence: + animation = imported_asset + break + + if animation: + animation.set_editor_property('enable_root_motion', True) + actor.skeletal_mesh_component.set_editor_property( + 'animation_mode', unreal.AnimationMode.ANIMATION_SINGLE_NODE) + actor.skeletal_mesh_component.animation_data.set_editor_property( + 'anim_to_play', animation) + + return animation + + def load(self, context, name, namespace, options=None): + """ + Load and containerise representation into Content Browser. + + This is two step process. First, import FBX to temporary path and + then call `containerise()` on it - this moves all content to new + directory and then it will create AssetContainer there and imprint it + with metadata. This will mark this path as container. + + Args: + context (dict): application context + name (str): subset name + namespace (str): in Unreal this is basically path to container. + This is not passed here, so namespace is set + by `containerise()` because only then we know + real path. + data (dict): Those would be data to be imprinted. This is not used + now, data are imprinted by `containerise()`. + + Returns: + list(str): list of container content + """ + # Create directory for asset and avalon container + hierarchy = context.get('asset').get('data').get('parents') + root = "/Game/OpenPype" + asset = context.get('asset').get('name') + suffix = "_CON" + asset_name = f"{asset}_{name}" if asset else f"{name}" + tools = unreal.AssetToolsHelpers().get_asset_tools() + asset_dir, container_name = tools.create_unique_asset_name( + f"{root}/Animations/{asset}/{name}", suffix="") + + ar = unreal.AssetRegistryHelpers.get_asset_registry() + + _filter = unreal.ARFilter( + class_names=["World"], + package_paths=[f"{root}/{hierarchy[0]}"], + recursive_paths=False) + levels = ar.get_assets(_filter) + master_level = levels[0].get_editor_property('object_path') + + hierarchy_dir = root + for h in hierarchy: + hierarchy_dir = f"{hierarchy_dir}/{h}" + hierarchy_dir = f"{hierarchy_dir}/{asset}" + + _filter = unreal.ARFilter( + class_names=["World"], + package_paths=[f"{hierarchy_dir}/"], + recursive_paths=True) + levels = ar.get_assets(_filter) + level = levels[0].get_editor_property('object_path') + + unreal.EditorLevelLibrary.save_all_dirty_levels() + unreal.EditorLevelLibrary.load_level(level) + + container_name += suffix + + EditorAssetLibrary.make_directory(asset_dir) + + libpath = self.fname.replace("fbx", "json") + + with open(libpath, "r") as fp: + data = json.load(fp) + + instance_name = data.get("instance_name") + + animation = self._process(asset_dir, asset_name, instance_name) + + asset_content = EditorAssetLibrary.list_assets( + hierarchy_dir, recursive=True, include_folder=False) + + # Get the sequence for the layout, excluding the camera one. + sequences = [a for a in asset_content + if (EditorAssetLibrary.find_asset_data(a).get_class() == + unreal.LevelSequence.static_class() and + "_camera" not in a.split("/")[-1])] + + ar = unreal.AssetRegistryHelpers.get_asset_registry() + + for s in sequences: + sequence = ar.get_asset_by_object_path(s).get_asset() + possessables = [ + p for p in sequence.get_possessables() + if p.get_display_name() == instance_name] + + for p in possessables: + tracks = [ + t for t in p.get_tracks() + if (t.get_class() == + MovieSceneSkeletalAnimationTrack.static_class())] + + for t in tracks: + sections = [ + s for s in t.get_sections() + if (s.get_class() == + MovieSceneSkeletalAnimationSection.static_class())] + + for s in sections: + s.params.set_editor_property('animation', animation) + # Create Asset Container unreal_pipeline.create_container( container=container_name, path=asset_dir) @@ -147,32 +232,16 @@ class AnimationFBXLoader(plugin.Loader): "parent": context["representation"]["parent"], "family": context["representation"]["context"]["family"] } - unreal_pipeline.imprint( - "{}/{}".format(asset_dir, container_name), data) + unreal_pipeline.imprint(f"{asset_dir}/{container_name}", data) - asset_content = unreal.EditorAssetLibrary.list_assets( - asset_dir, recursive=True, include_folder=True - ) + imported_content = EditorAssetLibrary.list_assets( + asset_dir, recursive=True, include_folder=False) - animation = None + for a in imported_content: + EditorAssetLibrary.save_asset(a) - for a in asset_content: - unreal.EditorAssetLibrary.save_asset(a) - imported_asset_data = unreal.EditorAssetLibrary.find_asset_data(a) - imported_asset = unreal.AssetRegistryHelpers.get_asset( - imported_asset_data) - if imported_asset.__class__ == unreal.AnimSequence: - animation = imported_asset - break - - if animation: - animation.set_editor_property('enable_root_motion', True) - actor.skeletal_mesh_component.set_editor_property( - 'animation_mode', unreal.AnimationMode.ANIMATION_SINGLE_NODE) - actor.skeletal_mesh_component.animation_data.set_editor_property( - 'anim_to_play', animation) - - return asset_content + unreal.EditorLevelLibrary.save_current_level() + unreal.EditorLevelLibrary.load_level(master_level) def update(self, container, representation): name = container["asset_name"] @@ -208,25 +277,26 @@ class AnimationFBXLoader(plugin.Loader): task.options.anim_sequence_import_data.set_editor_property( 'import_meshes_in_bone_hierarchy', False) task.options.anim_sequence_import_data.set_editor_property( - 'use_default_sample_rate', True) + 'use_default_sample_rate', False) + task.options.anim_sequence_import_data.set_editor_property( + 'custom_sample_rate', get_asset()["data"].get("fps")) task.options.anim_sequence_import_data.set_editor_property( 'import_custom_attribute', True) task.options.anim_sequence_import_data.set_editor_property( 'import_bone_tracks', True) task.options.anim_sequence_import_data.set_editor_property( - 'remove_redundant_keys', True) + 'remove_redundant_keys', False) task.options.anim_sequence_import_data.set_editor_property( 'convert_scene', True) - skeletal_mesh = unreal.EditorAssetLibrary.load_asset( + skeletal_mesh = EditorAssetLibrary.load_asset( container.get('namespace') + "/" + container.get('asset_name')) skeleton = skeletal_mesh.get_editor_property('skeleton') task.options.set_editor_property('skeleton', skeleton) # do import fbx and replace existing data unreal.AssetToolsHelpers.get_asset_tools().import_asset_tasks([task]) - container_path = "{}/{}".format(container["namespace"], - container["objectName"]) + container_path = f'{container["namespace"]}/{container["objectName"]}' # update metadata unreal_pipeline.imprint( container_path, @@ -235,22 +305,22 @@ class AnimationFBXLoader(plugin.Loader): "parent": str(representation["parent"]) }) - asset_content = unreal.EditorAssetLibrary.list_assets( + asset_content = EditorAssetLibrary.list_assets( destination_path, recursive=True, include_folder=True ) for a in asset_content: - unreal.EditorAssetLibrary.save_asset(a) + EditorAssetLibrary.save_asset(a) def remove(self, container): path = container["namespace"] parent_path = os.path.dirname(path) - unreal.EditorAssetLibrary.delete_directory(path) + EditorAssetLibrary.delete_directory(path) - asset_content = unreal.EditorAssetLibrary.list_assets( + asset_content = EditorAssetLibrary.list_assets( parent_path, recursive=False, include_folder=True ) if len(asset_content) == 0: - unreal.EditorAssetLibrary.delete_directory(parent_path) + EditorAssetLibrary.delete_directory(parent_path) diff --git a/openpype/hosts/unreal/plugins/load/load_camera.py b/openpype/hosts/unreal/plugins/load/load_camera.py index 63c0845ec2..e93be486b0 100644 --- a/openpype/hosts/unreal/plugins/load/load_camera.py +++ b/openpype/hosts/unreal/plugins/load/load_camera.py @@ -1,6 +1,11 @@ # -*- coding: utf-8 -*- """Load camera from FBX.""" -import os +from pathlib import Path + +import unreal +from unreal import EditorAssetLibrary +from unreal import EditorLevelLibrary +from unreal import EditorLevelUtils from openpype.pipeline import ( AVALON_CONTAINER_ID, @@ -8,7 +13,6 @@ from openpype.pipeline import ( ) from openpype.hosts.unreal.api import plugin from openpype.hosts.unreal.api import pipeline as unreal_pipeline -import unreal # noqa class CameraLoader(plugin.Loader): @@ -20,6 +24,67 @@ class CameraLoader(plugin.Loader): icon = "cube" color = "orange" + def _get_data(self, asset_name): + asset_doc = legacy_io.find_one({ + "type": "asset", + "name": asset_name + }) + + return asset_doc.get("data") + + def _set_sequence_hierarchy( + self, seq_i, seq_j, min_frame_j, max_frame_j + ): + tracks = seq_i.get_master_tracks() + track = None + for t in tracks: + if t.get_class() == unreal.MovieSceneSubTrack.static_class(): + track = t + break + if not track: + track = seq_i.add_master_track(unreal.MovieSceneSubTrack) + + subscenes = track.get_sections() + subscene = None + for s in subscenes: + if s.get_editor_property('sub_sequence') == seq_j: + subscene = s + break + if not subscene: + subscene = track.add_section() + subscene.set_row_index(len(track.get_sections())) + subscene.set_editor_property('sub_sequence', seq_j) + subscene.set_range( + min_frame_j, + max_frame_j + 1) + + def _import_camera( + self, world, sequence, bindings, import_fbx_settings, import_filename + ): + ue_version = unreal.SystemLibrary.get_engine_version().split('.') + ue_major = int(ue_version[0]) + ue_minor = int(ue_version[1]) + + if ue_major == 4 and ue_minor <= 26: + unreal.SequencerTools.import_fbx( + world, + sequence, + bindings, + import_fbx_settings, + import_filename + ) + elif (ue_major == 4 and ue_minor >= 27) or ue_major == 5: + unreal.SequencerTools.import_level_sequence_fbx( + world, + sequence, + bindings, + import_fbx_settings, + import_filename + ) + else: + raise NotImplementedError( + f"Unreal version {ue_major} not supported") + def load(self, context, name, namespace, data): """ Load and containerise representation into Content Browser. @@ -43,8 +108,14 @@ class CameraLoader(plugin.Loader): list(str): list of container content """ - # Create directory for asset and OpenPype container - root = "/Game/OpenPype/Assets" + # Create directory for asset and avalon container + hierarchy = context.get('asset').get('data').get('parents') + root = "/Game/OpenPype" + hierarchy_dir = root + hierarchy_dir_list = [] + for h in hierarchy: + hierarchy_dir = f"{hierarchy_dir}/{h}" + hierarchy_dir_list.append(hierarchy_dir) asset = context.get('asset').get('name') suffix = "_CON" if asset: @@ -54,10 +125,10 @@ class CameraLoader(plugin.Loader): tools = unreal.AssetToolsHelpers().get_asset_tools() + # Create a unique name for the camera directory unique_number = 1 - - if unreal.EditorAssetLibrary.does_directory_exist(f"{root}/{asset}"): - asset_content = unreal.EditorAssetLibrary.list_assets( + if EditorAssetLibrary.does_directory_exist(f"{hierarchy_dir}/{asset}"): + asset_content = EditorAssetLibrary.list_assets( f"{root}/{asset}", recursive=False, include_folder=True ) @@ -76,42 +147,135 @@ class CameraLoader(plugin.Loader): unique_number = f_numbers[-1] + 1 asset_dir, container_name = tools.create_unique_asset_name( - f"{root}/{asset}/{name}_{unique_number:02d}", suffix="") + f"{hierarchy_dir}/{asset}/{name}_{unique_number:02d}", suffix="") + + asset_path = Path(asset_dir) + asset_path_parent = str(asset_path.parent.as_posix()) container_name += suffix - unreal.EditorAssetLibrary.make_directory(asset_dir) + EditorAssetLibrary.make_directory(asset_dir) - sequence = tools.create_asset( - asset_name=asset_name, + # Create map for the shot, and create hierarchy of map. If the maps + # already exist, we will use them. + h_dir = hierarchy_dir_list[0] + h_asset = hierarchy[0] + master_level = f"{h_dir}/{h_asset}_map.{h_asset}_map" + if not EditorAssetLibrary.does_asset_exist(master_level): + EditorLevelLibrary.new_level(f"{h_dir}/{h_asset}_map") + + level = f"{asset_path_parent}/{asset}_map.{asset}_map" + if not EditorAssetLibrary.does_asset_exist(level): + EditorLevelLibrary.new_level(f"{asset_path_parent}/{asset}_map") + + EditorLevelLibrary.load_level(master_level) + EditorLevelUtils.add_level_to_world( + EditorLevelLibrary.get_editor_world(), + level, + unreal.LevelStreamingDynamic + ) + EditorLevelLibrary.save_all_dirty_levels() + EditorLevelLibrary.load_level(level) + + # Get all the sequences in the hierarchy. It will create them, if + # they don't exist. + sequences = [] + frame_ranges = [] + i = 0 + for h in hierarchy_dir_list: + root_content = EditorAssetLibrary.list_assets( + h, recursive=False, include_folder=False) + + existing_sequences = [ + EditorAssetLibrary.find_asset_data(asset) + for asset in root_content + if EditorAssetLibrary.find_asset_data( + asset).get_class().get_name() == 'LevelSequence' + ] + + if not existing_sequences: + scene = tools.create_asset( + asset_name=hierarchy[i], + package_path=h, + asset_class=unreal.LevelSequence, + factory=unreal.LevelSequenceFactoryNew() + ) + + asset_data = legacy_io.find_one({ + "type": "asset", + "name": h.split('/')[-1] + }) + + id = asset_data.get('_id') + + start_frames = [] + end_frames = [] + + elements = list( + legacy_io.find({"type": "asset", "data.visualParent": id})) + for e in elements: + start_frames.append(e.get('data').get('clipIn')) + end_frames.append(e.get('data').get('clipOut')) + + elements.extend(legacy_io.find({ + "type": "asset", + "data.visualParent": e.get('_id') + })) + + min_frame = min(start_frames) + max_frame = max(end_frames) + + scene.set_display_rate( + unreal.FrameRate(asset_data.get('data').get("fps"), 1.0)) + scene.set_playback_start(min_frame) + scene.set_playback_end(max_frame) + + sequences.append(scene) + frame_ranges.append((min_frame, max_frame)) + else: + for e in existing_sequences: + sequences.append(e.get_asset()) + frame_ranges.append(( + e.get_asset().get_playback_start(), + e.get_asset().get_playback_end())) + + i += 1 + + EditorAssetLibrary.make_directory(asset_dir) + + cam_seq = tools.create_asset( + asset_name=f"{asset}_camera", package_path=asset_dir, asset_class=unreal.LevelSequence, factory=unreal.LevelSequenceFactoryNew() ) - io_asset = legacy_io.Session["AVALON_ASSET"] - asset_doc = legacy_io.find_one({ - "type": "asset", - "name": io_asset - }) + # Add sequences data to hierarchy + for i in range(0, len(sequences) - 1): + self._set_sequence_hierarchy( + sequences[i], sequences[i + 1], + frame_ranges[i + 1][0], frame_ranges[i + 1][1]) - data = asset_doc.get("data") - - if data: - sequence.set_display_rate(unreal.FrameRate(data.get("fps"), 1.0)) - sequence.set_playback_start(data.get("frameStart")) - sequence.set_playback_end(data.get("frameEnd")) + data = self._get_data(asset) + cam_seq.set_display_rate( + unreal.FrameRate(data.get("fps"), 1.0)) + cam_seq.set_playback_start(0) + cam_seq.set_playback_end(data.get('clipOut') - data.get('clipIn') + 1) + self._set_sequence_hierarchy( + sequences[-1], cam_seq, + data.get('clipIn'), data.get('clipOut')) settings = unreal.MovieSceneUserImportFBXSettings() settings.set_editor_property('reduce_keys', False) - unreal.SequencerTools.import_fbx( - unreal.EditorLevelLibrary.get_editor_world(), - sequence, - sequence.get_bindings(), - settings, - self.fname - ) + if cam_seq: + self._import_camera( + EditorLevelLibrary.get_editor_world(), + cam_seq, + cam_seq.get_bindings(), + settings, + self.fname + ) # Create Asset Container unreal_pipeline.create_container( @@ -132,81 +296,258 @@ class CameraLoader(plugin.Loader): unreal_pipeline.imprint( "{}/{}".format(asset_dir, container_name), data) - asset_content = unreal.EditorAssetLibrary.list_assets( + EditorLevelLibrary.save_all_dirty_levels() + EditorLevelLibrary.load_level(master_level) + + asset_content = EditorAssetLibrary.list_assets( asset_dir, recursive=True, include_folder=True ) for a in asset_content: - unreal.EditorAssetLibrary.save_asset(a) + EditorAssetLibrary.save_asset(a) return asset_content def update(self, container, representation): - path = container["namespace"] - ar = unreal.AssetRegistryHelpers.get_asset_registry() - tools = unreal.AssetToolsHelpers().get_asset_tools() - asset_content = unreal.EditorAssetLibrary.list_assets( - path, recursive=False, include_folder=False - ) - asset_name = "" - for a in asset_content: - asset = ar.get_asset_by_object_path(a) - if a.endswith("_CON"): - loaded_asset = unreal.EditorAssetLibrary.load_asset(a) - unreal.EditorAssetLibrary.set_metadata_tag( - loaded_asset, "representation", str(representation["_id"]) - ) - unreal.EditorAssetLibrary.set_metadata_tag( - loaded_asset, "parent", str(representation["parent"]) - ) - asset_name = unreal.EditorAssetLibrary.get_metadata_tag( - loaded_asset, "asset_name" - ) - elif asset.asset_class == "LevelSequence": - unreal.EditorAssetLibrary.delete_asset(a) + root = "/Game/OpenPype" - sequence = tools.create_asset( - asset_name=asset_name, - package_path=path, - asset_class=unreal.LevelSequence, - factory=unreal.LevelSequenceFactoryNew() + asset_dir = container.get('namespace') + + context = representation.get("context") + + hierarchy = context.get('hierarchy').split("/") + h_dir = f"{root}/{hierarchy[0]}" + h_asset = hierarchy[0] + master_level = f"{h_dir}/{h_asset}_map.{h_asset}_map" + + EditorLevelLibrary.save_current_level() + + filter = unreal.ARFilter( + class_names=["LevelSequence"], + package_paths=[asset_dir], + recursive_paths=False) + sequences = ar.get_assets(filter) + filter = unreal.ARFilter( + class_names=["World"], + package_paths=[str(Path(asset_dir).parent.as_posix())], + recursive_paths=True) + maps = ar.get_assets(filter) + + # There should be only one map in the list + EditorLevelLibrary.load_level(maps[0].get_full_name()) + + level_sequence = sequences[0].get_asset() + + display_rate = level_sequence.get_display_rate() + playback_start = level_sequence.get_playback_start() + playback_end = level_sequence.get_playback_end() + + sequence_name = f"{container.get('asset')}_camera" + + # Get the actors in the level sequence. + objs = unreal.SequencerTools.get_bound_objects( + unreal.EditorLevelLibrary.get_editor_world(), + level_sequence, + level_sequence.get_bindings(), + unreal.SequencerScriptingRange( + has_start_value=True, + has_end_value=True, + inclusive_start=level_sequence.get_playback_start(), + exclusive_end=level_sequence.get_playback_end() + ) ) - io_asset = legacy_io.Session["AVALON_ASSET"] - asset_doc = legacy_io.find_one({ - "type": "asset", - "name": io_asset - }) + # Delete actors from the map + for o in objs: + if o.bound_objects[0].get_class().get_name() == "CineCameraActor": + actor_path = o.bound_objects[0].get_path_name().split(":")[-1] + actor = EditorLevelLibrary.get_actor_reference(actor_path) + EditorLevelLibrary.destroy_actor(actor) - data = asset_doc.get("data") + # Remove the Level Sequence from the parent. + # We need to traverse the hierarchy from the master sequence to find + # the level sequence. + root = "/Game/OpenPype" + namespace = container.get('namespace').replace(f"{root}/", "") + ms_asset = namespace.split('/')[0] + filter = unreal.ARFilter( + class_names=["LevelSequence"], + package_paths=[f"{root}/{ms_asset}"], + recursive_paths=False) + sequences = ar.get_assets(filter) + master_sequence = sequences[0].get_asset() - if data: - sequence.set_display_rate(unreal.FrameRate(data.get("fps"), 1.0)) - sequence.set_playback_start(data.get("frameStart")) - sequence.set_playback_end(data.get("frameEnd")) + sequences = [master_sequence] + + parent = None + sub_scene = None + for s in sequences: + tracks = s.get_master_tracks() + subscene_track = None + for t in tracks: + if t.get_class() == unreal.MovieSceneSubTrack.static_class(): + subscene_track = t + break + if subscene_track: + sections = subscene_track.get_sections() + for ss in sections: + if ss.get_sequence().get_name() == sequence_name: + parent = s + sub_scene = ss + # subscene_track.remove_section(ss) + break + sequences.append(ss.get_sequence()) + # Update subscenes indexes. + i = 0 + for ss in sections: + ss.set_row_index(i) + i += 1 + + if parent: + break + + assert parent, "Could not find the parent sequence" + + EditorAssetLibrary.delete_asset(level_sequence.get_path_name()) settings = unreal.MovieSceneUserImportFBXSettings() settings.set_editor_property('reduce_keys', False) - unreal.SequencerTools.import_fbx( - unreal.EditorLevelLibrary.get_editor_world(), - sequence, - sequence.get_bindings(), + tools = unreal.AssetToolsHelpers().get_asset_tools() + new_sequence = tools.create_asset( + asset_name=sequence_name, + package_path=asset_dir, + asset_class=unreal.LevelSequence, + factory=unreal.LevelSequenceFactoryNew() + ) + + new_sequence.set_display_rate(display_rate) + new_sequence.set_playback_start(playback_start) + new_sequence.set_playback_end(playback_end) + + sub_scene.set_sequence(new_sequence) + + self._import_camera( + EditorLevelLibrary.get_editor_world(), + new_sequence, + new_sequence.get_bindings(), settings, str(representation["data"]["path"]) ) + data = { + "representation": str(representation["_id"]), + "parent": str(representation["parent"]) + } + unreal_pipeline.imprint( + "{}/{}".format(asset_dir, container.get('container_name')), data) + + EditorLevelLibrary.save_current_level() + + asset_content = EditorAssetLibrary.list_assets( + asset_dir, recursive=True, include_folder=False) + + for a in asset_content: + EditorAssetLibrary.save_asset(a) + + EditorLevelLibrary.load_level(master_level) + def remove(self, container): - path = container["namespace"] - parent_path = os.path.dirname(path) + path = Path(container.get("namespace")) + parent_path = str(path.parent.as_posix()) - unreal.EditorAssetLibrary.delete_directory(path) + ar = unreal.AssetRegistryHelpers.get_asset_registry() + filter = unreal.ARFilter( + class_names=["LevelSequence"], + package_paths=[f"{str(path.as_posix())}"], + recursive_paths=False) + sequences = ar.get_assets(filter) - asset_content = unreal.EditorAssetLibrary.list_assets( + if not sequences: + raise Exception("Could not find sequence.") + + world = ar.get_asset_by_object_path( + EditorLevelLibrary.get_editor_world().get_path_name()) + + filter = unreal.ARFilter( + class_names=["World"], + package_paths=[f"{parent_path}"], + recursive_paths=True) + maps = ar.get_assets(filter) + + # There should be only one map in the list + if not maps: + raise Exception("Could not find map.") + + map = maps[0] + + EditorLevelLibrary.save_all_dirty_levels() + EditorLevelLibrary.load_level(map.get_full_name()) + + # Remove the camera from the level. + actors = EditorLevelLibrary.get_all_level_actors() + + for a in actors: + if a.__class__ == unreal.CineCameraActor: + EditorLevelLibrary.destroy_actor(a) + + EditorLevelLibrary.save_all_dirty_levels() + EditorLevelLibrary.load_level(world.get_full_name()) + + # There should be only one sequence in the path. + sequence_name = sequences[0].asset_name + + # Remove the Level Sequence from the parent. + # We need to traverse the hierarchy from the master sequence to find + # the level sequence. + root = "/Game/OpenPype" + namespace = container.get('namespace').replace(f"{root}/", "") + ms_asset = namespace.split('/')[0] + filter = unreal.ARFilter( + class_names=["LevelSequence"], + package_paths=[f"{root}/{ms_asset}"], + recursive_paths=False) + sequences = ar.get_assets(filter) + master_sequence = sequences[0].get_asset() + + sequences = [master_sequence] + + parent = None + for s in sequences: + tracks = s.get_master_tracks() + subscene_track = None + for t in tracks: + if t.get_class() == unreal.MovieSceneSubTrack.static_class(): + subscene_track = t + break + if subscene_track: + sections = subscene_track.get_sections() + for ss in sections: + if ss.get_sequence().get_name() == sequence_name: + parent = s + subscene_track.remove_section(ss) + break + sequences.append(ss.get_sequence()) + # Update subscenes indexes. + i = 0 + for ss in sections: + ss.set_row_index(i) + i += 1 + + if parent: + break + + assert parent, "Could not find the parent sequence" + + EditorAssetLibrary.delete_directory(str(path.as_posix())) + + # Check if there isn't any more assets in the parent folder, and + # delete it if not. + asset_content = EditorAssetLibrary.list_assets( parent_path, recursive=False, include_folder=True ) if len(asset_content) == 0: - unreal.EditorAssetLibrary.delete_directory(parent_path) + EditorAssetLibrary.delete_directory(parent_path) diff --git a/openpype/hosts/unreal/plugins/load/load_layout.py b/openpype/hosts/unreal/plugins/load/load_layout.py index 7f6ce7d822..c65cd25ac8 100644 --- a/openpype/hosts/unreal/plugins/load/load_layout.py +++ b/openpype/hosts/unreal/plugins/load/load_layout.py @@ -7,6 +7,7 @@ from pathlib import Path import unreal from unreal import EditorAssetLibrary from unreal import EditorLevelLibrary +from unreal import EditorLevelUtils from unreal import AssetToolsHelpers from unreal import FBXImportType from unreal import MathLibrary as umath @@ -17,7 +18,9 @@ from openpype.pipeline import ( load_container, get_representation_path, AVALON_CONTAINER_ID, + legacy_io, ) +from openpype.api import get_asset from openpype.hosts.unreal.api import plugin from openpype.hosts.unreal.api import pipeline as unreal_pipeline @@ -31,7 +34,7 @@ class LayoutLoader(plugin.Loader): label = "Load Layout" icon = "code-fork" color = "orange" - ASSET_ROOT = "/Game/OpenPype/Assets" + ASSET_ROOT = "/Game/OpenPype" def _get_asset_containers(self, path): ar = unreal.AssetRegistryHelpers.get_asset_registry() @@ -86,10 +89,93 @@ class LayoutLoader(plugin.Loader): return None @staticmethod - def _process_family(assets, class_name, transform, inst_name=None): + def _get_data(asset_name): + asset_doc = legacy_io.find_one({ + "type": "asset", + "name": asset_name + }) + + return asset_doc.get("data") + + @staticmethod + def _set_sequence_hierarchy( + seq_i, seq_j, max_frame_i, min_frame_j, max_frame_j, map_paths + ): + # Get existing sequencer tracks or create them if they don't exist + tracks = seq_i.get_master_tracks() + subscene_track = None + visibility_track = None + for t in tracks: + if t.get_class() == unreal.MovieSceneSubTrack.static_class(): + subscene_track = t + if (t.get_class() == + unreal.MovieSceneLevelVisibilityTrack.static_class()): + visibility_track = t + if not subscene_track: + subscene_track = seq_i.add_master_track(unreal.MovieSceneSubTrack) + if not visibility_track: + visibility_track = seq_i.add_master_track( + unreal.MovieSceneLevelVisibilityTrack) + + # Create the sub-scene section + subscenes = subscene_track.get_sections() + subscene = None + for s in subscenes: + if s.get_editor_property('sub_sequence') == seq_j: + subscene = s + break + if not subscene: + subscene = subscene_track.add_section() + subscene.set_row_index(len(subscene_track.get_sections())) + subscene.set_editor_property('sub_sequence', seq_j) + subscene.set_range( + min_frame_j, + max_frame_j + 1) + + # Create the visibility section + ar = unreal.AssetRegistryHelpers.get_asset_registry() + maps = [] + for m in map_paths: + # Unreal requires to load the level to get the map name + EditorLevelLibrary.save_all_dirty_levels() + EditorLevelLibrary.load_level(m) + maps.append(str(ar.get_asset_by_object_path(m).asset_name)) + + vis_section = visibility_track.add_section() + index = len(visibility_track.get_sections()) + + vis_section.set_range( + min_frame_j, + max_frame_j + 1) + vis_section.set_visibility(unreal.LevelVisibility.VISIBLE) + vis_section.set_row_index(index) + vis_section.set_level_names(maps) + + if min_frame_j > 1: + hid_section = visibility_track.add_section() + hid_section.set_range( + 1, + min_frame_j) + hid_section.set_visibility(unreal.LevelVisibility.HIDDEN) + hid_section.set_row_index(index) + hid_section.set_level_names(maps) + if max_frame_j < max_frame_i: + hid_section = visibility_track.add_section() + hid_section.set_range( + max_frame_j + 1, + max_frame_i + 1) + hid_section.set_visibility(unreal.LevelVisibility.HIDDEN) + hid_section.set_row_index(index) + hid_section.set_level_names(maps) + + @staticmethod + def _process_family( + assets, class_name, transform, sequence, inst_name=None + ): ar = unreal.AssetRegistryHelpers.get_asset_registry() actors = [] + bindings = [] for asset in assets: obj = ar.get_asset_by_object_path(asset).get_asset() @@ -119,14 +205,30 @@ class LayoutLoader(plugin.Loader): ), False) actor.set_actor_scale3d(transform.get('scale')) + if class_name == 'SkeletalMesh': + skm_comp = actor.get_editor_property( + 'skeletal_mesh_component') + skm_comp.set_bounds_scale(10.0) + actors.append(actor) - return actors + binding = None + for p in sequence.get_possessables(): + if p.get_name() == actor.get_name(): + binding = p + break + + if not binding: + binding = sequence.add_possessable(actor) + + bindings.append(binding) + + return actors, bindings - @staticmethod def _import_animation( - asset_dir, path, instance_name, skeleton, actors_dict, - animation_file): + self, asset_dir, path, instance_name, skeleton, actors_dict, + animation_file, bindings_dict, sequence + ): anim_file = Path(animation_file) anim_file_name = anim_file.with_suffix('') @@ -164,13 +266,15 @@ class LayoutLoader(plugin.Loader): task.options.anim_sequence_import_data.set_editor_property( 'import_meshes_in_bone_hierarchy', False) task.options.anim_sequence_import_data.set_editor_property( - 'use_default_sample_rate', True) + 'use_default_sample_rate', False) + task.options.anim_sequence_import_data.set_editor_property( + 'custom_sample_rate', get_asset()["data"].get("fps")) task.options.anim_sequence_import_data.set_editor_property( 'import_custom_attribute', True) task.options.anim_sequence_import_data.set_editor_property( 'import_bone_tracks', True) task.options.anim_sequence_import_data.set_editor_property( - 'remove_redundant_keys', True) + 'remove_redundant_keys', False) task.options.anim_sequence_import_data.set_editor_property( 'convert_scene', True) @@ -205,7 +309,104 @@ class LayoutLoader(plugin.Loader): actor.skeletal_mesh_component.animation_data.set_editor_property( 'anim_to_play', animation) - def _process(self, lib_path, asset_dir, loaded=None): + # Add animation to the sequencer + bindings = bindings_dict.get(instance_name) + + ar = unreal.AssetRegistryHelpers.get_asset_registry() + + for binding in bindings: + tracks = binding.get_tracks() + track = None + track = tracks[0] if tracks else binding.add_track( + unreal.MovieSceneSkeletalAnimationTrack) + + sections = track.get_sections() + section = None + if not sections: + section = track.add_section() + else: + section = sections[0] + + sec_params = section.get_editor_property('params') + curr_anim = sec_params.get_editor_property('animation') + + if curr_anim: + # Checks if the animation path has a container. + # If it does, it means that the animation is already + # in the sequencer. + anim_path = str(Path( + curr_anim.get_path_name()).parent + ).replace('\\', '/') + + _filter = unreal.ARFilter( + class_names=["AssetContainer"], + package_paths=[anim_path], + recursive_paths=False) + containers = ar.get_assets(_filter) + + if len(containers) > 0: + return + + section.set_range( + sequence.get_playback_start(), + sequence.get_playback_end()) + sec_params = section.get_editor_property('params') + sec_params.set_editor_property('animation', animation) + + @staticmethod + def _generate_sequence(self, h, h_dir): + tools = unreal.AssetToolsHelpers().get_asset_tools() + + sequence = tools.create_asset( + asset_name=h, + package_path=h_dir, + asset_class=unreal.LevelSequence, + factory=unreal.LevelSequenceFactoryNew() + ) + + asset_data = legacy_io.find_one({ + "type": "asset", + "name": h_dir.split('/')[-1] + }) + + id = asset_data.get('_id') + + start_frames = [] + end_frames = [] + + elements = list( + legacy_io.find({"type": "asset", "data.visualParent": id})) + for e in elements: + start_frames.append(e.get('data').get('clipIn')) + end_frames.append(e.get('data').get('clipOut')) + + elements.extend(legacy_io.find({ + "type": "asset", + "data.visualParent": e.get('_id') + })) + + min_frame = min(start_frames) + max_frame = max(end_frames) + + sequence.set_display_rate( + unreal.FrameRate(asset_data.get('data').get("fps"), 1.0)) + sequence.set_playback_start(min_frame) + sequence.set_playback_end(max_frame) + + tracks = sequence.get_master_tracks() + track = None + for t in tracks: + if (t.get_class() == + unreal.MovieSceneCameraCutTrack.static_class()): + track = t + break + if not track: + track = sequence.add_master_track( + unreal.MovieSceneCameraCutTrack) + + return sequence, (min_frame, max_frame) + + def _process(self, lib_path, asset_dir, sequence, loaded=None): ar = unreal.AssetRegistryHelpers.get_asset_registry() with open(lib_path, "r") as fp: @@ -220,6 +421,9 @@ class LayoutLoader(plugin.Loader): skeleton_dict = {} actors_dict = {} + bindings_dict = {} + + loaded_assets = [] for element in data: reference = None @@ -255,7 +459,7 @@ class LayoutLoader(plugin.Loader): continue options = { - "asset_dir": asset_dir + # "asset_dir": asset_dir } assets = load_container( @@ -265,6 +469,17 @@ class LayoutLoader(plugin.Loader): options=options ) + container = None + + for asset in assets: + obj = ar.get_asset_by_object_path(asset).get_asset() + if obj.get_class().get_name() == 'AssetContainer': + container = obj + if obj.get_class().get_name() == 'Skeleton': + skeleton = obj + + loaded_assets.append(container.get_path_name()) + instances = [ item for item in data if (item.get('reference_fbx') == reference or @@ -277,21 +492,13 @@ class LayoutLoader(plugin.Loader): actors = [] if family == 'model': - actors = self._process_family( - assets, 'StaticMesh', transform, inst) + actors, _ = self._process_family( + assets, 'StaticMesh', transform, sequence, inst) elif family == 'rig': - actors = self._process_family( - assets, 'SkeletalMesh', transform, inst) + actors, bindings = self._process_family( + assets, 'SkeletalMesh', transform, sequence, inst) actors_dict[inst] = actors - - if family == 'rig': - # Finds skeleton among the imported assets - for asset in assets: - obj = ar.get_asset_by_object_path(asset).get_asset() - if obj.get_class().get_name() == 'Skeleton': - skeleton = obj - if skeleton: - break + bindings_dict[inst] = bindings if skeleton: skeleton_dict[reference] = skeleton @@ -302,8 +509,10 @@ class LayoutLoader(plugin.Loader): if animation_file and skeleton: self._import_animation( - asset_dir, path, instance_name, skeleton, - actors_dict, animation_file) + asset_dir, path, instance_name, skeleton, actors_dict, + animation_file, bindings_dict, sequence) + + return loaded_assets @staticmethod def _remove_family(assets, components, class_name, prop_name): @@ -369,23 +578,106 @@ class LayoutLoader(plugin.Loader): list(str): list of container content """ # Create directory for asset and avalon container + hierarchy = context.get('asset').get('data').get('parents') root = self.ASSET_ROOT + hierarchy_dir = root + hierarchy_dir_list = [] + for h in hierarchy: + hierarchy_dir = f"{hierarchy_dir}/{h}" + hierarchy_dir_list.append(hierarchy_dir) asset = context.get('asset').get('name') suffix = "_CON" - if asset: - asset_name = "{}_{}".format(asset, name) - else: - asset_name = "{}".format(name) + asset_name = f"{asset}_{name}" if asset else name tools = unreal.AssetToolsHelpers().get_asset_tools() asset_dir, container_name = tools.create_unique_asset_name( - "{}/{}/{}".format(root, asset, name), suffix="") + "{}/{}/{}".format(hierarchy_dir, asset, name), suffix="") container_name += suffix EditorAssetLibrary.make_directory(asset_dir) - self._process(self.fname, asset_dir) + # Create map for the shot, and create hierarchy of map. If the maps + # already exist, we will use them. + h_dir = hierarchy_dir_list[0] + h_asset = hierarchy[0] + master_level = f"{h_dir}/{h_asset}_map.{h_asset}_map" + if not EditorAssetLibrary.does_asset_exist(master_level): + EditorLevelLibrary.new_level(f"{h_dir}/{h_asset}_map") + + level = f"{asset_dir}/{asset}_map.{asset}_map" + EditorLevelLibrary.new_level(f"{asset_dir}/{asset}_map") + + EditorLevelLibrary.load_level(master_level) + EditorLevelUtils.add_level_to_world( + EditorLevelLibrary.get_editor_world(), + level, + unreal.LevelStreamingDynamic + ) + EditorLevelLibrary.save_all_dirty_levels() + EditorLevelLibrary.load_level(level) + + # Get all the sequences in the hierarchy. It will create them, if + # they don't exist. + sequences = [] + frame_ranges = [] + for (h_dir, h) in zip(hierarchy_dir_list, hierarchy): + root_content = EditorAssetLibrary.list_assets( + h_dir, recursive=False, include_folder=False) + + existing_sequences = [ + EditorAssetLibrary.find_asset_data(asset) + for asset in root_content + if EditorAssetLibrary.find_asset_data( + asset).get_class().get_name() == 'LevelSequence' + ] + + if not existing_sequences: + sequence, frame_range = self._generate_sequence(h, h_dir) + + sequences.append(sequence) + frame_ranges.append(frame_range) + else: + for e in existing_sequences: + sequences.append(e.get_asset()) + frame_ranges.append(( + e.get_asset().get_playback_start(), + e.get_asset().get_playback_end())) + + shot = tools.create_asset( + asset_name=asset, + package_path=asset_dir, + asset_class=unreal.LevelSequence, + factory=unreal.LevelSequenceFactoryNew() + ) + + # sequences and frame_ranges have the same length + for i in range(0, len(sequences) - 1): + self._set_sequence_hierarchy( + sequences[i], sequences[i + 1], + frame_ranges[i][1], + frame_ranges[i + 1][0], frame_ranges[i + 1][1], + [level]) + + data = self._get_data(asset) + shot.set_display_rate( + unreal.FrameRate(data.get("fps"), 1.0)) + shot.set_playback_start(0) + shot.set_playback_end(data.get('clipOut') - data.get('clipIn') + 1) + self._set_sequence_hierarchy( + sequences[-1], shot, + frame_ranges[-1][1], + data.get('clipIn'), data.get('clipOut'), + [level]) + + EditorLevelLibrary.load_level(level) + + loaded_assets = self._process(self.fname, asset_dir, shot) + + for s in sequences: + EditorAssetLibrary.save_asset(s.get_full_name()) + + EditorLevelLibrary.save_current_level() # Create Asset Container unreal_pipeline.create_container( @@ -401,7 +693,8 @@ class LayoutLoader(plugin.Loader): "loader": str(self.__class__.__name__), "representation": context["representation"]["_id"], "parent": context["representation"]["parent"], - "family": context["representation"]["context"]["family"] + "family": context["representation"]["context"]["family"], + "loaded_assets": loaded_assets } unreal_pipeline.imprint( "{}/{}".format(asset_dir, container_name), data) @@ -412,146 +705,192 @@ class LayoutLoader(plugin.Loader): for a in asset_content: EditorAssetLibrary.save_asset(a) + EditorLevelLibrary.load_level(master_level) + return asset_content def update(self, container, representation): ar = unreal.AssetRegistryHelpers.get_asset_registry() + root = "/Game/OpenPype" + + asset_dir = container.get('namespace') + + context = representation.get("context") + + hierarchy = context.get('hierarchy').split("/") + h_dir = f"{root}/{hierarchy[0]}" + h_asset = hierarchy[0] + master_level = f"{h_dir}/{h_asset}_map.{h_asset}_map" + + # # Create a temporary level to delete the layout level. + # EditorLevelLibrary.save_all_dirty_levels() + # EditorAssetLibrary.make_directory(f"{root}/tmp") + # tmp_level = f"{root}/tmp/temp_map" + # if not EditorAssetLibrary.does_asset_exist(f"{tmp_level}.temp_map"): + # EditorLevelLibrary.new_level(tmp_level) + # else: + # EditorLevelLibrary.load_level(tmp_level) + + # Get layout level + filter = unreal.ARFilter( + class_names=["World"], + package_paths=[asset_dir], + recursive_paths=False) + levels = ar.get_assets(filter) + filter = unreal.ARFilter( + class_names=["LevelSequence"], + package_paths=[asset_dir], + recursive_paths=False) + sequences = ar.get_assets(filter) + + layout_level = levels[0].get_editor_property('object_path') + + EditorLevelLibrary.save_all_dirty_levels() + EditorLevelLibrary.load_level(layout_level) + + # Delete all the actors in the level + actors = unreal.EditorLevelLibrary.get_all_level_actors() + for actor in actors: + unreal.EditorLevelLibrary.destroy_actor(actor) + + EditorLevelLibrary.save_current_level() + + EditorAssetLibrary.delete_directory(f"{asset_dir}/animations/") + source_path = get_representation_path(representation) - destination_path = container["namespace"] - lib_path = Path(get_representation_path(representation)) - self._remove_actors(destination_path) + loaded_assets = self._process( + source_path, asset_dir, sequences[0].get_asset()) - # Delete old animations - anim_path = f"{destination_path}/animations/" - EditorAssetLibrary.delete_directory(anim_path) - - with open(source_path, "r") as fp: - data = json.load(fp) - - references = [e.get('reference_fbx') for e in data] - asset_containers = self._get_asset_containers(destination_path) - loaded = [] - - # Delete all the assets imported with the previous version of the - # layout, if they're not in the new layout. - for asset_container in asset_containers: - if asset_container.get_editor_property( - 'asset_name') == container["objectName"]: - continue - ref = EditorAssetLibrary.get_metadata_tag( - asset_container.get_asset(), 'representation') - ppath = asset_container.get_editor_property('package_path') - - if ref not in references: - # If the asset is not in the new layout, delete it. - # Also check if the parent directory is empty, and delete that - # as well, if it is. - EditorAssetLibrary.delete_directory(ppath) - - parent = os.path.dirname(str(ppath)) - parent_content = EditorAssetLibrary.list_assets( - parent, recursive=False, include_folder=True - ) - - if len(parent_content) == 0: - EditorAssetLibrary.delete_directory(parent) - else: - # If the asset is in the new layout, search the instances in - # the JSON file, and create actors for them. - - actors_dict = {} - skeleton_dict = {} - - for element in data: - reference = element.get('reference_fbx') - instance_name = element.get('instance_name') - - skeleton = None - - if reference == ref and ref not in loaded: - loaded.append(ref) - - family = element.get('family') - - assets = EditorAssetLibrary.list_assets( - ppath, recursive=True, include_folder=False) - - instances = [ - item for item in data - if item.get('reference_fbx') == reference] - - for instance in instances: - transform = instance.get('transform') - inst = instance.get('instance_name') - - actors = [] - - if family == 'model': - actors = self._process_family( - assets, 'StaticMesh', transform, inst) - elif family == 'rig': - actors = self._process_family( - assets, 'SkeletalMesh', transform, inst) - actors_dict[inst] = actors - - if family == 'rig': - # Finds skeleton among the imported assets - for asset in assets: - obj = ar.get_asset_by_object_path( - asset).get_asset() - if obj.get_class().get_name() == 'Skeleton': - skeleton = obj - if skeleton: - break - - if skeleton: - skeleton_dict[reference] = skeleton - else: - skeleton = skeleton_dict.get(reference) - - animation_file = element.get('animation') - - if animation_file and skeleton: - self._import_animation( - destination_path, lib_path, - instance_name, skeleton, - actors_dict, animation_file) - - self._process(source_path, destination_path, loaded) - - container_path = "{}/{}".format(container["namespace"], - container["objectName"]) - # update metadata + data = { + "representation": str(representation["_id"]), + "parent": str(representation["parent"]), + "loaded_assets": loaded_assets + } unreal_pipeline.imprint( - container_path, - { - "representation": str(representation["_id"]), - "parent": str(representation["parent"]) - }) + "{}/{}".format(asset_dir, container.get('container_name')), data) + + EditorLevelLibrary.save_current_level() asset_content = EditorAssetLibrary.list_assets( - destination_path, recursive=True, include_folder=False) + asset_dir, recursive=True, include_folder=False) for a in asset_content: EditorAssetLibrary.save_asset(a) + EditorLevelLibrary.load_level(master_level) + def remove(self, container): """ - First, destroy all actors of the assets to be removed. Then, deletes - the asset's directory. + Delete the layout. First, check if the assets loaded with the layout + are used by other layouts. If not, delete the assets. """ - path = container["namespace"] - parent_path = os.path.dirname(path) + path = Path(container.get("namespace")) - self._remove_actors(path) + containers = unreal_pipeline.ls() + layout_containers = [ + c for c in containers + if (c.get('asset_name') != container.get('asset_name') and + c.get('family') == "layout")] - EditorAssetLibrary.delete_directory(path) + # Check if the assets have been loaded by other layouts, and deletes + # them if they haven't. + for asset in container.get('loaded_assets'): + layouts = [ + lc for lc in layout_containers + if asset in lc.get('loaded_assets')] + if not layouts: + EditorAssetLibrary.delete_directory(str(Path(asset).parent)) + + # Remove the Level Sequence from the parent. + # We need to traverse the hierarchy from the master sequence to find + # the level sequence. + root = "/Game/OpenPype" + namespace = container.get('namespace').replace(f"{root}/", "") + ms_asset = namespace.split('/')[0] + ar = unreal.AssetRegistryHelpers.get_asset_registry() + _filter = unreal.ARFilter( + class_names=["LevelSequence"], + package_paths=[f"{root}/{ms_asset}"], + recursive_paths=False) + sequences = ar.get_assets(_filter) + master_sequence = sequences[0].get_asset() + _filter = unreal.ARFilter( + class_names=["World"], + package_paths=[f"{root}/{ms_asset}"], + recursive_paths=False) + levels = ar.get_assets(_filter) + master_level = levels[0].get_editor_property('object_path') + + sequences = [master_sequence] + + parent = None + for s in sequences: + tracks = s.get_master_tracks() + subscene_track = None + visibility_track = None + for t in tracks: + if t.get_class() == unreal.MovieSceneSubTrack.static_class(): + subscene_track = t + if (t.get_class() == + unreal.MovieSceneLevelVisibilityTrack.static_class()): + visibility_track = t + if subscene_track: + sections = subscene_track.get_sections() + for ss in sections: + if ss.get_sequence().get_name() == container.get('asset'): + parent = s + subscene_track.remove_section(ss) + break + sequences.append(ss.get_sequence()) + # Update subscenes indexes. + i = 0 + for ss in sections: + ss.set_row_index(i) + i += 1 + + if visibility_track: + sections = visibility_track.get_sections() + for ss in sections: + if (unreal.Name(f"{container.get('asset')}_map") + in ss.get_level_names()): + visibility_track.remove_section(ss) + # Update visibility sections indexes. + i = -1 + prev_name = [] + for ss in sections: + if prev_name != ss.get_level_names(): + i += 1 + ss.set_row_index(i) + prev_name = ss.get_level_names() + if parent: + break + + assert parent, "Could not find the parent sequence" + + # Create a temporary level to delete the layout level. + EditorLevelLibrary.save_all_dirty_levels() + EditorAssetLibrary.make_directory(f"{root}/tmp") + tmp_level = f"{root}/tmp/temp_map" + if not EditorAssetLibrary.does_asset_exist(f"{tmp_level}.temp_map"): + EditorLevelLibrary.new_level(tmp_level) + else: + EditorLevelLibrary.load_level(tmp_level) + + # Delete the layout directory. + EditorAssetLibrary.delete_directory(str(path)) + + EditorLevelLibrary.load_level(master_level) + EditorAssetLibrary.delete_directory(f"{root}/tmp") + + EditorLevelLibrary.save_current_level() + + # Delete the parent folder if there aren't any more layouts in it. asset_content = EditorAssetLibrary.list_assets( - parent_path, recursive=False, include_folder=True + str(path.parent), recursive=False, include_folder=True ) if len(asset_content) == 0: - EditorAssetLibrary.delete_directory(parent_path) + EditorAssetLibrary.delete_directory(str(path.parent)) diff --git a/openpype/hosts/unreal/plugins/load/load_rig.py b/openpype/hosts/unreal/plugins/load/load_rig.py index ff844a5e94..c27bd23aaf 100644 --- a/openpype/hosts/unreal/plugins/load/load_rig.py +++ b/openpype/hosts/unreal/plugins/load/load_rig.py @@ -52,54 +52,55 @@ class SkeletalMeshFBXLoader(plugin.Loader): asset_name = "{}_{}".format(asset, name) else: asset_name = "{}".format(name) + version = context.get('version').get('name') tools = unreal.AssetToolsHelpers().get_asset_tools() asset_dir, container_name = tools.create_unique_asset_name( - "{}/{}/{}".format(root, asset, name), suffix="") + f"{root}/{asset}/{name}_v{version:03d}", suffix="") container_name += suffix - unreal.EditorAssetLibrary.make_directory(asset_dir) + if not unreal.EditorAssetLibrary.does_directory_exist(asset_dir): + unreal.EditorAssetLibrary.make_directory(asset_dir) - task = unreal.AssetImportTask() + task = unreal.AssetImportTask() - task.set_editor_property('filename', self.fname) - task.set_editor_property('destination_path', asset_dir) - task.set_editor_property('destination_name', asset_name) - task.set_editor_property('replace_existing', False) - task.set_editor_property('automated', True) - task.set_editor_property('save', False) + task.set_editor_property('filename', self.fname) + task.set_editor_property('destination_path', asset_dir) + task.set_editor_property('destination_name', asset_name) + task.set_editor_property('replace_existing', False) + task.set_editor_property('automated', True) + task.set_editor_property('save', False) - # set import options here - options = unreal.FbxImportUI() - options.set_editor_property('import_as_skeletal', True) - options.set_editor_property('import_animations', False) - options.set_editor_property('import_mesh', True) - options.set_editor_property('import_materials', True) - options.set_editor_property('import_textures', True) - options.set_editor_property('skeleton', None) - options.set_editor_property('create_physics_asset', False) + # set import options here + options = unreal.FbxImportUI() + options.set_editor_property('import_as_skeletal', True) + options.set_editor_property('import_animations', False) + options.set_editor_property('import_mesh', True) + options.set_editor_property('import_materials', False) + options.set_editor_property('import_textures', False) + options.set_editor_property('skeleton', None) + options.set_editor_property('create_physics_asset', False) - options.set_editor_property('mesh_type_to_import', - unreal.FBXImportType.FBXIT_SKELETAL_MESH) + options.set_editor_property( + 'mesh_type_to_import', + unreal.FBXImportType.FBXIT_SKELETAL_MESH) - options.skeletal_mesh_import_data.set_editor_property( - 'import_content_type', - unreal.FBXImportContentType.FBXICT_ALL - ) - # set to import normals, otherwise Unreal will compute them - # and it will take a long time, depending on the size of the mesh - options.skeletal_mesh_import_data.set_editor_property( - 'normal_import_method', - unreal.FBXNormalImportMethod.FBXNIM_IMPORT_NORMALS - ) + options.skeletal_mesh_import_data.set_editor_property( + 'import_content_type', + unreal.FBXImportContentType.FBXICT_ALL) + # set to import normals, otherwise Unreal will compute them + # and it will take a long time, depending on the size of the mesh + options.skeletal_mesh_import_data.set_editor_property( + 'normal_import_method', + unreal.FBXNormalImportMethod.FBXNIM_IMPORT_NORMALS) - task.options = options - unreal.AssetToolsHelpers.get_asset_tools().import_asset_tasks([task]) # noqa: E501 + task.options = options + unreal.AssetToolsHelpers.get_asset_tools().import_asset_tasks([task]) # noqa: E501 - # Create Asset Container - unreal_pipeline.create_container( - container=container_name, path=asset_dir) + # Create Asset Container + unreal_pipeline.create_container( + container=container_name, path=asset_dir) data = { "schema": "openpype:container-2.0", diff --git a/openpype/hosts/unreal/plugins/publish/collect_instances.py b/openpype/hosts/unreal/plugins/publish/collect_instances.py index 94e732d728..2f604cb322 100644 --- a/openpype/hosts/unreal/plugins/publish/collect_instances.py +++ b/openpype/hosts/unreal/plugins/publish/collect_instances.py @@ -17,7 +17,7 @@ class CollectInstances(pyblish.api.ContextPlugin): """ label = "Collect Instances" - order = pyblish.api.CollectorOrder + order = pyblish.api.CollectorOrder - 0.1 hosts = ["unreal"] def process(self, context): diff --git a/openpype/hosts/unreal/plugins/publish/collect_remove_marked.py b/openpype/hosts/unreal/plugins/publish/collect_remove_marked.py new file mode 100644 index 0000000000..69e69f6630 --- /dev/null +++ b/openpype/hosts/unreal/plugins/publish/collect_remove_marked.py @@ -0,0 +1,24 @@ +import pyblish.api + + +class CollectRemoveMarked(pyblish.api.ContextPlugin): + """Remove marked data + + Remove instances that have 'remove' in their instance.data + + """ + + order = pyblish.api.CollectorOrder + 0.499 + label = 'Remove Marked Instances' + + def process(self, context): + + self.log.debug(context) + # make ftrack publishable + instances_to_remove = [] + for instance in context: + if instance.data.get('remove'): + instances_to_remove.append(instance) + + for instance in instances_to_remove: + context.remove(instance) diff --git a/openpype/hosts/unreal/plugins/publish/collect_render_instances.py b/openpype/hosts/unreal/plugins/publish/collect_render_instances.py new file mode 100644 index 0000000000..9fb45ea7a7 --- /dev/null +++ b/openpype/hosts/unreal/plugins/publish/collect_render_instances.py @@ -0,0 +1,112 @@ +import os +from pathlib import Path + +import unreal + +from openpype.api import Anatomy +from openpype.hosts.unreal.api import pipeline +import pyblish.api + + +class CollectRenderInstances(pyblish.api.InstancePlugin): + """ This collector will try to find all the rendered frames. + + """ + order = pyblish.api.CollectorOrder + hosts = ["unreal"] + families = ["render"] + label = "Collect Render Instances" + + def process(self, instance): + self.log.debug("Preparing Rendering Instances") + + context = instance.context + + data = instance.data + data['remove'] = True + + ar = unreal.AssetRegistryHelpers.get_asset_registry() + + sequence = ar.get_asset_by_object_path( + data.get('sequence')).get_asset() + + sequences = [{ + "sequence": sequence, + "output": data.get('output'), + "frame_range": ( + data.get('frameStart'), data.get('frameEnd')) + }] + + for s in sequences: + self.log.debug(f"Processing: {s.get('sequence').get_name()}") + subscenes = pipeline.get_subsequences(s.get('sequence')) + + if subscenes: + for ss in subscenes: + sequences.append({ + "sequence": ss.get_sequence(), + "output": (f"{s.get('output')}/" + f"{ss.get_sequence().get_name()}"), + "frame_range": ( + ss.get_start_frame(), ss.get_end_frame() - 1) + }) + else: + # Avoid creating instances for camera sequences + if "_camera" not in s.get('sequence').get_name(): + seq = s.get('sequence') + seq_name = seq.get_name() + + new_instance = context.create_instance( + f"{data.get('subset')}_" + f"{seq_name}") + new_instance[:] = seq_name + + new_data = new_instance.data + + new_data["asset"] = seq_name + new_data["setMembers"] = seq_name + new_data["family"] = "render" + new_data["families"] = ["render", "review"] + new_data["parent"] = data.get("parent") + new_data["subset"] = f"{data.get('subset')}_{seq_name}" + new_data["level"] = data.get("level") + new_data["output"] = s.get('output') + new_data["fps"] = seq.get_display_rate().numerator + new_data["frameStart"] = s.get('frame_range')[0] + new_data["frameEnd"] = s.get('frame_range')[1] + new_data["sequence"] = seq.get_path_name() + new_data["master_sequence"] = data["master_sequence"] + new_data["master_level"] = data["master_level"] + + self.log.debug(f"new instance data: {new_data}") + + try: + project = os.environ.get("AVALON_PROJECT") + anatomy = Anatomy(project) + root = anatomy.roots['renders'] + except Exception: + raise Exception( + "Could not find render root in anatomy settings.") + + render_dir = f"{root}/{project}/{s.get('output')}" + render_path = Path(render_dir) + + frames = [] + + for x in render_path.iterdir(): + if x.is_file() and x.suffix == '.png': + frames.append(str(x.name)) + + if "representations" not in new_instance.data: + new_instance.data["representations"] = [] + + repr = { + 'frameStart': s.get('frame_range')[0], + 'frameEnd': s.get('frame_range')[1], + 'name': 'png', + 'ext': 'png', + 'files': frames, + 'stagingDir': render_dir, + 'tags': ['review'] + } + new_instance.data["representations"].append(repr) diff --git a/openpype/hosts/unreal/plugins/publish/extract_render.py b/openpype/hosts/unreal/plugins/publish/extract_render.py new file mode 100644 index 0000000000..37fe7e916f --- /dev/null +++ b/openpype/hosts/unreal/plugins/publish/extract_render.py @@ -0,0 +1,48 @@ +from pathlib import Path + +import unreal + +import openpype.api + + +class ExtractRender(openpype.api.Extractor): + """Extract render.""" + + label = "Extract Render" + hosts = ["unreal"] + families = ["render"] + optional = True + + def process(self, instance): + # Define extract output file path + stagingdir = self.staging_dir(instance) + + # Perform extraction + self.log.info("Performing extraction..") + + # Get the render output directory + project_dir = unreal.Paths.project_dir() + render_dir = (f"{project_dir}/Saved/MovieRenders/" + f"{instance.data['subset']}") + + assert unreal.Paths.directory_exists(render_dir), \ + "Render directory does not exist" + + render_path = Path(render_dir) + + frames = [] + + for x in render_path.iterdir(): + if x.is_file() and x.suffix == '.png': + frames.append(str(x)) + + if "representations" not in instance.data: + instance.data["representations"] = [] + + render_representation = { + 'name': 'png', + 'ext': 'png', + 'files': frames, + "stagingDir": stagingdir, + } + instance.data["representations"].append(render_representation) diff --git a/openpype/hosts/unreal/plugins/publish/validate_sequence_frames.py b/openpype/hosts/unreal/plugins/publish/validate_sequence_frames.py new file mode 100644 index 0000000000..87f1338ee8 --- /dev/null +++ b/openpype/hosts/unreal/plugins/publish/validate_sequence_frames.py @@ -0,0 +1,41 @@ +import clique + +import pyblish.api + + +class ValidateSequenceFrames(pyblish.api.InstancePlugin): + """Ensure the sequence of frames is complete + + The files found in the folder are checked against the frameStart and + frameEnd of the instance. If the first or last file is not + corresponding with the first or last frame it is flagged as invalid. + """ + + order = pyblish.api.ValidatorOrder + label = "Validate Sequence Frames" + families = ["render"] + hosts = ["unreal"] + optional = True + + def process(self, instance): + representations = instance.data.get("representations") + for repr in representations: + patterns = [clique.PATTERNS["frames"]] + collections, remainder = clique.assemble( + repr["files"], minimum_items=1, patterns=patterns) + + assert not remainder, "Must not have remainder" + assert len(collections) == 1, "Must detect single collection" + collection = collections[0] + frames = list(collection.indexes) + + current_range = (frames[0], frames[-1]) + required_range = (instance.data["frameStart"], + instance.data["frameEnd"]) + + if current_range != required_range: + raise ValueError(f"Invalid frame range: {current_range} - " + f"expected: {required_range}") + + missing = collection.holes().indexes + assert not missing, "Missing frames: %s" % (missing,) diff --git a/openpype/lib/__init__.py b/openpype/lib/__init__.py index 29719b63bd..8d4e733b7d 100644 --- a/openpype/lib/__init__.py +++ b/openpype/lib/__init__.py @@ -42,12 +42,12 @@ from .attribute_definitions import ( EnumDef, BoolDef, FileDef, + FileDefItem, ) from .env_tools import ( env_value_to_bool, get_paths_from_environ, - get_global_environments ) from .terminal import Terminal @@ -203,6 +203,7 @@ from .editorial import ( is_overlapping_otio_ranges, otio_range_to_frame_range, otio_range_with_handles, + get_media_range_with_retimes, convert_to_padded_path, trim_media_range, range_from_frames, @@ -248,7 +249,6 @@ __all__ = [ "env_value_to_bool", "get_paths_from_environ", - "get_global_environments", "get_vendor_bin_path", "get_oiio_tools_path", @@ -267,6 +267,7 @@ __all__ = [ "EnumDef", "BoolDef", "FileDef", + "FileDefItem", "import_filepath", "modules_from_path", @@ -382,6 +383,7 @@ __all__ = [ "otio_range_with_handles", "convert_to_padded_path", "otio_range_to_frame_range", + "get_media_range_with_retimes", "trim_media_range", "range_from_frames", "frames_to_secons", diff --git a/openpype/lib/applications.py b/openpype/lib/applications.py index b52da52dc9..6ade33b59c 100644 --- a/openpype/lib/applications.py +++ b/openpype/lib/applications.py @@ -1012,8 +1012,8 @@ class ApplicationLaunchContext: self.log.debug("Discovery of launch hooks started.") paths = self.paths_to_launch_hooks() - self.log.debug("Paths where will look for launch hooks:{}".format( - "\n- ".join(paths) + self.log.debug("Paths searched for launch hooks:\n{}".format( + "\n".join("- {}".format(path) for path in paths) )) all_classes = { @@ -1023,7 +1023,7 @@ class ApplicationLaunchContext: for path in paths: if not os.path.exists(path): self.log.info( - "Path to launch hooks does not exists: \"{}\"".format(path) + "Path to launch hooks does not exist: \"{}\"".format(path) ) continue @@ -1044,13 +1044,14 @@ class ApplicationLaunchContext: hook = klass(self) if not hook.is_valid: self.log.debug( - "Hook is not valid for current launch context." + "Skipped hook invalid for current launch context: " + "{}".format(klass.__name__) ) continue if inspect.isabstract(hook): self.log.debug("Skipped abstract hook: {}".format( - str(hook) + klass.__name__ )) continue @@ -1062,7 +1063,8 @@ class ApplicationLaunchContext: except Exception: self.log.warning( - "Initialization of hook failed. {}".format(str(klass)), + "Initialization of hook failed: " + "{}".format(klass.__name__), exc_info=True ) diff --git a/openpype/lib/attribute_definitions.py b/openpype/lib/attribute_definitions.py index 189a5e7acd..a1f7c1e0f4 100644 --- a/openpype/lib/attribute_definitions.py +++ b/openpype/lib/attribute_definitions.py @@ -1,8 +1,12 @@ +import os import re import collections import uuid +import json from abc import ABCMeta, abstractmethod + import six +import clique class AbstractAttrDefMeta(ABCMeta): @@ -302,12 +306,230 @@ class BoolDef(AbtractAttrDef): return self.default +class FileDefItem(object): + def __init__( + self, directory, filenames, frames=None, template=None + ): + self.directory = directory + + self.filenames = [] + self.is_sequence = False + self.template = None + self.frames = [] + self.is_empty = True + + self.set_filenames(filenames, frames, template) + + def __str__(self): + return json.dumps(self.to_dict()) + + def __repr__(self): + if self.is_empty: + filename = "< empty >" + elif self.is_sequence: + filename = self.template + else: + filename = self.filenames[0] + + return "<{}: \"{}\">".format( + self.__class__.__name__, + os.path.join(self.directory, filename) + ) + + @property + def label(self): + if self.is_empty: + return None + + if not self.is_sequence: + return self.filenames[0] + + frame_start = self.frames[0] + filename_template = os.path.basename(self.template) + if len(self.frames) == 1: + return "{} [{}]".format(filename_template, frame_start) + + frame_end = self.frames[-1] + expected_len = (frame_end - frame_start) + 1 + if expected_len == len(self.frames): + return "{} [{}-{}]".format( + filename_template, frame_start, frame_end + ) + + ranges = [] + _frame_start = None + _frame_end = None + for frame in range(frame_start, frame_end + 1): + if frame not in self.frames: + add_to_ranges = _frame_start is not None + elif _frame_start is None: + _frame_start = _frame_end = frame + add_to_ranges = frame == frame_end + else: + _frame_end = frame + add_to_ranges = frame == frame_end + + if add_to_ranges: + if _frame_start != _frame_end: + _range = "{}-{}".format(_frame_start, _frame_end) + else: + _range = str(_frame_start) + ranges.append(_range) + _frame_start = _frame_end = None + return "{} [{}]".format( + filename_template, ",".join(ranges) + ) + + def split_sequence(self): + if not self.is_sequence: + raise ValueError("Cannot split single file item") + + paths = [ + os.path.join(self.directory, filename) + for filename in self.filenames + ] + return self.from_paths(paths, False) + + @property + def ext(self): + if self.is_empty: + return None + _, ext = os.path.splitext(self.filenames[0]) + if ext: + return ext + return None + + @property + def is_dir(self): + if self.is_empty: + return False + + # QUESTION a better way how to define folder (in init argument?) + if self.ext: + return False + return True + + def set_directory(self, directory): + self.directory = directory + + def set_filenames(self, filenames, frames=None, template=None): + if frames is None: + frames = [] + is_sequence = False + if frames: + is_sequence = True + + if is_sequence and not template: + raise ValueError("Missing template for sequence") + + self.is_empty = len(filenames) == 0 + self.filenames = filenames + self.template = template + self.frames = frames + self.is_sequence = is_sequence + + @classmethod + def create_empty_item(cls): + return cls("", "") + + @classmethod + def from_value(cls, value, allow_sequences): + """Convert passed value to FileDefItem objects. + + Returns: + list: Created FileDefItem objects. + """ + + # Convert single item to iterable + if not isinstance(value, (list, tuple, set)): + value = [value] + + output = [] + str_filepaths = [] + for item in value: + if isinstance(item, dict): + item = cls.from_dict(item) + + if isinstance(item, FileDefItem): + if not allow_sequences and item.is_sequence: + output.extend(item.split_sequence()) + else: + output.append(item) + + elif isinstance(item, six.string_types): + str_filepaths.append(item) + else: + raise TypeError( + "Unknown type \"{}\". Can't convert to {}".format( + str(type(item)), cls.__name__ + ) + ) + + if str_filepaths: + output.extend(cls.from_paths(str_filepaths, allow_sequences)) + + return output + + @classmethod + def from_dict(cls, data): + return cls( + data["directory"], + data["filenames"], + data.get("frames"), + data.get("template") + ) + + @classmethod + def from_paths(cls, paths, allow_sequences): + filenames_by_dir = collections.defaultdict(list) + for path in paths: + normalized = os.path.normpath(path) + directory, filename = os.path.split(normalized) + filenames_by_dir[directory].append(filename) + + output = [] + for directory, filenames in filenames_by_dir.items(): + if allow_sequences: + cols, remainders = clique.assemble(filenames) + else: + cols = [] + remainders = filenames + + for remainder in remainders: + output.append(cls(directory, [remainder])) + + for col in cols: + frames = list(col.indexes) + paths = [filename for filename in col] + template = col.format("{head}{padding}{tail}") + + output.append(cls( + directory, paths, frames, template + )) + + return output + + def to_dict(self): + output = { + "is_sequence": self.is_sequence, + "directory": self.directory, + "filenames": list(self.filenames), + } + if self.is_sequence: + output.update({ + "template": self.template, + "frames": list(sorted(self.frames)), + }) + + return output + + class FileDef(AbtractAttrDef): """File definition. It is possible to define filters of allowed file extensions and if supports folders. Args: - multipath(bool): Allow multiple path. + single_item(bool): Allow only single path item. folders(bool): Allow folder paths. extensions(list): Allow files with extensions. Empty list will allow all extensions and None will disable files completely. @@ -315,44 +537,47 @@ class FileDef(AbtractAttrDef): """ def __init__( - self, key, multipath=False, folders=None, extensions=None, - default=None, **kwargs + self, key, single_item=True, folders=None, extensions=None, + allow_sequences=True, default=None, **kwargs ): if folders is None and extensions is None: folders = True extensions = [] if default is None: - if multipath: - default = [] + if single_item: + default = FileDefItem.create_empty_item().to_dict() else: - default = "" + default = [] else: - if multipath: + if single_item: + if isinstance(default, dict): + FileDefItem.from_dict(default) + + elif isinstance(default, six.string_types): + default = FileDefItem.from_paths([default.strip()])[0] + + else: + raise TypeError(( + "'default' argument must be 'str' or 'dict' not '{}'" + ).format(type(default))) + + else: if not isinstance(default, (tuple, list, set)): raise TypeError(( "'default' argument must be 'list', 'tuple' or 'set'" ", not '{}'" ).format(type(default))) - else: - if not isinstance(default, six.string_types): - raise TypeError(( - "'default' argument must be 'str' not '{}'" - ).format(type(default))) - default = default.strip() - # Change horizontal label is_label_horizontal = kwargs.get("is_label_horizontal") if is_label_horizontal is None: - is_label_horizontal = True - if multipath: - is_label_horizontal = False - kwargs["is_label_horizontal"] = is_label_horizontal + kwargs["is_label_horizontal"] = False - self.multipath = multipath + self.single_item = single_item self.folders = folders - self.extensions = extensions + self.extensions = set(extensions) + self.allow_sequences = allow_sequences super(FileDef, self).__init__(key, default=default, **kwargs) def __eq__(self, other): @@ -360,30 +585,43 @@ class FileDef(AbtractAttrDef): return False return ( - self.multipath == other.multipath + self.single_item == other.single_item and self.folders == other.folders and self.extensions == other.extensions + and self.allow_sequences == other.allow_sequences ) def convert_value(self, value): - if isinstance(value, six.string_types): - if self.multipath: - value = [value.strip()] - else: - value = value.strip() - return value + if isinstance(value, six.string_types) or isinstance(value, dict): + value = [value] if isinstance(value, (tuple, list, set)): - _value = [] + string_paths = [] + dict_items = [] for item in value: if isinstance(item, six.string_types): - _value.append(item.strip()) + string_paths.append(item.strip()) + elif isinstance(item, dict): + try: + FileDefItem.from_dict(item) + dict_items.append(item) + except (ValueError, KeyError): + pass - if self.multipath: - return _value + if string_paths: + file_items = FileDefItem.from_paths(string_paths) + dict_items.extend([ + file_item.to_dict() + for file_item in file_items + ]) - if not _value: + if not self.single_item: + return dict_items + + if not dict_items: return self.default - return _value[0].strip() + return dict_items[0] - return str(value).strip() + if self.single_item: + return FileDefItem.create_empty_item().to_dict() + return [] diff --git a/openpype/lib/avalon_context.py b/openpype/lib/avalon_context.py index 3fcddef745..9d8a92cfe9 100644 --- a/openpype/lib/avalon_context.py +++ b/openpype/lib/avalon_context.py @@ -1532,13 +1532,13 @@ class BuildWorkfile: subsets = list(legacy_io.find({ "type": "subset", - "parent": {"$in": asset_entity_by_ids.keys()} + "parent": {"$in": list(asset_entity_by_ids.keys())} })) subset_entity_by_ids = {subset["_id"]: subset for subset in subsets} sorted_versions = list(legacy_io.find({ "type": "version", - "parent": {"$in": subset_entity_by_ids.keys()} + "parent": {"$in": list(subset_entity_by_ids.keys())} }).sort("name", -1)) subset_id_with_latest_version = [] @@ -1552,7 +1552,7 @@ class BuildWorkfile: repres = legacy_io.find({ "type": "representation", - "parent": {"$in": last_versions_by_id.keys()} + "parent": {"$in": list(last_versions_by_id.keys())} }) output = {} diff --git a/openpype/lib/editorial.py b/openpype/lib/editorial.py index bf868953ea..32d6dc3688 100644 --- a/openpype/lib/editorial.py +++ b/openpype/lib/editorial.py @@ -17,7 +17,7 @@ def otio_range_to_frame_range(otio_range): start = _ot.to_frames( otio_range.start_time, otio_range.start_time.rate) end = start + _ot.to_frames( - otio_range.duration, otio_range.duration.rate) - 1 + otio_range.duration, otio_range.duration.rate) return start, end @@ -168,7 +168,7 @@ def make_sequence_collection(path, otio_range, metadata): first, last = otio_range_to_frame_range(otio_range) collection = clique.Collection( head=head, tail=tail, padding=metadata["padding"]) - collection.indexes.update([i for i in range(first, (last + 1))]) + collection.indexes.update([i for i in range(first, last)]) return dir_path, collection @@ -218,6 +218,7 @@ def get_media_range_with_retimes(otio_clip, handle_start, handle_end): "name": name } tw_node.update(metadata) + tw_node["lookup"] = list(lookup) # get first and last frame offsets offset_in += lookup[0] @@ -269,16 +270,16 @@ def get_media_range_with_retimes(otio_clip, handle_start, handle_end): "retime": True, "speed": time_scalar, "timewarps": time_warp_nodes, - "handleStart": handle_start, - "handleEnd": handle_end + "handleStart": round(handle_start), + "handleEnd": round(handle_end) } } returning_dict = { "mediaIn": media_in_trimmed, "mediaOut": media_out_trimmed, - "handleStart": handle_start, - "handleEnd": handle_end + "handleStart": round(handle_start), + "handleEnd": round(handle_end) } # add version data only if retime diff --git a/openpype/lib/env_tools.py b/openpype/lib/env_tools.py index 6521d20f1e..25bcbf7c1b 100644 --- a/openpype/lib/env_tools.py +++ b/openpype/lib/env_tools.py @@ -69,57 +69,3 @@ def get_paths_from_environ(env_key=None, env_value=None, return_first=False): return None # Return all existing paths from environment variable return existing_paths - - -def get_global_environments(env=None): - """Load global environments from Pype. - - Return prepared and parsed global environments by pype's settings. Use - combination of "global" environments set in pype's settings and enabled - modules. - - Args: - env (dict, optional): Initial environments. Empty dictionary is used - when not entered. - - Returns; - dict of str: Loaded and processed environments. - - """ - import acre - from openpype.modules import ModulesManager - from openpype.settings import get_environments - - if env is None: - env = {} - - # Get global environments from settings - all_settings_env = get_environments() - parsed_global_env = acre.parse(all_settings_env["global"]) - - # Merge with entered environments - merged_env = acre.append(env, parsed_global_env) - - # Get environments from Pype modules - modules_manager = ModulesManager() - - module_envs = modules_manager.collect_global_environments() - publish_plugin_dirs = modules_manager.collect_plugin_paths()["publish"] - - # Set pyblish plugins paths if any module want to register them - if publish_plugin_dirs: - publish_paths_str = os.environ.get("PYBLISHPLUGINPATH") or "" - publish_paths = publish_paths_str.split(os.pathsep) - _publish_paths = { - os.path.normpath(path) for path in publish_paths if path - } - for path in publish_plugin_dirs: - _publish_paths.add(os.path.normpath(path)) - module_envs["PYBLISHPLUGINPATH"] = os.pathsep.join(_publish_paths) - - # Merge environments with current environments and update values - if module_envs: - parsed_envs = acre.parse(module_envs) - merged_env = acre.merge(parsed_envs, merged_env) - - return acre.compute(merged_env, cleanup=True) diff --git a/openpype/lib/path_templates.py b/openpype/lib/path_templates.py index 14e5fe59f8..5c40aa4549 100644 --- a/openpype/lib/path_templates.py +++ b/openpype/lib/path_templates.py @@ -365,6 +365,7 @@ class TemplateResult(str): when value of key in data is dictionary but template expect string of number. """ + used_values = None solved = None template = None @@ -383,6 +384,12 @@ class TemplateResult(str): new_obj.invalid_types = invalid_types return new_obj + def __copy__(self, *args, **kwargs): + return self.copy() + + def __deepcopy__(self, *args, **kwargs): + return self.copy() + def validate(self): if not self.solved: raise TemplateUnsolved( @@ -391,6 +398,17 @@ class TemplateResult(str): self.invalid_types ) + def copy(self): + cls = self.__class__ + return cls( + str(self), + self.template, + self.solved, + self.used_values, + self.missing_keys, + self.invalid_types + ) + class TemplatesResultDict(dict): """Holds and wrap TemplateResults for easy bug report.""" diff --git a/openpype/lib/terminal.py b/openpype/lib/terminal.py index 5121b6ec26..f6072ed209 100644 --- a/openpype/lib/terminal.py +++ b/openpype/lib/terminal.py @@ -98,7 +98,7 @@ class Terminal: r"\*\*\* WRN": _SB + _LY + r"*** WRN" + _RST, r" \- ": _SB + _LY + r" - " + _RST, r"\[ ": _SB + _LG + r"[ " + _RST, - r"\]": _SB + _LG + r"]" + _RST, + r" \]": _SB + _LG + r" ]" + _RST, r"{": _LG + r"{", r"}": r"}" + _RST, r"\(": _LY + r"(", diff --git a/openpype/lib/transcoding.py b/openpype/lib/transcoding.py index fcec5d4216..adb9bb2c3a 100644 --- a/openpype/lib/transcoding.py +++ b/openpype/lib/transcoding.py @@ -493,8 +493,9 @@ def convert_for_ffmpeg( erase_reason = "has too long value ({} chars).".format( len(attr_value) ) + erase_attribute = True - if erase_attribute: + if not erase_attribute: for char in NOT_ALLOWED_FFMPEG_CHARS: if char in attr_value: erase_attribute = True @@ -623,8 +624,9 @@ def convert_input_paths_for_ffmpeg( erase_reason = "has too long value ({} chars).".format( len(attr_value) ) + erase_attribute = True - if erase_attribute: + if not erase_attribute: for char in NOT_ALLOWED_FFMPEG_CHARS: if char in attr_value: erase_attribute = True @@ -727,9 +729,9 @@ def get_ffmpeg_format_args(ffprobe_data, source_ffmpeg_cmd=None): def _ffmpeg_mxf_format_args(ffprobe_data, source_ffmpeg_cmd): input_format = ffprobe_data["format"] format_tags = input_format.get("tags") or {} - product_name = format_tags.get("product_name") or "" + operational_pattern_ul = format_tags.get("operational_pattern_ul") or "" output = [] - if "opatom" in product_name.lower(): + if operational_pattern_ul == "060e2b34.04010102.0d010201.10030000": output.extend(["-f", "mxf_opatom"]) return output diff --git a/openpype/modules/base.py b/openpype/modules/base.py index 23c908299f..5b49649359 100644 --- a/openpype/modules/base.py +++ b/openpype/modules/base.py @@ -290,49 +290,16 @@ def _load_modules(): log = PypeLogger.get_logger("ModulesLoader") - current_dir = os.path.abspath(os.path.dirname(__file__)) - processed_paths = set() - processed_paths.add(current_dir) - # Import default modules imported from 'openpype.modules' - for filename in os.listdir(current_dir): - # Ignore filenames - if ( - filename in IGNORED_FILENAMES - or filename in IGNORED_DEFAULT_FILENAMES - ): - continue - - fullpath = os.path.join(current_dir, filename) - basename, ext = os.path.splitext(filename) - - if os.path.isdir(fullpath): - # Check existence of init fil - init_path = os.path.join(fullpath, "__init__.py") - if not os.path.exists(init_path): - log.debug(( - "Module directory does not contan __init__.py file {}" - ).format(fullpath)) - continue - - elif ext not in (".py", ): - continue - - try: - import_str = "openpype.modules.{}".format(basename) - new_import_str = "{}.{}".format(modules_key, basename) - default_module = __import__(import_str, fromlist=("", )) - sys.modules[new_import_str] = default_module - setattr(openpype_modules, basename, default_module) - - except Exception: - msg = ( - "Failed to import default module '{}'." - ).format(basename) - log.error(msg, exc_info=True) - # Look for OpenPype modules in paths defined with `get_module_dirs` # - dynamically imported OpenPype modules and addons - for dirpath in get_module_dirs(): + module_dirs = get_module_dirs() + # Add current directory at first place + # - has small differences in import logic + current_dir = os.path.abspath(os.path.dirname(__file__)) + module_dirs.insert(0, current_dir) + + processed_paths = set() + for dirpath in module_dirs: # Skip already processed paths if dirpath in processed_paths: continue @@ -344,20 +311,29 @@ def _load_modules(): ).format(dirpath)) continue + is_in_current_dir = dirpath == current_dir for filename in os.listdir(dirpath): # Ignore filenames if filename in IGNORED_FILENAMES: continue + if ( + is_in_current_dir + and filename in IGNORED_DEFAULT_FILENAMES + ): + continue + fullpath = os.path.join(dirpath, filename) basename, ext = os.path.splitext(filename) + # Validations if os.path.isdir(fullpath): - # Check existence of init fil + # Check existence of init file init_path = os.path.join(fullpath, "__init__.py") if not os.path.exists(init_path): log.debug(( - "Module directory does not contan __init__.py file {}" + "Module directory does not contain __init__.py" + " file {}" ).format(fullpath)) continue @@ -367,27 +343,29 @@ def _load_modules(): # TODO add more logic how to define if folder is module or not # - check manifest and content of manifest try: - if os.path.isdir(fullpath): - # Module without init file can't be used as OpenPype module - # because the module class could not be imported - init_file = os.path.join(fullpath, "__init__.py") - if not os.path.exists(init_file): - log.info(( - "Skipping module directory because of" - " missing \"__init__.py\" file. \"{}\"" - ).format(fullpath)) - continue + # Don't import dynamically current directory modules + if is_in_current_dir: + import_str = "openpype.modules.{}".format(basename) + new_import_str = "{}.{}".format(modules_key, basename) + default_module = __import__(import_str, fromlist=("", )) + sys.modules[new_import_str] = default_module + setattr(openpype_modules, basename, default_module) + + elif os.path.isdir(fullpath): import_module_from_dirpath(dirpath, filename, modules_key) - elif ext in (".py", ): + else: module = import_filepath(fullpath) setattr(openpype_modules, basename, module) except Exception: - log.error( - "Failed to import '{}'.".format(fullpath), - exc_info=True - ) + if is_in_current_dir: + msg = "Failed to import default module '{}'.".format( + basename + ) + else: + msg = "Failed to import module '{}'.".format(fullpath) + log.error(msg, exc_info=True) class _OpenPypeInterfaceMeta(ABCMeta): @@ -724,6 +702,32 @@ class ModulesManager: ).format(expected_keys, " | ".join(msg_items))) return output + def collect_creator_plugin_paths(self, host_name): + """Helper to collect creator plugin paths from modules. + + Args: + host_name (str): For which host are creators meants. + + Returns: + list: List of creator plugin paths. + """ + # Output structure + from openpype_interfaces import IPluginPaths + + output = [] + for module in self.get_enabled_modules(): + # Skip module that do not inherit from `IPluginPaths` + if not isinstance(module, IPluginPaths): + continue + + paths = module.get_creator_plugin_paths(host_name) + if paths: + # Convert to list if value is not list + if not isinstance(paths, (list, tuple, set)): + paths = [paths] + output.extend(paths) + return output + def collect_launch_hook_paths(self): """Helper to collect hooks from modules inherited ILaunchHookPaths. diff --git a/openpype/modules/deadline/plugins/publish/submit_maya_deadline.py b/openpype/modules/deadline/plugins/publish/submit_maya_deadline.py index 8f776a3371..8562c85f7d 100644 --- a/openpype/modules/deadline/plugins/publish/submit_maya_deadline.py +++ b/openpype/modules/deadline/plugins/publish/submit_maya_deadline.py @@ -440,7 +440,10 @@ class MayaSubmitDeadline(pyblish.api.InstancePlugin): output_filename_0 = filename_0 - dirname = os.path.dirname(output_filename_0) + # this is needed because renderman handles directory and file + # prefixes separately + if self._instance.data["renderer"] == "renderman": + dirname = os.path.dirname(output_filename_0) # Create render folder ---------------------------------------------- try: diff --git a/openpype/modules/deadline/plugins/publish/submit_publish_job.py b/openpype/modules/deadline/plugins/publish/submit_publish_job.py index 306237c78c..78ab935e42 100644 --- a/openpype/modules/deadline/plugins/publish/submit_publish_job.py +++ b/openpype/modules/deadline/plugins/publish/submit_publish_job.py @@ -466,7 +466,7 @@ class ProcessSubmittedJobOnFarm(pyblish.api.InstancePlugin): if instance_data.get("multipartExr"): preview = True - new_instance = copy(instance_data) + new_instance = deepcopy(instance_data) new_instance["subset"] = subset_name new_instance["subsetGroup"] = group_name if preview: @@ -883,8 +883,10 @@ class ProcessSubmittedJobOnFarm(pyblish.api.InstancePlugin): new_i = copy(i) new_i["version"] = at.get("version") new_i["subset"] = at.get("subset") + new_i["family"] = at.get("family") new_i["append"] = True - new_i["families"].append(at.get("family")) + # don't set subsetGroup if we are attaching + new_i.pop("subsetGroup") new_instances.append(new_i) self.log.info(" - {} / v{}".format( at.get("subset"), at.get("version"))) diff --git a/openpype/modules/ftrack/event_handlers_server/action_push_frame_values_to_task.py b/openpype/modules/ftrack/event_handlers_server/action_push_frame_values_to_task.py index 868bbb8463..1209375f82 100644 --- a/openpype/modules/ftrack/event_handlers_server/action_push_frame_values_to_task.py +++ b/openpype/modules/ftrack/event_handlers_server/action_push_frame_values_to_task.py @@ -356,7 +356,7 @@ class PushHierValuesToNonHier(ServerAction): values_per_entity_id[entity_id][key] = None values = query_custom_attributes( - session, all_ids_with_parents, hier_attr_ids, True + session, hier_attr_ids, all_ids_with_parents, True ) for item in values: entity_id = item["entity_id"] diff --git a/openpype/modules/ftrack/event_handlers_user/action_delete_old_versions.py b/openpype/modules/ftrack/event_handlers_user/action_delete_old_versions.py index f5addde8ae..a0bf6622e9 100644 --- a/openpype/modules/ftrack/event_handlers_user/action_delete_old_versions.py +++ b/openpype/modules/ftrack/event_handlers_user/action_delete_old_versions.py @@ -569,7 +569,7 @@ class DeleteOldVersions(BaseAction): context["frame"] = self.sequence_splitter sequence_path = os.path.normpath( StringTemplate.format_strict_template( - context, template + template, context ) ) diff --git a/openpype/modules/ftrack/lib/avalon_sync.py b/openpype/modules/ftrack/lib/avalon_sync.py index 124787e467..e4ba651bfd 100644 --- a/openpype/modules/ftrack/lib/avalon_sync.py +++ b/openpype/modules/ftrack/lib/avalon_sync.py @@ -143,14 +143,17 @@ def create_chunks(iterable, chunk_size=None): list: Chunked items. """ chunks = [] - if not iterable: - return chunks tupled_iterable = tuple(iterable) + if not tupled_iterable: + return chunks iterable_size = len(tupled_iterable) if chunk_size is None: chunk_size = 200 + if chunk_size < 1: + chunk_size = 1 + for idx in range(0, iterable_size, chunk_size): chunks.append(tupled_iterable[idx:idx + chunk_size]) return chunks diff --git a/openpype/modules/ftrack/lib/credentials.py b/openpype/modules/ftrack/lib/credentials.py index 4e29e66382..2eb64254d1 100644 --- a/openpype/modules/ftrack/lib/credentials.py +++ b/openpype/modules/ftrack/lib/credentials.py @@ -92,14 +92,18 @@ def check_credentials(username, api_key, ftrack_server=None): if not ftrack_server or not username or not api_key: return False + user_exists = False try: session = ftrack_api.Session( server_url=ftrack_server, api_key=api_key, api_user=username ) + # Validated that the username actually exists + user = session.query("User where username is \"{}\"".format(username)) + user_exists = user is not None session.close() except Exception: - return False - return True + pass + return user_exists diff --git a/openpype/modules/ftrack/plugins/publish/integrate_ftrack_api.py b/openpype/modules/ftrack/plugins/publish/integrate_ftrack_api.py index 650c59fae8..c4f7b1f05d 100644 --- a/openpype/modules/ftrack/plugins/publish/integrate_ftrack_api.py +++ b/openpype/modules/ftrack/plugins/publish/integrate_ftrack_api.py @@ -12,6 +12,7 @@ Provides: import os import sys +import collections import six import pyblish.api import clique @@ -24,48 +25,6 @@ class IntegrateFtrackApi(pyblish.api.InstancePlugin): label = "Integrate Ftrack Api" families = ["ftrack"] - def query(self, entitytype, data): - """ Generate a query expression from data supplied. - - If a value is not a string, we'll add the id of the entity to the - query. - - Args: - entitytype (str): The type of entity to query. - data (dict): The data to identify the entity. - exclusions (list): All keys to exclude from the query. - - Returns: - str: String query to use with "session.query" - """ - queries = [] - if sys.version_info[0] < 3: - for key, value in data.iteritems(): - if not isinstance(value, (basestring, int)): - self.log.info("value: {}".format(value)) - if "id" in value.keys(): - queries.append( - "{0}.id is \"{1}\"".format(key, value["id"]) - ) - else: - queries.append("{0} is \"{1}\"".format(key, value)) - else: - for key, value in data.items(): - if not isinstance(value, (str, int)): - self.log.info("value: {}".format(value)) - if "id" in value.keys(): - queries.append( - "{0}.id is \"{1}\"".format(key, value["id"]) - ) - else: - queries.append("{0} is \"{1}\"".format(key, value)) - - query = ( - "select id from " + entitytype + " where " + " and ".join(queries) - ) - self.log.debug(query) - return query - def process(self, instance): session = instance.context.data["ftrackSession"] context = instance.context @@ -108,12 +67,25 @@ class IntegrateFtrackApi(pyblish.api.InstancePlugin): default_asset_name = parent_entity["name"] # Change status on task - self._set_task_status(instance, task_entity, session) + asset_version_status_ids_by_name = {} + project_entity = instance.context.data.get("ftrackProject") + if project_entity: + project_schema = project_entity["project_schema"] + asset_version_statuses = ( + project_schema.get_statuses("AssetVersion") + ) + asset_version_status_ids_by_name = { + status["name"].lower(): status["id"] + for status in asset_version_statuses + } + + self._set_task_status(instance, project_entity, task_entity, session) # Prepare AssetTypes asset_types_by_short = self._ensure_asset_types_exists( session, component_list ) + self._fill_component_locations(session, component_list) asset_versions_data_by_id = {} used_asset_versions = [] @@ -139,7 +111,11 @@ class IntegrateFtrackApi(pyblish.api.InstancePlugin): # Asset Version asset_version_data = data.get("assetversion_data") or {} asset_version_entity = self._ensure_asset_version_exists( - session, asset_version_data, asset_entity["id"], task_entity + session, + asset_version_data, + asset_entity["id"], + task_entity, + asset_version_status_ids_by_name ) # Component @@ -174,8 +150,7 @@ class IntegrateFtrackApi(pyblish.api.InstancePlugin): if asset_version not in instance.data[asset_versions_key]: instance.data[asset_versions_key].append(asset_version) - def _set_task_status(self, instance, task_entity, session): - project_entity = instance.context.data.get("ftrackProject") + def _set_task_status(self, instance, project_entity, task_entity, session): if not project_entity: self.log.info("Task status won't be set, project is not known.") return @@ -220,6 +195,70 @@ class IntegrateFtrackApi(pyblish.api.InstancePlugin): session._configure_locations() six.reraise(tp, value, tb) + def _fill_component_locations(self, session, component_list): + components_by_location_name = collections.defaultdict(list) + components_by_location_id = collections.defaultdict(list) + for component_item in component_list: + # Location entity can be prefilled + # - this is not recommended as connection to ftrack server may + # be lost and in that case the entity is not valid when gets + # to this plugin + location = component_item.get("component_location") + if location is not None: + continue + + # Collect location id + location_id = component_item.get("component_location_id") + if location_id: + components_by_location_id[location_id].append( + component_item + ) + continue + + location_name = component_item.get("component_location_name") + if location_name: + components_by_location_name[location_name].append( + component_item + ) + continue + + # Skip if there is nothing to do + if not components_by_location_name and not components_by_location_id: + return + + # Query locations + query_filters = [] + if components_by_location_id: + joined_location_ids = ",".join([ + '"{}"'.format(location_id) + for location_id in components_by_location_id + ]) + query_filters.append("id in ({})".format(joined_location_ids)) + + if components_by_location_name: + joined_location_names = ",".join([ + '"{}"'.format(location_name) + for location_name in components_by_location_name + ]) + query_filters.append("name in ({})".format(joined_location_names)) + + locations = session.query( + "select id, name from Location where {}".format( + " or ".join(query_filters) + ) + ).all() + # Fill locations in components + for location in locations: + location_id = location["id"] + location_name = location["name"] + if location_id in components_by_location_id: + for component in components_by_location_id[location_id]: + component["component_location"] = location + + if location_name in components_by_location_name: + for component in components_by_location_name[location_name]: + component["component_location"] = location + def _ensure_asset_types_exists(self, session, component_list): """Make sure that all AssetType entities exists for integration. @@ -319,12 +358,19 @@ class IntegrateFtrackApi(pyblish.api.InstancePlugin): ).first() def _ensure_asset_version_exists( - self, session, asset_version_data, asset_id, task_entity + self, + session, + asset_version_data, + asset_id, + task_entity, + status_ids_by_name ): task_id = None if task_entity: task_id = task_entity["id"] + status_name = asset_version_data.pop("status_name", None) + # Try query asset version by criteria (asset id and version) version = asset_version_data.get("version") or 0 asset_version_entity = self._query_asset_version( @@ -366,6 +412,18 @@ class IntegrateFtrackApi(pyblish.api.InstancePlugin): session, version, asset_id ) + if status_name: + status_id = status_ids_by_name.get(status_name.lower()) + if not status_id: + self.log.info(( + "Ftrack status with name \"{}\"" + " for AssetVersion was not found." + ).format(status_name)) + + elif asset_version_entity["status_id"] != status_id: + asset_version_entity["status_id"] = status_id + session.commit() + # Set custom attributes if there were any set custom_attrs = asset_version_data.get("custom_attributes") or {} for attr_key, attr_value in custom_attrs.items(): diff --git a/openpype/modules/ftrack/plugins/publish/integrate_ftrack_instances.py b/openpype/modules/ftrack/plugins/publish/integrate_ftrack_instances.py index 5ea0469bce..c8d9e4117d 100644 --- a/openpype/modules/ftrack/plugins/publish/integrate_ftrack_instances.py +++ b/openpype/modules/ftrack/plugins/publish/integrate_ftrack_instances.py @@ -3,6 +3,9 @@ import json import copy import pyblish.api +from openpype.lib import get_ffprobe_streams +from openpype.lib.profiles_filtering import filter_profiles + class IntegrateFtrackInstance(pyblish.api.InstancePlugin): """Collect ftrack component data (not integrate yet). @@ -36,6 +39,7 @@ class IntegrateFtrackInstance(pyblish.api.InstancePlugin): "reference": "reference" } keep_first_subset_name_for_review = True + asset_versions_status_profiles = {} def process(self, instance): self.log.debug("instance {}".format(instance)) @@ -80,6 +84,8 @@ class IntegrateFtrackInstance(pyblish.api.InstancePlugin): if instance_fps is None: instance_fps = instance.context.data["fps"] + status_name = self._get_asset_version_status_name(instance) + # Base of component item data # - create a copy of this object when want to use it base_component_item = { @@ -91,7 +97,8 @@ class IntegrateFtrackInstance(pyblish.api.InstancePlugin): }, "assetversion_data": { "version": version_number, - "comment": instance.context.data.get("comment") or "" + "comment": instance.context.data.get("comment") or "", + "status_name": status_name }, "component_overwrite": False, # This can be change optionally @@ -99,11 +106,10 @@ class IntegrateFtrackInstance(pyblish.api.InstancePlugin): # These must be changed for each component "component_data": None, "component_path": None, - "component_location": None + "component_location": None, + "component_location_name": None } - ft_session = instance.context.data["ftrackSession"] - # Filter types of representations review_representations = [] thumbnail_representations = [] @@ -121,12 +127,8 @@ class IntegrateFtrackInstance(pyblish.api.InstancePlugin): other_representations.append(repre) # Prepare ftrack locations - unmanaged_location = ft_session.query( - "Location where name is \"ftrack.unmanaged\"" - ).one() - ftrack_server_location = ft_session.query( - "Location where name is \"ftrack.server\"" - ).one() + unmanaged_location_name = "ftrack.unmanaged" + ftrack_server_location_name = "ftrack.server" # Components data component_list = [] @@ -136,6 +138,7 @@ class IntegrateFtrackInstance(pyblish.api.InstancePlugin): # Create thumbnail components # TODO what if there is multiple thumbnails? first_thumbnail_component = None + first_thumbnail_component_repre = None for repre in thumbnail_representations: published_path = repre.get("published_path") if not published_path: @@ -163,12 +166,49 @@ class IntegrateFtrackInstance(pyblish.api.InstancePlugin): src_components_to_add.append(copy.deepcopy(thumbnail_item)) # Create copy of first thumbnail if first_thumbnail_component is None: - first_thumbnail_component = copy.deepcopy(thumbnail_item) + first_thumbnail_component_repre = repre + first_thumbnail_component = thumbnail_item # Set location - thumbnail_item["component_location"] = ftrack_server_location + thumbnail_item["component_location_name"] = ( + ftrack_server_location_name + ) + # Add item to component list component_list.append(thumbnail_item) + if ( + not review_representations + and first_thumbnail_component is not None + ): + width = first_thumbnail_component_repre.get("width") + height = first_thumbnail_component_repre.get("height") + if not width or not height: + component_path = first_thumbnail_component["component_path"] + streams = [] + try: + streams = get_ffprobe_streams(component_path) + except Exception: + self.log.debug(( + "Failed to retrieve information about intput {}" + ).format(component_path)) + + for stream in streams: + if "width" in stream and "height" in stream: + width = stream["width"] + height = stream["height"] + break + + if width and height: + component_data = first_thumbnail_component["component_data"] + component_data["name"] = "ftrackreview-image" + component_data["metadata"] = { + "ftr_meta": json.dumps({ + "width": width, + "height": height, + "format": "image" + }) + } + # Create review components # Change asset name of each new component for review is_first_review_repre = True @@ -254,7 +294,9 @@ class IntegrateFtrackInstance(pyblish.api.InstancePlugin): src_components_to_add.append(copy.deepcopy(review_item)) # Set location - review_item["component_location"] = ftrack_server_location + review_item["component_location_name"] = ( + ftrack_server_location_name + ) # Add item to component list component_list.append(review_item) @@ -266,8 +308,8 @@ class IntegrateFtrackInstance(pyblish.api.InstancePlugin): first_thumbnail_component ) new_thumbnail_component["asset_data"]["name"] = asset_name - new_thumbnail_component["component_location"] = ( - ftrack_server_location + new_thumbnail_component["component_location_name"] = ( + ftrack_server_location_name ) component_list.append(new_thumbnail_component) @@ -276,7 +318,7 @@ class IntegrateFtrackInstance(pyblish.api.InstancePlugin): # Make sure thumbnail is disabled copy_src_item["thumbnail"] = False # Set location - copy_src_item["component_location"] = unmanaged_location + copy_src_item["component_location_name"] = unmanaged_location_name # Modify name of component to have suffix "_src" component_data = copy_src_item["component_data"] component_name = component_data["name"] @@ -301,7 +343,7 @@ class IntegrateFtrackInstance(pyblish.api.InstancePlugin): other_item["component_data"] = { "name": repre["name"] } - other_item["component_location"] = unmanaged_location + other_item["component_location_name"] = unmanaged_location_name other_item["component_path"] = published_path component_list.append(other_item) @@ -317,3 +359,24 @@ class IntegrateFtrackInstance(pyblish.api.InstancePlugin): ) )) instance.data["ftrackComponentsList"] = component_list + + def _get_asset_version_status_name(self, instance): + if not self.asset_versions_status_profiles: + return None + + # Prepare filtering data for new asset version status + anatomy_data = instance.data["anatomyData"] + task_type = anatomy_data.get("task", {}).get("type") + filtering_criteria = { + "families": instance.data["family"], + "hosts": instance.context.data["hostName"], + "task_types": task_type + } + matching_profile = filter_profiles( + self.asset_versions_status_profiles, + filtering_criteria + ) + if not matching_profile: + return None + + return matching_profile["status"] or None diff --git a/openpype/modules/interfaces.py b/openpype/modules/interfaces.py index 13cbea690b..334485cab2 100644 --- a/openpype/modules/interfaces.py +++ b/openpype/modules/interfaces.py @@ -14,11 +14,38 @@ class IPluginPaths(OpenPypeInterface): "publish": ["path/to/publish_plugins"] } """ - # TODO validation of an output + @abstractmethod def get_plugin_paths(self): pass + def get_creator_plugin_paths(self, host_name): + """Retreive creator plugin paths. + + Give addons ability to add creator plugin paths based on host name. + + NOTES: + - Default implementation uses 'get_plugin_paths' and always return + all creator plugins. + - Host name may help to organize plugins by host, but each creator + alsomay have host filtering. + + Args: + host_name (str): For which host are the plugins meant. + """ + + paths = self.get_plugin_paths() + if not paths or "create" not in paths: + return [] + + create_paths = paths["create"] + if not create_paths: + return [] + + if not isinstance(create_paths, (list, tuple, set)): + create_paths = [create_paths] + return create_paths + class ILaunchHookPaths(OpenPypeInterface): """Module has launch hook paths to return. diff --git a/openpype/modules/sync_server/sync_server_module.py b/openpype/modules/sync_server/sync_server_module.py index 45ff8bc4d1..5a1d8467ec 100644 --- a/openpype/modules/sync_server/sync_server_module.py +++ b/openpype/modules/sync_server/sync_server_module.py @@ -921,12 +921,18 @@ class SyncServerModule(OpenPypeModule, ITrayModule): if self.enabled: for project in self.connection.projects(projection={"name": 1}): project_name = project["name"] - project_settings = self.get_sync_project_setting(project_name) - if project_settings and project_settings.get("enabled"): + if self.is_project_enabled(project_name): enabled_projects.append(project_name) return enabled_projects + def is_project_enabled(self, project_name): + if self.enabled: + project_settings = self.get_sync_project_setting(project_name) + if project_settings and project_settings.get("enabled"): + return True + return False + def handle_alternate_site(self, collection, representation, processed_site, file_id, synced_file_id): """ diff --git a/openpype/pipeline/context_tools.py b/openpype/pipeline/context_tools.py index 06bd639776..e849f5b0d1 100644 --- a/openpype/pipeline/context_tools.py +++ b/openpype/pipeline/context_tools.py @@ -12,7 +12,7 @@ import pyblish.api from pyblish.lib import MessageHandler import openpype -from openpype.modules import load_modules +from openpype.modules import load_modules, ModulesManager from openpype.settings import get_project_settings from openpype.lib import ( Anatomy, @@ -107,7 +107,7 @@ def install_host(host): install_openpype_plugins() -def install_openpype_plugins(project_name=None): +def install_openpype_plugins(project_name=None, host_name=None): # Make sure modules are loaded load_modules() @@ -116,6 +116,18 @@ def install_openpype_plugins(project_name=None): pyblish.api.register_discovery_filter(filter_pyblish_plugins) register_loader_plugin_path(LOAD_PATH) + modules_manager = ModulesManager() + publish_plugin_dirs = modules_manager.collect_plugin_paths()["publish"] + for path in publish_plugin_dirs: + pyblish.api.register_plugin_path(path) + + if host_name is None: + host_name = os.environ.get("AVALON_APP") + + creator_paths = modules_manager.collect_creator_plugin_paths(host_name) + for creator_path in creator_paths: + register_creator_plugin_path(creator_path) + if project_name is None: project_name = os.environ.get("AVALON_PROJECT") diff --git a/openpype/pipeline/create/context.py b/openpype/pipeline/create/context.py index 6f862e0588..2f1922c103 100644 --- a/openpype/pipeline/create/context.py +++ b/openpype/pipeline/create/context.py @@ -749,6 +749,10 @@ class CreateContext: """Is host valid for creation.""" return self._host_is_valid + @property + def host_name(self): + return os.environ["AVALON_APP"] + @property def log(self): """Dynamic access to logger.""" @@ -861,6 +865,17 @@ class CreateContext: "Using first and skipping following" )) continue + + # Filter by host name + if ( + creator_class.host_name + and creator_class.host_name != self.host_name + ): + self.log.info(( + "Creator's host name is not supported for current host {}" + ).format(creator_class.host_name, self.host_name)) + continue + creator = creator_class( self, system_settings, diff --git a/openpype/pipeline/create/creator_plugins.py b/openpype/pipeline/create/creator_plugins.py index cbe19da064..8006d4f4f8 100644 --- a/openpype/pipeline/create/creator_plugins.py +++ b/openpype/pipeline/create/creator_plugins.py @@ -63,6 +63,12 @@ class BaseCreator: # `openpype.pipeline.attribute_definitions` instance_attr_defs = [] + # Filtering by host name - can be used to be filtered by host name + # - used on all hosts when set to 'None' for Backwards compatibility + # - was added afterwards + # QUESTION make this required? + host_name = None + def __init__( self, create_context, system_settings, project_settings, headless=False ): diff --git a/openpype/pipeline/legacy_io.py b/openpype/pipeline/legacy_io.py index c41406b208..c8e7e79600 100644 --- a/openpype/pipeline/legacy_io.py +++ b/openpype/pipeline/legacy_io.py @@ -25,7 +25,7 @@ def install(): session = session_data_from_environment(context_keys=True) - session["schema"] = "openpype:session-2.0" + session["schema"] = "openpype:session-3.0" try: schema.validate(session) except schema.ValidationError as e: diff --git a/openpype/pipeline/plugin_discover.py b/openpype/pipeline/plugin_discover.py index fb860fe5f2..004e530b1c 100644 --- a/openpype/pipeline/plugin_discover.py +++ b/openpype/pipeline/plugin_discover.py @@ -59,7 +59,7 @@ class DiscoverResult: self.ignored_plugins ))) for cls in self.ignored_plugins: - lines.append("- {}".format(cls.__class__.__name__)) + lines.append("- {}".format(cls.__name__)) # Abstract classes if self.abstract_plugins or full_report: @@ -67,7 +67,7 @@ class DiscoverResult: self.abstract_plugins ))) for cls in self.abstract_plugins: - lines.append("- {}".format(cls.__class__.__name__)) + lines.append("- {}".format(cls.__name__)) # Abstract classes if self.duplicated_plugins or full_report: @@ -75,7 +75,7 @@ class DiscoverResult: self.duplicated_plugins ))) for cls in self.duplicated_plugins: - lines.append("- {}".format(cls.__class__.__name__)) + lines.append("- {}".format(cls.__name__)) if self.crashed_file_paths or full_report: lines.append("*** Failed to load {} files".format(len( diff --git a/openpype/plugins/publish/collect_hierarchy.py b/openpype/plugins/publish/collect_hierarchy.py index 4e94acce4a..8398a2815a 100644 --- a/openpype/plugins/publish/collect_hierarchy.py +++ b/openpype/plugins/publish/collect_hierarchy.py @@ -30,14 +30,15 @@ class CollectHierarchy(pyblish.api.ContextPlugin): # shot data dict shot_data = {} - family = instance.data.get("family") + family = instance.data["family"] + families = instance.data["families"] # filter out all unepropriate instances if not instance.data["publish"]: continue # exclude other families then self.families with intersection - if not set(self.families).intersection([family]): + if not set(self.families).intersection(set(families + [family])): continue # exclude if not masterLayer True @@ -61,7 +62,7 @@ class CollectHierarchy(pyblish.api.ContextPlugin): "frameEnd": instance.data["frameEnd"], "clipIn": instance.data["clipIn"], "clipOut": instance.data["clipOut"], - 'fps': instance.context.data["fps"], + "fps": instance.data["fps"], "resolutionWidth": instance.data["resolutionWidth"], "resolutionHeight": instance.data["resolutionHeight"], "pixelAspect": instance.data["pixelAspect"] diff --git a/openpype/plugins/publish/collect_otio_frame_ranges.py b/openpype/plugins/publish/collect_otio_frame_ranges.py index ee7b7957ad..8eaf9d6f29 100644 --- a/openpype/plugins/publish/collect_otio_frame_ranges.py +++ b/openpype/plugins/publish/collect_otio_frame_ranges.py @@ -55,13 +55,13 @@ class CollectOtioFrameRanges(pyblish.api.InstancePlugin): "frameStart": frame_start, "frameEnd": frame_end, "clipIn": tl_start, - "clipOut": tl_end, + "clipOut": tl_end - 1, "clipInH": tl_start_h, - "clipOutH": tl_end_h, + "clipOutH": tl_end_h - 1, "sourceStart": src_starting_from + src_start, - "sourceEnd": src_starting_from + src_end, + "sourceEnd": src_starting_from + src_end - 1, "sourceStartH": src_starting_from + src_start_h, - "sourceEndH": src_starting_from + src_end_h, + "sourceEndH": src_starting_from + src_end_h - 1, } instance.data.update(data) self.log.debug( diff --git a/openpype/plugins/publish/collect_otio_subset_resources.py b/openpype/plugins/publish/collect_otio_subset_resources.py index 7c11462ef0..40d4f35bdc 100644 --- a/openpype/plugins/publish/collect_otio_subset_resources.py +++ b/openpype/plugins/publish/collect_otio_subset_resources.py @@ -10,8 +10,7 @@ import os import clique import opentimelineio as otio import pyblish.api -import openpype -from openpype.lib import editorial +import openpype.lib as oplib class CollectOtioSubsetResources(pyblish.api.InstancePlugin): @@ -43,7 +42,7 @@ class CollectOtioSubsetResources(pyblish.api.InstancePlugin): available_duration = otio_avalable_range.duration.value # get available range trimmed with processed retimes - retimed_attributes = editorial.get_media_range_with_retimes( + retimed_attributes = oplib.get_media_range_with_retimes( otio_clip, handle_start, handle_end) self.log.debug( ">> retimed_attributes: {}".format(retimed_attributes)) @@ -65,8 +64,8 @@ class CollectOtioSubsetResources(pyblish.api.InstancePlugin): a_frame_end_h = media_out + handle_end # create trimmed otio time range - trimmed_media_range_h = editorial.range_from_frames( - a_frame_start_h, (a_frame_end_h - a_frame_start_h + 1), + trimmed_media_range_h = oplib.range_from_frames( + a_frame_start_h, (a_frame_end_h - a_frame_start_h) + 1, media_fps ) trimmed_duration = trimmed_media_range_h.duration.value @@ -145,7 +144,7 @@ class CollectOtioSubsetResources(pyblish.api.InstancePlugin): # in case it is file sequence but not new OTIO schema # `ImageSequenceReference` path = media_ref.target_url - collection_data = openpype.lib.make_sequence_collection( + collection_data = oplib.make_sequence_collection( path, trimmed_media_range_h, metadata) self.staging_dir, collection = collection_data diff --git a/openpype/plugins/publish/collect_scene_loaded_versions.py b/openpype/plugins/publish/collect_scene_loaded_versions.py index f2ade1ac28..bb34e3ce31 100644 --- a/openpype/plugins/publish/collect_scene_loaded_versions.py +++ b/openpype/plugins/publish/collect_scene_loaded_versions.py @@ -41,21 +41,33 @@ class CollectSceneLoadedVersions(pyblish.api.ContextPlugin): loaded_versions = [] _containers = list(host.ls()) _repr_ids = [ObjectId(c["representation"]) for c in _containers] + repre_docs = legacy_io.find( + {"_id": {"$in": _repr_ids}}, + projection={"_id": 1, "parent": 1} + ) version_by_repr = { - str(doc["_id"]): doc["parent"] for doc in - legacy_io.find( - {"_id": {"$in": _repr_ids}}, - projection={"parent": 1} - ) + str(doc["_id"]): doc["parent"] + for doc in repre_docs } + # QUESTION should we add same representation id when loaded multiple + # times? for con in _containers: + repre_id = con["representation"] + version_id = version_by_repr.get(repre_id) + if version_id is None: + self.log.warning(( + "Skipping container," + " did not find representation document. {}" + ).format(str(con))) + continue + # NOTE: # may have more then one representation that are same version version = { "subsetName": con["name"], - "representation": ObjectId(con["representation"]), - "version": version_by_repr[con["representation"]], # _id + "representation": ObjectId(repre_id), + "version": version_id, } loaded_versions.append(version) diff --git a/openpype/plugins/publish/extract_burnin.py b/openpype/plugins/publish/extract_burnin.py index 544c763b52..88093fb92f 100644 --- a/openpype/plugins/publish/extract_burnin.py +++ b/openpype/plugins/publish/extract_burnin.py @@ -41,6 +41,7 @@ class ExtractBurnin(openpype.api.Extractor): "shell", "hiero", "premiere", + "traypublisher", "standalonepublisher", "harmony", "fusion", diff --git a/openpype/plugins/publish/extract_jpeg_exr.py b/openpype/plugins/publish/extract_jpeg_exr.py index d6d6854092..ae29f8b95b 100644 --- a/openpype/plugins/publish/extract_jpeg_exr.py +++ b/openpype/plugins/publish/extract_jpeg_exr.py @@ -49,6 +49,7 @@ class ExtractJpegEXR(pyblish.api.InstancePlugin): return filtered_repres = self._get_filtered_repres(instance) + for repre in filtered_repres: repre_files = repre["files"] if not isinstance(repre_files, (list, tuple)): @@ -151,6 +152,11 @@ class ExtractJpegEXR(pyblish.api.InstancePlugin): if convert_dir is not None and os.path.exists(convert_dir): shutil.rmtree(convert_dir) + # Create only one representation with name 'thumbnail' + # TODO maybe handle way how to decide from which representation + # will be thumbnail created + break + def _get_filtered_repres(self, instance): filtered_repres = [] src_repres = instance.data.get("representations") or [] diff --git a/openpype/plugins/publish/extract_otio_trimming_video.py b/openpype/plugins/publish/extract_otio_trimming_video.py index 30b57e2c69..e8e2994f36 100644 --- a/openpype/plugins/publish/extract_otio_trimming_video.py +++ b/openpype/plugins/publish/extract_otio_trimming_video.py @@ -80,7 +80,7 @@ class ExtractOTIOTrimmingVideo(openpype.api.Extractor): video_path = input_file_path frame_start = otio_range.start_time.value input_fps = otio_range.start_time.rate - frame_duration = (otio_range.duration.value + 1) + frame_duration = otio_range.duration.value - 1 sec_start = openpype.lib.frames_to_secons(frame_start, input_fps) sec_duration = openpype.lib.frames_to_secons(frame_duration, input_fps) diff --git a/openpype/plugins/publish/extract_review.py b/openpype/plugins/publish/extract_review.py index f2473839d9..879125dac3 100644 --- a/openpype/plugins/publish/extract_review.py +++ b/openpype/plugins/publish/extract_review.py @@ -45,13 +45,15 @@ class ExtractReview(pyblish.api.InstancePlugin): "hiero", "premiere", "harmony", + "traypublisher", "standalonepublisher", "fusion", "tvpaint", "resolve", "webpublisher", "aftereffects", - "flame" + "flame", + "unreal" ] # Supported extensions diff --git a/openpype/plugins/publish/integrate_new.py b/openpype/plugins/publish/integrate_new.py index bf13a4050e..353314fff2 100644 --- a/openpype/plugins/publish/integrate_new.py +++ b/openpype/plugins/publish/integrate_new.py @@ -113,7 +113,7 @@ class IntegrateAssetNew(pyblish.api.InstancePlugin): "usdOverride", "simpleUnrealTexture" ] - exclude_families = ["clip", "render.farm"] + exclude_families = ["render.farm"] db_representation_context_keys = [ "project", "asset", "task", "subset", "version", "representation", "family", "hierarchy", "task", "username" diff --git a/openpype/resources/app_icons/hiero.png b/openpype/resources/app_icons/hiero.png index 04bbf6265b..ba666c2fe0 100644 Binary files a/openpype/resources/app_icons/hiero.png and b/openpype/resources/app_icons/hiero.png differ diff --git a/openpype/resources/app_icons/nuke.png b/openpype/resources/app_icons/nuke.png index 4234454096..e734b4984e 100644 Binary files a/openpype/resources/app_icons/nuke.png and b/openpype/resources/app_icons/nuke.png differ diff --git a/openpype/resources/app_icons/nukestudio.png b/openpype/resources/app_icons/nukestudio.png new file mode 100644 index 0000000000..601d4a591d Binary files /dev/null and b/openpype/resources/app_icons/nukestudio.png differ diff --git a/openpype/resources/app_icons/nukex.png b/openpype/resources/app_icons/nukex.png index 1c5a83c8ab..980f150124 100644 Binary files a/openpype/resources/app_icons/nukex.png and b/openpype/resources/app_icons/nukex.png differ diff --git a/openpype/settings/__init__.py b/openpype/settings/__init__.py index 14e4678050..ca7157812d 100644 --- a/openpype/settings/__init__.py +++ b/openpype/settings/__init__.py @@ -22,7 +22,6 @@ from .lib import ( get_project_settings, get_current_project_settings, get_anatomy_settings, - get_environments, get_local_settings ) from .entities import ( @@ -54,7 +53,6 @@ __all__ = ( "get_project_settings", "get_current_project_settings", "get_anatomy_settings", - "get_environments", "get_local_settings", "SystemSettings", diff --git a/openpype/settings/constants.py b/openpype/settings/constants.py index 19ff953eb4..cd84d4db1c 100644 --- a/openpype/settings/constants.py +++ b/openpype/settings/constants.py @@ -3,14 +3,11 @@ import re # Metadata keys for work with studio and project overrides M_OVERRIDDEN_KEY = "__overriden_keys__" -# Metadata key for storing information about environments -M_ENVIRONMENT_KEY = "__environment_keys__" # Metadata key for storing dynamic created labels M_DYNAMIC_KEY_LABEL = "__dynamic_keys_labels__" METADATA_KEYS = frozenset([ M_OVERRIDDEN_KEY, - M_ENVIRONMENT_KEY, M_DYNAMIC_KEY_LABEL ]) @@ -35,7 +32,6 @@ KEY_REGEX = re.compile(r"^[{}]+$".format(KEY_ALLOWED_SYMBOLS)) __all__ = ( "M_OVERRIDDEN_KEY", - "M_ENVIRONMENT_KEY", "M_DYNAMIC_KEY_LABEL", "METADATA_KEYS", diff --git a/openpype/settings/defaults/project_anatomy/imageio.json b/openpype/settings/defaults/project_anatomy/imageio.json index 7a3f49452e..f0be8f95f4 100644 --- a/openpype/settings/defaults/project_anatomy/imageio.json +++ b/openpype/settings/defaults/project_anatomy/imageio.json @@ -55,36 +55,49 @@ "nukeNodeClass": "Write", "knobs": [ { + "type": "text", "name": "file_type", "value": "exr" }, { + "type": "text", "name": "datatype", "value": "16 bit half" }, { + "type": "text", "name": "compression", "value": "Zip (1 scanline)" }, { + "type": "bool", "name": "autocrop", - "value": "True" + "value": true }, { + "type": "color_gui", "name": "tile_color", - "value": "0xff0000ff" + "value": [ + 186, + 35, + 35, + 255 + ] }, { + "type": "text", "name": "channels", "value": "rgb" }, { + "type": "text", "name": "colorspace", "value": "linear" }, { + "type": "bool", "name": "create_directories", - "value": "True" + "value": true } ] }, @@ -95,36 +108,49 @@ "nukeNodeClass": "Write", "knobs": [ { + "type": "text", "name": "file_type", "value": "exr" }, { + "type": "text", "name": "datatype", "value": "16 bit half" }, { + "type": "text", "name": "compression", "value": "Zip (1 scanline)" }, { + "type": "bool", "name": "autocrop", - "value": "False" + "value": true }, { + "type": "color_gui", "name": "tile_color", - "value": "0xadab1dff" + "value": [ + 171, + 171, + 10, + 255 + ] }, { + "type": "text", "name": "channels", "value": "rgb" }, { + "type": "text", "name": "colorspace", "value": "linear" }, { + "type": "bool", "name": "create_directories", - "value": "True" + "value": true } ] }, @@ -135,42 +161,54 @@ "nukeNodeClass": "Write", "knobs": [ { + "type": "text", "name": "file_type", "value": "tiff" }, { + "type": "text", "name": "datatype", "value": "16 bit" }, { + "type": "text", "name": "compression", "value": "Deflate" }, { + "type": "color_gui", "name": "tile_color", - "value": "0x23ff00ff" + "value": [ + 56, + 162, + 7, + 255 + ] }, { + "type": "text", "name": "channels", "value": "rgb" }, { + "type": "text", "name": "colorspace", "value": "sRGB" }, { + "type": "bool", "name": "create_directories", - "value": "True" + "value": true } ] } ], - "customNodes": [] + "overrideNodes": [] }, "regexInputs": { "inputs": [ { - "regex": "[^-a-zA-Z0-9]beauty[^-a-zA-Z0-9]", + "regex": "(beauty).*(?=.exr)", "colorspace": "linear" } ] diff --git a/openpype/settings/defaults/project_settings/deadline.json b/openpype/settings/defaults/project_settings/deadline.json index f0b2a7e555..5c5a14bf21 100644 --- a/openpype/settings/defaults/project_settings/deadline.json +++ b/openpype/settings/defaults/project_settings/deadline.json @@ -98,4 +98,4 @@ } } } -} +} \ No newline at end of file diff --git a/openpype/settings/defaults/project_settings/flame.json b/openpype/settings/defaults/project_settings/flame.json index ef7a2a4467..a7836b9c1f 100644 --- a/openpype/settings/defaults/project_settings/flame.json +++ b/openpype/settings/defaults/project_settings/flame.json @@ -16,7 +16,8 @@ "vSyncOn": false, "workfileFrameStart": 1001, "handleStart": 5, - "handleEnd": 5 + "handleEnd": 5, + "includeHandles": false } }, "publish": { @@ -55,18 +56,23 @@ "keep_original_representation": false, "export_presets_mapping": { "exr16fpdwaa": { + "active": true, + "export_type": "File Sequence", "ext": "exr", "xml_preset_file": "OpenEXR (16-bit fp DWAA).xml", - "xml_preset_dir": "", - "export_type": "File Sequence", - "ignore_comment_attrs": false, "colorspace_out": "ACES - ACEScg", + "xml_preset_dir": "", + "parsed_comment_attrs": true, "representation_add_range": true, "representation_tags": [], "load_to_batch_group": true, - "batch_group_loader_name": "LoadClip" + "batch_group_loader_name": "LoadClipBatch", + "filter_path_regex": ".*" } } + }, + "IntegrateBatchGroup": { + "enabled": false } }, "load": { @@ -87,7 +93,8 @@ "png", "h264", "mov", - "mp4" + "mp4", + "exr16fpdwaa" ], "reel_group_name": "OpenPype_Reels", "reel_name": "Loaded", @@ -110,7 +117,8 @@ "png", "h264", "mov", - "mp4" + "mp4", + "exr16fpdwaa" ], "reel_name": "OP_LoadedReel", "clip_name_template": "{asset}_{subset}_{output}" diff --git a/openpype/settings/defaults/project_settings/ftrack.json b/openpype/settings/defaults/project_settings/ftrack.json index a846a596c2..f9d16d6476 100644 --- a/openpype/settings/defaults/project_settings/ftrack.json +++ b/openpype/settings/defaults/project_settings/ftrack.json @@ -418,7 +418,8 @@ "redshiftproxy": "cache", "usd": "usd" }, - "keep_first_subset_name_for_review": true + "keep_first_subset_name_for_review": true, + "asset_versions_status_profiles": [] } } } \ No newline at end of file diff --git a/openpype/settings/defaults/project_settings/global.json b/openpype/settings/defaults/project_settings/global.json index 7317a3da1c..7b223798f1 100644 --- a/openpype/settings/defaults/project_settings/global.json +++ b/openpype/settings/defaults/project_settings/global.json @@ -307,7 +307,7 @@ ], "task_types": [], "tasks": [], - "template": "{family}{Task}_{Render_layer}_{Render_pass}" + "template": "{family}{Task}_{Renderlayer}_{Renderpass}" }, { "families": [ diff --git a/openpype/settings/defaults/project_settings/maya.json b/openpype/settings/defaults/project_settings/maya.json index 4cdfe1ca5d..e03bdcecc3 100644 --- a/openpype/settings/defaults/project_settings/maya.json +++ b/openpype/settings/defaults/project_settings/maya.json @@ -165,6 +165,9 @@ "CollectMayaRender": { "sync_workfile_version": false }, + "CollectFbxCamera": { + "enabled": false + }, "ValidateInstanceInContext": { "enabled": true, "optional": true, diff --git a/openpype/settings/defaults/project_settings/nuke.json b/openpype/settings/defaults/project_settings/nuke.json index ab015271ff..dc8ffcebff 100644 --- a/openpype/settings/defaults/project_settings/nuke.json +++ b/openpype/settings/defaults/project_settings/nuke.json @@ -15,16 +15,47 @@ "destination-path": [] } }, + "scriptsmenu": { + "name": "OpenPype Tools", + "definition": [ + { + "type": "action", + "sourcetype": "python", + "title": "OpenPype Docs", + "command": "import webbrowser;webbrowser.open(url='https://openpype.io/docs/artist_hosts_nuke_tut')", + "tooltip": "Open the OpenPype Nuke user doc page" + } + ] + }, "create": { "CreateWriteRender": { "fpath_template": "{work}/renders/nuke/{subset}/{subset}.{frame}.{ext}", "defaults": [ "Main", "Mask" - ] + ], + "knobs": [], + "prenodes": { + "Reformat01": { + "nodeclass": "Reformat", + "dependent": "", + "knobs": [ + { + "type": "text", + "name": "resize", + "value": "none" + }, + { + "type": "bool", + "name": "black_outside", + "value": true + } + ] + } + } }, "CreateWritePrerender": { - "fpath_template": "{work}/prerenders/nuke/{subset}/{subset}.{frame}.{ext}", + "fpath_template": "{work}/renders/nuke/{subset}/{subset}.{frame}.{ext}", "use_range_limit": true, "defaults": [ "Key01", @@ -33,7 +64,32 @@ "Branch01", "Part01" ], - "reviewable": false + "reviewable": false, + "knobs": [], + "prenodes": {} + }, + "CreateWriteStill": { + "fpath_template": "{work}/renders/nuke/{subset}/{subset}.{ext}", + "defaults": [ + "ImageFrame", + "MPFrame", + "LayoutFrame" + ], + "knobs": [], + "prenodes": { + "FrameHold01": { + "nodeclass": "FrameHold", + "dependent": "", + "knobs": [ + { + "type": "formatable", + "name": "first_frame", + "template": "{frame}", + "to_type": "number" + } + ] + } + } } }, "publish": { @@ -43,7 +99,8 @@ "camera", "gizmo", "source", - "render" + "render", + "write" ] }, "ValidateInstanceInContext": { @@ -120,7 +177,7 @@ "filter": { "task_types": [], "families": [], - "sebsets": [] + "subsets": [] }, "read_raw": false, "viewer_process_override": "", @@ -129,17 +186,17 @@ "reformat_node_add": false, "reformat_node_config": [ { - "type": "string", + "type": "text", "name": "type", "value": "to format" }, { - "type": "string", + "type": "text", "name": "format", "value": "HD_1080" }, { - "type": "string", + "type": "text", "name": "filter", "value": "Lanczos6" }, @@ -220,11 +277,12 @@ "repre_names": [ "exr", "dpx", - "mov" + "mov", + "mp4", + "h264" ], "loaders": [ - "LoadSequence", - "LoadMov" + "LoadClip" ] } ], diff --git a/openpype/settings/defaults/project_settings/traypublisher.json b/openpype/settings/defaults/project_settings/traypublisher.json new file mode 100644 index 0000000000..0b54cfd39e --- /dev/null +++ b/openpype/settings/defaults/project_settings/traypublisher.json @@ -0,0 +1,35 @@ +{ + "simple_creators": [ + { + "family": "workfile", + "identifier": "", + "label": "Workfile", + "icon": "fa.file", + "default_variants": [ + "Main" + ], + "description": "Publish workfile backup", + "detailed_description": "", + "allow_sequences": true, + "extensions": [ + ".ma", + ".mb", + ".nk", + ".hrox", + ".hip", + ".hiplc", + ".hipnc", + ".blend", + ".scn", + ".tvpp", + ".comp", + ".zip", + ".prproj", + ".drp", + ".psd", + ".psb", + ".aep" + ] + } + ] +} \ No newline at end of file diff --git a/openpype/settings/defaults/system_settings/applications.json b/openpype/settings/defaults/system_settings/applications.json index 0fb99a2608..6c90a99661 100644 --- a/openpype/settings/defaults/system_settings/applications.json +++ b/openpype/settings/defaults/system_settings/applications.json @@ -12,6 +12,26 @@ "LC_ALL": "C" }, "variants": { + "2023": { + "use_python_2": false, + "executables": { + "windows": [ + "C:\\Program Files\\Autodesk\\Maya2023\\bin\\maya.exe" + ], + "darwin": [], + "linux": [ + "/usr/autodesk/maya2023/bin/maya" + ] + }, + "arguments": { + "windows": [], + "darwin": [], + "linux": [] + }, + "environment": { + "MAYA_VERSION": "2023" + } + }, "2022": { "use_python_2": false, "executables": { @@ -91,9 +111,6 @@ "environment": { "MAYA_VERSION": "2018" } - }, - "__dynamic_keys_labels__": { - "2022": "2022" } } }, @@ -275,7 +292,7 @@ "nukex": { "enabled": true, "label": "Nuke X", - "icon": "{}/app_icons/nuke.png", + "icon": "{}/app_icons/nukex.png", "host_name": "nuke", "environment": { "NUKE_PATH": [ @@ -414,7 +431,7 @@ "nukestudio": { "enabled": true, "label": "Nuke Studio", - "icon": "{}/app_icons/nuke.png", + "icon": "{}/app_icons/nukestudio.png", "host_name": "hiero", "environment": { "WORKFILES_STARTUP": "0", @@ -1226,9 +1243,19 @@ "host_name": "unreal", "environment": {}, "variants": { - "4-26": { + "4-27": { "use_python_2": false, "environment": {} + }, + "5-0": { + "use_python_2": false, + "environment": { + "UE_PYTHONPATH": "{PYTHONPATH}" + } + }, + "__dynamic_keys_labels__": { + "4-27": "4.27", + "5-0": "5.0" } } }, diff --git a/openpype/settings/entities/base_entity.py b/openpype/settings/entities/base_entity.py index 21ee44ae77..741f13c49b 100644 --- a/openpype/settings/entities/base_entity.py +++ b/openpype/settings/entities/base_entity.py @@ -127,12 +127,6 @@ class BaseItemEntity(BaseEntity): # Entity is in hierarchy of dynamically created entity self.is_in_dynamic_item = False - # Entity will save metadata about environments - # - this is current possible only for RawJsonEnity - self.is_env_group = False - # Key of environment group key must be unique across system settings - self.env_group_key = None - # Roles of an entity self.roles = None @@ -286,16 +280,6 @@ class BaseItemEntity(BaseEntity): ).format(self.group_item.path) raise EntitySchemaError(self, reason) - # Validate that env group entities will be stored into file. - # - env group entities must store metadata which is not possible if - # metadata would be outside of file - if self.file_item is None and self.is_env_group: - reason = ( - "Environment item is not inside file" - " item so can't store metadata for defaults." - ) - raise EntitySchemaError(self, reason) - # Dynamic items must not have defined labels. (UI specific) if self.label and self.is_dynamic_item: raise EntitySchemaError( @@ -862,11 +846,6 @@ class ItemEntity(BaseItemEntity): if self.is_dynamic_item: self.require_key = False - # If value should be stored to environments and uder which group key - # - the key may be dynamically changed by it's parent on save - self.env_group_key = self.schema_data.get("env_group_key") - self.is_env_group = bool(self.env_group_key is not None) - # Root item reference self.root_item = self.parent.root_item diff --git a/openpype/settings/entities/dict_mutable_keys_entity.py b/openpype/settings/entities/dict_mutable_keys_entity.py index a0c93b97a7..e6d332b9ad 100644 --- a/openpype/settings/entities/dict_mutable_keys_entity.py +++ b/openpype/settings/entities/dict_mutable_keys_entity.py @@ -15,7 +15,6 @@ from .exceptions import ( from openpype.settings.constants import ( METADATA_KEYS, M_DYNAMIC_KEY_LABEL, - M_ENVIRONMENT_KEY, KEY_REGEX, KEY_ALLOWED_SYMBOLS ) @@ -148,11 +147,7 @@ class DictMutableKeysEntity(EndpointEntity): ): raise InvalidKeySymbols(self.path, key) - if self.value_is_env_group: - item_schema = copy.deepcopy(self.item_schema) - item_schema["env_group_key"] = key - else: - item_schema = self.item_schema + item_schema = self.item_schema new_child = self.create_schema_object(item_schema, self, True) self.children_by_key[key] = new_child @@ -216,9 +211,7 @@ class DictMutableKeysEntity(EndpointEntity): self.children_label_by_id = {} self.store_as_list = self.schema_data.get("store_as_list") or False - self.value_is_env_group = ( - self.schema_data.get("value_is_env_group") or False - ) + self.required_keys = self.schema_data.get("required_keys") or [] self.collapsible_key = self.schema_data.get("collapsible_key") or False # GUI attributes @@ -241,9 +234,6 @@ class DictMutableKeysEntity(EndpointEntity): object_type.update(input_modifiers) self.item_schema = object_type - if self.value_is_env_group: - self.item_schema["env_group_key"] = "" - if self.group_item is None: self.is_group = True @@ -259,10 +249,6 @@ class DictMutableKeysEntity(EndpointEntity): if used_temp_label: self.label = None - if self.value_is_env_group and self.store_as_list: - reason = "Item can't store environments metadata to list output." - raise EntitySchemaError(self, reason) - if not self.schema_data.get("object_type"): reason = ( "Modifiable dictionary must have specified `object_type`." @@ -579,18 +565,10 @@ class DictMutableKeysEntity(EndpointEntity): output.append([key, child_value]) return output - output = {} - for key, child_entity in self.children_by_key.items(): - child_value = child_entity.settings_value() - # TODO child should have setter of env group key se child can - # know what env group represents. - if self.value_is_env_group: - if key not in child_value[M_ENVIRONMENT_KEY]: - _metadata = child_value[M_ENVIRONMENT_KEY] - _m_keykey = tuple(_metadata.keys())[0] - env_keys = child_value[M_ENVIRONMENT_KEY].pop(_m_keykey) - child_value[M_ENVIRONMENT_KEY][key] = env_keys - output[key] = child_value + output = { + key: child_entity.settings_value() + for key, child_entity in self.children_by_key.items() + } output.update(self.metadata) return output diff --git a/openpype/settings/entities/input_entities.py b/openpype/settings/entities/input_entities.py index 3dcd238672..89f12afd9b 100644 --- a/openpype/settings/entities/input_entities.py +++ b/openpype/settings/entities/input_entities.py @@ -15,10 +15,7 @@ from .exceptions import ( EntitySchemaError ) -from openpype.settings.constants import ( - METADATA_KEYS, - M_ENVIRONMENT_KEY -) +from openpype.settings.constants import METADATA_KEYS class EndpointEntity(ItemEntity): @@ -534,13 +531,7 @@ class RawJsonEntity(InputEntity): @property def metadata(self): - output = {} - if isinstance(self._current_value, dict) and self.is_env_group: - output[M_ENVIRONMENT_KEY] = { - self.env_group_key: list(self._current_value.keys()) - } - - return output + return {} @property def has_unsaved_changes(self): @@ -549,15 +540,6 @@ class RawJsonEntity(InputEntity): result = self.metadata != self._metadata_for_current_state() return result - def schema_validations(self): - if self.store_as_string and self.is_env_group: - reason = ( - "RawJson entity can't store environment group metadata" - " as string." - ) - raise EntitySchemaError(self, reason) - super(RawJsonEntity, self).schema_validations() - def _convert_to_valid_type(self, value): if isinstance(value, STRING_TYPE): try: @@ -583,9 +565,6 @@ class RawJsonEntity(InputEntity): def _settings_value(self): value = super(RawJsonEntity, self)._settings_value() - if self.is_env_group and isinstance(value, dict): - value.update(self.metadata) - if self.store_as_string: return json.dumps(value) return value diff --git a/openpype/settings/entities/root_entities.py b/openpype/settings/entities/root_entities.py index edb4407679..ff76fa5180 100644 --- a/openpype/settings/entities/root_entities.py +++ b/openpype/settings/entities/root_entities.py @@ -52,7 +52,6 @@ from openpype.settings.lib import ( get_available_studio_project_settings_overrides_versions, get_available_studio_project_anatomy_overrides_versions, - find_environments, apply_overrides ) @@ -422,11 +421,6 @@ class RootEntity(BaseItemEntity): """ pass - @abstractmethod - def _validate_defaults_to_save(self, value): - """Validate default values before save.""" - pass - def _save_default_values(self): """Save default values. @@ -435,7 +429,6 @@ class RootEntity(BaseItemEntity): DEFAULTS. """ settings_value = self.settings_value() - self._validate_defaults_to_save(settings_value) defaults_dir = self.defaults_dir() for file_path, value in settings_value.items(): @@ -604,8 +597,6 @@ class SystemSettings(RootEntity): def _save_studio_values(self): settings_value = self.settings_value() - self._validate_duplicated_env_group(settings_value) - self.log.debug("Saving system settings: {}".format( json.dumps(settings_value, indent=4) )) @@ -613,29 +604,6 @@ class SystemSettings(RootEntity): # Reset source version after restart self._source_version = None - def _validate_defaults_to_save(self, value): - """Valiations of default values before save.""" - self._validate_duplicated_env_group(value) - - def _validate_duplicated_env_group(self, value, override_state=None): - """ Validate duplicated environment groups. - - Raises: - DuplicatedEnvGroups: When value contain duplicated env groups. - """ - value = copy.deepcopy(value) - if override_state is None: - override_state = self._override_state - - if override_state is OverrideState.STUDIO: - default_values = get_default_settings()[SYSTEM_SETTINGS_KEY] - final_value = apply_overrides(default_values, value) - else: - final_value = value - - # Check if final_value contain duplicated environment groups - find_environments(final_value) - def _save_project_values(self): """System settings can't have project overrides. @@ -911,10 +879,6 @@ class ProjectSettings(RootEntity): if warnings: raise SaveWarningExc(warnings) - def _validate_defaults_to_save(self, value): - """Valiations of default values before save.""" - pass - def _validate_values_to_save(self, value): pass diff --git a/openpype/settings/entities/schemas/README.md b/openpype/settings/entities/schemas/README.md index b4bfef2972..b4c878fe0f 100644 --- a/openpype/settings/entities/schemas/README.md +++ b/openpype/settings/entities/schemas/README.md @@ -46,8 +46,7 @@ }, { "type": "raw-json", "label": "{host_label} Environments", - "key": "{host_name}_environments", - "env_group_key": "{host_name}" + "key": "{host_name}_environments" }, { "type": "path", "key": "{host_name}_executables", diff --git a/openpype/settings/entities/schemas/projects_schema/schema_main.json b/openpype/settings/entities/schemas/projects_schema/schema_main.json index 8e4eba86ef..dbddd18c80 100644 --- a/openpype/settings/entities/schemas/projects_schema/schema_main.json +++ b/openpype/settings/entities/schemas/projects_schema/schema_main.json @@ -126,6 +126,10 @@ "type": "schema", "name": "schema_project_standalonepublisher" }, + { + "type": "schema", + "name": "schema_project_traypublisher" + }, { "type": "schema", "name": "schema_project_webpublisher" diff --git a/openpype/settings/entities/schemas/projects_schema/schema_project_flame.json b/openpype/settings/entities/schemas/projects_schema/schema_project_flame.json index fe11d63ac2..ca62679b3d 100644 --- a/openpype/settings/entities/schemas/projects_schema/schema_project_flame.json +++ b/openpype/settings/entities/schemas/projects_schema/schema_project_flame.json @@ -123,6 +123,11 @@ "type": "number", "key": "handleEnd", "label": "Handle end (tail)" + }, + { + "type": "boolean", + "key": "includeHandles", + "label": "Enable handles including" } ] } @@ -238,25 +243,19 @@ "type": "dict", "children": [ { - "key": "ext", - "label": "Output extension", - "type": "text" + "type": "boolean", + "key": "active", + "label": "Is active", + "default": true }, { - "key": "xml_preset_file", - "label": "XML preset file (with ext)", - "type": "text" - }, - { - "key": "xml_preset_dir", - "label": "XML preset folder (optional)", - "type": "text" + "type": "separator" }, { "key": "export_type", "label": "Eport clip type", "type": "enum", - "default": "File Sequence", + "default": "Sequence Publish", "enum_items": [ { "Movie": "Movie" @@ -268,59 +267,125 @@ "Sequence Publish": "Sequence Publish" } ] - }, { - "type": "separator" + "key": "ext", + "label": "Output extension", + "type": "text", + "default": "exr" }, { - "type": "boolean", - "key": "ignore_comment_attrs", - "label": "Ignore attributes parsed from a segment comments" - }, - { - "type": "separator" + "key": "xml_preset_file", + "label": "XML preset file (with ext)", + "type": "text" }, { "key": "colorspace_out", "label": "Output color (imageio)", - "type": "text" - }, - { - "type": "separator" - }, - { - "type": "boolean", - "key": "representation_add_range", - "label": "Add frame range to representation" - }, - { - "type": "list", - "key": "representation_tags", - "label": "Add representation tags", - "object_type": { - "type": "text", - "multiline": false - } - }, - { - "type": "separator" - }, - { - "type": "boolean", - "key": "load_to_batch_group", - "label": "Load to batch group reel", - "default": false - }, - { "type": "text", - "key": "batch_group_loader_name", - "label": "Use loader name" + "default": "linear" + }, + { + "type": "collapsible-wrap", + "label": "Other parameters", + "collapsible": true, + "collapsed": true, + "children": [ + { + "key": "xml_preset_dir", + "label": "XML preset folder (optional)", + "type": "text" + }, + { + "type": "separator" + }, + { + "type": "boolean", + "key": "parsed_comment_attrs", + "label": "Include parsed attributes from comments", + "default": false + + }, + { + "type": "separator" + }, + { + "type": "collapsible-wrap", + "label": "Representation", + "collapsible": true, + "collapsed": true, + "children": [ + { + "type": "boolean", + "key": "representation_add_range", + "label": "Add frame range to representation" + }, + { + "type": "list", + "key": "representation_tags", + "label": "Add representation tags", + "object_type": { + "type": "text", + "multiline": false + } + } + ] + }, + { + "type": "collapsible-wrap", + "label": "Loading during publish", + "collapsible": true, + "collapsed": true, + "children": [ + { + "type": "boolean", + "key": "load_to_batch_group", + "label": "Load to batch group reel", + "default": false + }, + { + "type": "text", + "key": "batch_group_loader_name", + "label": "Use loader name" + } + ] + } + + ] + }, + { + "type": "collapsible-wrap", + "label": "Filtering", + "collapsible": true, + "collapsed": true, + "children": [ + { + "key": "filter_path_regex", + "label": "Regex in clip path", + "type": "text", + "default": ".*" + } + ] } ] } } ] + }, + { + "type": "dict", + "collapsible": true, + "key": "IntegrateBatchGroup", + "label": "IntegrateBatchGroup", + "is_group": true, + "checkbox_key": "enabled", + "children": [ + { + "type": "boolean", + "key": "enabled", + "label": "Enabled" + } + ] } ] }, diff --git a/openpype/settings/entities/schemas/projects_schema/schema_project_ftrack.json b/openpype/settings/entities/schemas/projects_schema/schema_project_ftrack.json index 47effb3dbd..7db490b114 100644 --- a/openpype/settings/entities/schemas/projects_schema/schema_project_ftrack.json +++ b/openpype/settings/entities/schemas/projects_schema/schema_project_ftrack.json @@ -858,6 +858,43 @@ "key": "keep_first_subset_name_for_review", "label": "Make subset name as first asset name", "default": true + }, + { + "type": "list", + "collapsible": true, + "key": "asset_versions_status_profiles", + "label": "AssetVersion status on publish", + "use_label_wrap": true, + "object_type": { + "type": "dict", + "children": [ + { + "key": "hosts", + "label": "Host names", + "type": "hosts-enum", + "multiselection": true + }, + { + "key": "task_types", + "label": "Task types", + "type": "task-types-enum" + }, + { + "key": "family", + "label": "Family", + "type": "list", + "object_type": "text" + }, + { + "type": "separator" + }, + { + "key": "status", + "label": "Status name", + "type": "text" + } + ] + } } ] } diff --git a/openpype/settings/entities/schemas/projects_schema/schema_project_maya.json b/openpype/settings/entities/schemas/projects_schema/schema_project_maya.json index cc70516c72..0c7943447b 100644 --- a/openpype/settings/entities/schemas/projects_schema/schema_project_maya.json +++ b/openpype/settings/entities/schemas/projects_schema/schema_project_maya.json @@ -49,7 +49,7 @@ }, { "type": "schema", - "name": "schema_maya_scriptsmenu" + "name": "schema_scriptsmenu" }, { "type": "schema", diff --git a/openpype/settings/entities/schemas/projects_schema/schema_project_nuke.json b/openpype/settings/entities/schemas/projects_schema/schema_project_nuke.json index 9ab5fc65fb..1ae4efd8ea 100644 --- a/openpype/settings/entities/schemas/projects_schema/schema_project_nuke.json +++ b/openpype/settings/entities/schemas/projects_schema/schema_project_nuke.json @@ -79,6 +79,10 @@ } ] }, + { + "type": "schema", + "name": "schema_scriptsmenu" + }, { "type": "dict", "collapsible": true, @@ -87,7 +91,7 @@ "children": [ { "type": "dict", - "collapsible": false, + "collapsible": true, "key": "CreateWriteRender", "label": "CreateWriteRender", "is_group": true, @@ -104,12 +108,53 @@ "object_type": { "type": "text" } + }, + { + "type": "schema_template", + "name": "template_nuke_knob_inputs", + "template_data": [ + { + "label": "Node knobs", + "key": "knobs" + } + ] + }, + { + "key": "prenodes", + "label": "Pre write nodes", + "type": "dict-modifiable", + "highlight_content": true, + "object_type": { + "type": "dict", + "children": [ + { + "key": "nodeclass", + "label": "Node class", + "type": "text" + }, + { + "key": "dependent", + "label": "Outside node dependency", + "type": "text" + }, + { + "type": "schema_template", + "name": "template_nuke_knob_inputs", + "template_data": [ + { + "label": "Node knobs", + "key": "knobs" + } + ] + } + ] + } } ] }, { "type": "dict", - "collapsible": false, + "collapsible": true, "key": "CreateWritePrerender", "label": "CreateWritePrerender", "is_group": true, @@ -136,6 +181,110 @@ "type": "boolean", "key": "reviewable", "label": "Add reviewable toggle" + }, + { + "type": "schema_template", + "name": "template_nuke_knob_inputs", + "template_data": [ + { + "label": "Node knobs", + "key": "knobs" + } + ] + }, + { + "key": "prenodes", + "label": "Pre write nodes", + "type": "dict-modifiable", + "highlight_content": true, + "object_type": { + "type": "dict", + "children": [ + { + "key": "nodeclass", + "label": "Node class", + "type": "text" + }, + { + "key": "dependent", + "label": "Outside node dependency", + "type": "text" + }, + { + "type": "schema_template", + "name": "template_nuke_knob_inputs", + "template_data": [ + { + "label": "Node knobs", + "key": "knobs" + } + ] + } + ] + } + } + ] + }, + { + "type": "dict", + "collapsible": true, + "key": "CreateWriteStill", + "label": "CreateWriteStill", + "is_group": true, + "children": [ + { + "type": "text", + "key": "fpath_template", + "label": "Path template" + }, + { + "type": "list", + "key": "defaults", + "label": "Subset name defaults", + "object_type": { + "type": "text" + } + }, + { + "type": "schema_template", + "name": "template_nuke_knob_inputs", + "template_data": [ + { + "label": "Node knobs", + "key": "knobs" + } + ] + }, + { + "key": "prenodes", + "label": "Pre write nodes", + "type": "dict-modifiable", + "highlight_content": true, + "object_type": { + "type": "dict", + "children": [ + { + "key": "nodeclass", + "label": "Node class", + "type": "text" + }, + { + "key": "dependent", + "label": "Outside node dependency", + "type": "text" + }, + { + "type": "schema_template", + "name": "template_nuke_knob_inputs", + "template_data": [ + { + "label": "Node knobs", + "key": "knobs" + } + ] + } + ] + } } ] } diff --git a/openpype/settings/entities/schemas/projects_schema/schema_project_traypublisher.json b/openpype/settings/entities/schemas/projects_schema/schema_project_traypublisher.json new file mode 100644 index 0000000000..55c1b7b7d7 --- /dev/null +++ b/openpype/settings/entities/schemas/projects_schema/schema_project_traypublisher.json @@ -0,0 +1,83 @@ +{ + "type": "dict", + "collapsible": true, + "key": "traypublisher", + "label": "Tray Publisher", + "is_file": true, + "children": [ + { + "type": "list", + "collapsible": true, + "key": "simple_creators", + "label": "Creator plugins", + "use_label_wrap": true, + "collapsible_key": true, + "object_type": { + "type": "dict", + "children": [ + { + "type": "text", + "key": "family", + "label": "Family" + }, + { + "type": "text", + "key": "identifier", + "label": "Identifier", + "placeholder": "< Use 'Family' >", + "tooltip": "All creators must have unique identifier.\nBy default is used 'family' but if you need to have more creators with same families\nyou have to set identifier too." + }, + { + "type": "text", + "key": "label", + "label": "Label" + }, + { + "type": "text", + "key": "icon", + "label": "Icon" + }, + { + "type": "list", + "key": "default_variants", + "label": "Default variants", + "object_type": { + "type": "text" + } + }, + { + "type": "separator" + }, + { + "type": "text", + "key": "description", + "label": "Description" + }, + { + "type": "text", + "key": "detailed_description", + "label": "Detailed Description", + "multiline": true + }, + { + "type": "separator" + }, + { + "key": "allow_sequences", + "label": "Allow sequences", + "type": "boolean" + }, + { + "type": "list", + "key": "extensions", + "label": "Extensions", + "use_label_wrap": true, + "collapsible_key": true, + "collapsed": false, + "object_type": "text" + } + ] + } + } + ] +} diff --git a/openpype/settings/entities/schemas/projects_schema/schemas/schema_anatomy_imageio.json b/openpype/settings/entities/schemas/projects_schema/schemas/schema_anatomy_imageio.json index 9f142bad09..ef8c907dda 100644 --- a/openpype/settings/entities/schemas/projects_schema/schemas/schema_anatomy_imageio.json +++ b/openpype/settings/entities/schemas/projects_schema/schemas/schema_anatomy_imageio.json @@ -253,7 +253,7 @@ { "key": "requiredNodes", "type": "list", - "label": "Required Nodes", + "label": "Plugin required", "object_type": { "type": "dict", "children": [ @@ -272,35 +272,26 @@ "label": "Nuke Node Class" }, { - "type": "splitter" - }, - { - "key": "knobs", - "label": "Knobs", - "type": "list", - "object_type": { - "type": "dict", - "children": [ - { - "type": "text", - "key": "name", - "label": "Name" - }, - { - "type": "text", - "key": "value", - "label": "Value" - } - ] - } + "type": "schema_template", + "name": "template_nuke_knob_inputs", + "template_data": [ + { + "label": "Knobs", + "key": "knobs" + } + ] } + ] } }, + { + "type": "splitter" + }, { "type": "list", - "key": "customNodes", - "label": "Custom Nodes", + "key": "overrideNodes", + "label": "Plugin's node overrides", "object_type": { "type": "dict", "children": [ @@ -319,27 +310,20 @@ "label": "Nuke Node Class" }, { - "type": "splitter" + "key": "subsets", + "label": "Subsets", + "type": "list", + "object_type": "text" }, { - "key": "knobs", - "label": "Knobs", - "type": "list", - "object_type": { - "type": "dict", - "children": [ - { - "type": "text", - "key": "name", - "label": "Name" - }, - { - "type": "text", - "key": "value", - "label": "Value" - } - ] - } + "type": "schema_template", + "name": "template_nuke_knob_inputs", + "template_data": [ + { + "label": "Knobs overrides", + "key": "knobs" + } + ] } ] } @@ -446,7 +430,7 @@ { "key": "flame", "type": "dict", - "label": "Flame/Flair", + "label": "Flame & Flare", "children": [ { "key": "project", diff --git a/openpype/settings/entities/schemas/projects_schema/schemas/schema_maya_publish.json b/openpype/settings/entities/schemas/projects_schema/schemas/schema_maya_publish.json index 2e5bc64e1c..9877b5ff0d 100644 --- a/openpype/settings/entities/schemas/projects_schema/schemas/schema_maya_publish.json +++ b/openpype/settings/entities/schemas/projects_schema/schemas/schema_maya_publish.json @@ -21,6 +21,20 @@ } ] }, + { + "type": "dict", + "collapsible": true, + "key": "CollectFbxCamera", + "label": "Collect Camera for FBX export", + "checkbox_key": "enabled", + "children": [ + { + "type": "boolean", + "key": "enabled", + "label": "Enabled" + } + ] + }, { "type": "splitter" }, diff --git a/openpype/settings/entities/schemas/projects_schema/schemas/schema_nuke_publish.json b/openpype/settings/entities/schemas/projects_schema/schemas/schema_nuke_publish.json index 4a796f1933..04df957d67 100644 --- a/openpype/settings/entities/schemas/projects_schema/schemas/schema_nuke_publish.json +++ b/openpype/settings/entities/schemas/projects_schema/schemas/schema_nuke_publish.json @@ -41,6 +41,9 @@ }, { "render": "render" + }, + { + "write": "write" } ] } @@ -212,7 +215,7 @@ "object_type": "text" }, { - "key": "sebsets", + "key": "subsets", "label": "Subsets", "type": "list", "object_type": "text" @@ -253,108 +256,12 @@ "default": false }, { - "type": "collapsible-wrap", - "label": "Reformat Node Knobs", - "collapsible": true, - "collapsed": true, - "children": [ + "type": "schema_template", + "name": "template_nuke_knob_inputs", + "template_data": [ { - "type": "list", - "key": "reformat_node_config", - "object_type": { - "type": "dict-conditional", - "enum_key": "type", - "enum_label": "Type", - "enum_children": [ - { - "key": "string", - "label": "String", - "children": [ - { - "type": "text", - "key": "name", - "label": "Name" - }, - { - "type": "text", - "key": "value", - "label": "Value" - } - ] - }, - { - "key": "bool", - "label": "Boolean", - "children": [ - { - "type": "text", - "key": "name", - "label": "Name" - }, - { - "type": "boolean", - "key": "value", - "label": "Value" - } - ] - }, - { - "key": "number", - "label": "Number", - "children": [ - { - "type": "text", - "key": "name", - "label": "Name" - }, - { - "type": "list-strict", - "key": "value", - "label": "Value", - "object_types": [ - { - "type": "number", - "key": "number", - "default": 1, - "decimal": 4 - } - ] - } - - ] - }, - { - "key": "list_numbers", - "label": "2 Numbers", - "children": [ - { - "type": "text", - "key": "name", - "label": "Name" - }, - { - "type": "list-strict", - "key": "value", - "label": "Value", - "object_types": [ - { - "type": "number", - "key": "x", - "default": 1, - "decimal": 4 - }, - { - "type": "number", - "key": "y", - "default": 1, - "decimal": 4 - } - ] - } - ] - } - ] - } + "label": "Reformat Node Knobs", + "key": "reformat_node_config" } ] }, diff --git a/openpype/settings/entities/schemas/projects_schema/schemas/schema_maya_scriptsmenu.json b/openpype/settings/entities/schemas/projects_schema/schemas/schema_scriptsmenu.json similarity index 100% rename from openpype/settings/entities/schemas/projects_schema/schemas/schema_maya_scriptsmenu.json rename to openpype/settings/entities/schemas/projects_schema/schemas/schema_scriptsmenu.json diff --git a/openpype/settings/entities/schemas/projects_schema/schemas/template_nuke_knob_inputs.json b/openpype/settings/entities/schemas/projects_schema/schemas/template_nuke_knob_inputs.json new file mode 100644 index 0000000000..52a14e0636 --- /dev/null +++ b/openpype/settings/entities/schemas/projects_schema/schemas/template_nuke_knob_inputs.json @@ -0,0 +1,275 @@ +[ + { + "type": "collapsible-wrap", + "label": "{label}", + "collapsible": true, + "collapsed": true, + "children": [{ + "type": "list", + "key": "{key}", + "object_type": { + "type": "dict-conditional", + "enum_key": "type", + "enum_label": "Type", + "enum_children": [ + { + "key": "text", + "label": "Text", + "children": [ + { + "type": "text", + "key": "name", + "label": "Name" + }, + { + "type": "text", + "key": "value", + "label": "Value" + } + ] + }, + { + "key": "formatable", + "label": "Formate from template", + "children": [ + { + "type": "text", + "key": "name", + "label": "Name" + }, + { + "type": "text", + "key": "template", + "label": "Template", + "placeholder": "{{key}} or {{key}};{{key}}" + }, + { + "type": "enum", + "key": "to_type", + "label": "Knob type", + "enum_items": [ + { + "text": "Text" + }, + { + "number": "Number" + }, + { + "decimal_number": "Decimal number" + }, + { + "2d_vector": "2D vector" + } + ] + } + ] + }, + { + "key": "color_gui", + "label": "Color GUI", + "children": [ + { + "type": "text", + "key": "name", + "label": "Name" + }, + { + "type": "color", + "key": "value", + "label": "Value", + "use_alpha": false + } + ] + }, + { + "key": "bool", + "label": "Boolean", + "children": [ + { + "type": "text", + "key": "name", + "label": "Name" + }, + { + "type": "boolean", + "key": "value", + "label": "Value" + } + ] + }, + { + "key": "number", + "label": "Number", + "children": [ + { + "type": "text", + "key": "name", + "label": "Name" + }, + { + "type": "number", + "key": "value", + "default": 1, + "decimal": 0, + "maximum": 99999999 + } + + ] + }, + { + "key": "decimal_number", + "label": "Decimal number", + "children": [ + { + "type": "text", + "key": "name", + "label": "Name" + }, + { + "type": "number", + "key": "value", + "default": 1, + "decimal": 4, + "maximum": 99999999 + } + + ] + }, + { + "key": "2d_vector", + "label": "2D vector", + "children": [ + { + "type": "text", + "key": "name", + "label": "Name" + }, + { + "type": "list-strict", + "key": "value", + "label": "Value", + "object_types": [ + { + "type": "number", + "key": "x", + "default": 1, + "decimal": 4, + "maximum": 99999999 + }, + { + "type": "number", + "key": "y", + "default": 1, + "decimal": 4, + "maximum": 99999999 + } + ] + } + ] + }, + { + "key": "3d_vector", + "label": "3D vector", + "children": [ + { + "type": "text", + "key": "name", + "label": "Name" + }, + { + "type": "list-strict", + "key": "value", + "label": "Value", + "object_types": [ + { + "type": "number", + "key": "x", + "default": 1, + "decimal": 4, + "maximum": 99999999 + }, + { + "type": "number", + "key": "y", + "default": 1, + "decimal": 4, + "maximum": 99999999 + }, + { + "type": "number", + "key": "y", + "default": 1, + "decimal": 4, + "maximum": 99999999 + } + ] + } + ] + }, + { + "key": "color", + "label": "Color", + "children": [ + { + "type": "text", + "key": "name", + "label": "Name" + }, + { + "type": "list-strict", + "key": "value", + "label": "Value", + "object_types": [ + { + "type": "number", + "key": "x", + "default": 1, + "decimal": 4, + "maximum": 99999999 + }, + { + "type": "number", + "key": "x", + "default": 1, + "decimal": 4, + "maximum": 99999999 + }, + { + "type": "number", + "key": "y", + "default": 1, + "decimal": 4, + "maximum": 99999999 + }, + { + "type": "number", + "key": "y", + "default": 1, + "decimal": 4, + "maximum": 99999999 + } + ] + } + ] + }, + { + "key": "__legacy__", + "label": "_ Legacy type _", + "children": [ + { + "type": "text", + "key": "name", + "label": "Name" + }, + { + "type": "text", + "key": "value", + "label": "Value" + } + ] + } + ] + } + }] + } +] diff --git a/openpype/settings/entities/schemas/system_schema/example_schema.json b/openpype/settings/entities/schemas/system_schema/example_schema.json index 6a86dae259..b9747b5f4f 100644 --- a/openpype/settings/entities/schemas/system_schema/example_schema.json +++ b/openpype/settings/entities/schemas/system_schema/example_schema.json @@ -117,19 +117,6 @@ } ] }, - { - "key": "env_group_test", - "label": "EnvGroup Test", - "type": "dict", - "children": [ - { - "key": "key_to_store_in_system_settings", - "label": "Testing environment group", - "type": "raw-json", - "env_group_key": "test_group" - } - ] - }, { "key": "dict_wrapper", "type": "dict", diff --git a/openpype/settings/entities/schemas/system_schema/example_template.json b/openpype/settings/entities/schemas/system_schema/example_template.json index ff78c78e8f..9955cf5651 100644 --- a/openpype/settings/entities/schemas/system_schema/example_template.json +++ b/openpype/settings/entities/schemas/system_schema/example_template.json @@ -7,8 +7,7 @@ { "type": "raw-json", "label": "{host_label} Environments", - "key": "{host_name}_environments", - "env_group_key": "{host_name}" + "key": "{host_name}_environments" }, { "type": "path", diff --git a/openpype/settings/entities/schemas/system_schema/schema_general.json b/openpype/settings/entities/schemas/system_schema/schema_general.json index 0090c54386..5b6d8d5d62 100644 --- a/openpype/settings/entities/schemas/system_schema/schema_general.json +++ b/openpype/settings/entities/schemas/system_schema/schema_general.json @@ -34,7 +34,6 @@ "key": "environment", "label": "Environment", "type": "raw-json", - "env_group_key": "global", "require_restart": true }, { diff --git a/openpype/settings/lib.py b/openpype/settings/lib.py index 937329b417..6df41112c8 100644 --- a/openpype/settings/lib.py +++ b/openpype/settings/lib.py @@ -9,7 +9,6 @@ from .exceptions import ( ) from .constants import ( M_OVERRIDDEN_KEY, - M_ENVIRONMENT_KEY, METADATA_KEYS, @@ -292,6 +291,22 @@ def _system_settings_backwards_compatible_conversion(studio_overrides): } +def _project_anatomy_backwards_compatible_conversion(project_anatomy): + # Backwards compatibility of node settings in Nuke 3.9.x - 3.10.0 + # - source PR - https://github.com/pypeclub/OpenPype/pull/3143 + value = project_anatomy + for key in ("imageio", "nuke", "nodes", "requiredNodes"): + if key not in value: + return + value = value[key] + + for item in value: + for node in item.get("knobs") or []: + if "type" in node: + break + node["type"] = "__legacy__" + + @require_handler def get_studio_system_settings_overrides(return_version=False): output = _SETTINGS_HANDLER.get_studio_system_settings_overrides( @@ -327,7 +342,9 @@ def get_project_settings_overrides(project_name, return_version=False): @require_handler def get_project_anatomy_overrides(project_name): - return _SETTINGS_HANDLER.get_project_anatomy_overrides(project_name) + output = _SETTINGS_HANDLER.get_project_anatomy_overrides(project_name) + _project_anatomy_backwards_compatible_conversion(output) + return output @require_handler @@ -457,24 +474,6 @@ def get_local_settings(): return _LOCAL_SETTINGS_HANDLER.get_local_settings() -class DuplicatedEnvGroups(Exception): - def __init__(self, duplicated): - self.origin_duplicated = duplicated - self.duplicated = {} - for key, items in duplicated.items(): - self.duplicated[key] = [] - for item in items: - self.duplicated[key].append("/".join(item["parents"])) - - msg = "Duplicated environment group keys. {}".format( - ", ".join([ - "\"{}\"".format(env_key) for env_key in self.duplicated.keys() - ]) - ) - - super(DuplicatedEnvGroups, self).__init__(msg) - - def load_openpype_default_settings(): """Load openpype default settings.""" return load_jsons_from_dir(DEFAULTS_DIR) @@ -624,69 +623,6 @@ def load_jsons_from_dir(path, *args, **kwargs): return output -def find_environments(data, with_items=False, parents=None): - """ Find environemnt values from system settings by it's metadata. - - Args: - data(dict): System settings data or dictionary which may contain - environments metadata. - - Returns: - dict: Key as Environment key and value for `acre` module. - """ - if not data or not isinstance(data, dict): - return {} - - output = {} - if parents is None: - parents = [] - - if M_ENVIRONMENT_KEY in data: - metadata = data.get(M_ENVIRONMENT_KEY) - for env_group_key, env_keys in metadata.items(): - if env_group_key not in output: - output[env_group_key] = [] - - _env_values = {} - for key in env_keys: - _env_values[key] = data[key] - - item = { - "env": _env_values, - "parents": parents[:-1] - } - output[env_group_key].append(item) - - for key, value in data.items(): - _parents = copy.deepcopy(parents) - _parents.append(key) - result = find_environments(value, True, _parents) - if not result: - continue - - for env_group_key, env_values in result.items(): - if env_group_key not in output: - output[env_group_key] = [] - - for env_values_item in env_values: - output[env_group_key].append(env_values_item) - - if with_items: - return output - - duplicated_env_groups = {} - final_output = {} - for key, value_in_list in output.items(): - if len(value_in_list) > 1: - duplicated_env_groups[key] = value_in_list - else: - final_output[key] = value_in_list[0]["env"] - - if duplicated_env_groups: - raise DuplicatedEnvGroups(duplicated_env_groups) - return final_output - - def subkey_merge(_dict, value, keys): key = keys.pop(0) if not keys: @@ -1082,19 +1018,6 @@ def get_current_project_settings(): return get_project_settings(project_name) -def get_environments(): - """Calculated environment based on defaults and system settings. - - Any default environment also found in the system settings will be fully - overridden by the one from the system settings. - - Returns: - dict: Output should be ready for `acre` module. - """ - - return find_environments(get_system_settings(False)) - - def get_general_environments(): """Get general environments. diff --git a/openpype/style/style.css b/openpype/style/style.css index f2b0cdd6ac..d76d833be1 100644 --- a/openpype/style/style.css +++ b/openpype/style/style.css @@ -856,23 +856,45 @@ QScrollBar::add-page:vertical, QScrollBar::sub-page:vertical { } /* New Create/Publish UI */ +#CreatorDetailedDescription { + padding-left: 5px; + padding-right: 5px; + padding-top: 5px; + background: transparent; + border: 1px solid {color:border}; +} + #CreateDialogHelpButton { background: rgba(255, 255, 255, 31); + border-top-left-radius: 0.2em; + border-bottom-left-radius: 0.2em; border-top-right-radius: 0; border-bottom-right-radius: 0; font-size: 10pt; font-weight: bold; - padding: 3px 3px 3px 3px; + padding: 0px; } #CreateDialogHelpButton:hover { background: rgba(255, 255, 255, 63); } +#CreateDialogHelpButton QWidget { + background: transparent; +} #PublishLogConsole { font-family: "Noto Sans Mono"; } - +VariantInputsWidget QLineEdit { + border-bottom-right-radius: 0px; + border-top-right-radius: 0px; +} +VariantInputsWidget QToolButton { + border-bottom-left-radius: 0px; + border-top-left-radius: 0px; + padding-top: 0.5em; + padding-bottom: 0.5em; +} #VariantInput[state="new"], #VariantInput[state="new"]:focus, #VariantInput[state="new"]:hover { border-color: {color:publisher:success}; } @@ -1005,7 +1027,44 @@ QScrollBar::add-page:vertical, QScrollBar::sub-page:vertical { border-left: 1px solid {color:border}; } -#TasksCombobox[state="invalid"], #AssetNameInput[state="invalid"] { +#AssetNameInputWidget { + background: {color:bg-inputs}; + border: 1px solid {color:border}; + border-radius: 0.3em; +} + +#AssetNameInputWidget QWidget { + background: transparent; +} + +#AssetNameInputButton { + border-bottom-left-radius: 0px; + border-top-left-radius: 0px; + padding: 0px; + qproperty-iconSize: 11px 11px; + border-left: 1px solid {color:border}; + border-right: none; + border-top: none; + border-bottom: none; +} + +#AssetNameInput { + border-bottom-right-radius: 0px; + border-top-right-radius: 0px; + border: none; +} + +#AssetNameInputWidget:hover { + border-color: {color:border-hover}; +} +#AssetNameInputWidget:focus{ + border-color: {color:border-focus}; +} +#AssetNameInputWidget:disabled { + background: {color:bg-inputs-disabled}; +} + +#TasksCombobox[state="invalid"], #AssetNameInputWidget[state="invalid"], #AssetNameInputButton[state="invalid"] { border-color: {color:publisher:error}; } @@ -1351,3 +1410,11 @@ QScrollBar::add-page:vertical, QScrollBar::sub-page:vertical { #LikeDisabledInput:focus { border-color: {color:border}; } + +/* Attribute Definition widgets */ +InViewButton, InViewButton:disabled { + background: transparent; +} +InViewButton:hover { + background: rgba(255, 255, 255, 37); +} diff --git a/openpype/tools/loader/model.py b/openpype/tools/loader/model.py index 8cb8f30013..6f39c428be 100644 --- a/openpype/tools/loader/model.py +++ b/openpype/tools/loader/model.py @@ -1,6 +1,7 @@ import copy import re import math +import time from uuid import uuid4 from Qt import QtCore, QtGui @@ -38,6 +39,14 @@ def is_filtering_recursible(): class BaseRepresentationModel(object): """Methods for SyncServer useful in multiple models""" + # Cheap & hackish way how to avoid refreshing of whole sync server module + # on each selection change + _last_project = None + _modules_manager = None + _last_project_cache = 0 + _last_manager_cache = 0 + _max_project_cache_time = 30 + _max_manager_cache_time = 60 def reset_sync_server(self, project_name=None): """Sets/Resets sync server vars after every change (refresh.)""" @@ -47,28 +56,53 @@ class BaseRepresentationModel(object): remote_site = remote_provider = None if not project_name: - project_name = self.dbcon.Session["AVALON_PROJECT"] + project_name = self.dbcon.Session.get("AVALON_PROJECT") else: self.dbcon.Session["AVALON_PROJECT"] = project_name - if project_name: - manager = ModulesManager() - sync_server = manager.modules_by_name["sync_server"] + if not project_name: + self.repre_icons = repre_icons + self.sync_server = sync_server + self.active_site = active_site + self.active_provider = active_provider + self.remote_site = remote_site + self.remote_provider = remote_provider + return - if project_name in sync_server.get_enabled_projects(): - active_site = sync_server.get_active_site(project_name) - active_provider = sync_server.get_provider_for_site( - project_name, active_site) - if active_site == 'studio': # for studio use explicit icon - active_provider = 'studio' + now_time = time.time() + project_cache_diff = now_time - self._last_project_cache + if project_cache_diff > self._max_project_cache_time: + self._last_project = None - remote_site = sync_server.get_remote_site(project_name) - remote_provider = sync_server.get_provider_for_site( - project_name, remote_site) - if remote_site == 'studio': # for studio use explicit icon - remote_provider = 'studio' + if project_name == self._last_project: + return - repre_icons = lib.get_repre_icons() + self._last_project = project_name + self._last_project_cache = now_time + + manager_cache_diff = now_time - self._last_manager_cache + if manager_cache_diff > self._max_manager_cache_time: + self._modules_manager = None + + if self._modules_manager is None: + self._modules_manager = ModulesManager() + self._last_manager_cache = now_time + + sync_server = self._modules_manager.modules_by_name["sync_server"] + if sync_server.is_project_enabled(project_name): + active_site = sync_server.get_active_site(project_name) + active_provider = sync_server.get_provider_for_site( + project_name, active_site) + if active_site == 'studio': # for studio use explicit icon + active_provider = 'studio' + + remote_site = sync_server.get_remote_site(project_name) + remote_provider = sync_server.get_provider_for_site( + project_name, remote_site) + if remote_site == 'studio': # for studio use explicit icon + remote_provider = 'studio' + + repre_icons = lib.get_repre_icons() self.repre_icons = repre_icons self.sync_server = sync_server diff --git a/openpype/tools/project_manager/project_manager/model.py b/openpype/tools/project_manager/project_manager/model.py index 871704e13c..223cfa629d 100644 --- a/openpype/tools/project_manager/project_manager/model.py +++ b/openpype/tools/project_manager/project_manager/model.py @@ -1472,12 +1472,7 @@ class HierarchyModel(QtCore.QAbstractItemModel): mimedata.setData("application/copy_task", encoded_data) return mimedata - def paste_mime_data(self, index, mime_data): - if not index.isValid(): - return - - item_id = index.data(IDENTIFIER_ROLE) - item = self._items_by_id[item_id] + def _paste_mime_data(self, item, mime_data): if not isinstance(item, (AssetItem, TaskItem)): return @@ -1511,6 +1506,25 @@ class HierarchyModel(QtCore.QAbstractItemModel): task_item = TaskItem(task_data, True) self.add_item(task_item, parent) + def paste(self, indexes, mime_data): + + # Get the selected Assets uniquely + items = set() + for index in indexes: + if not index.isValid(): + return + item_id = index.data(IDENTIFIER_ROLE) + item = self._items_by_id[item_id] + + # Do not copy into the Task Item so get parent Asset instead + if isinstance(item, TaskItem): + item = item.parent() + + items.add(item) + + for item in items: + self._paste_mime_data(item, mime_data) + class BaseItem: """Base item for HierarchyModel. diff --git a/openpype/tools/project_manager/project_manager/view.py b/openpype/tools/project_manager/project_manager/view.py index 74f5a06b71..4b5bc36aeb 100644 --- a/openpype/tools/project_manager/project_manager/view.py +++ b/openpype/tools/project_manager/project_manager/view.py @@ -139,6 +139,7 @@ class HierarchyView(QtWidgets.QTreeView): self.setAlternatingRowColors(True) self.setSelectionMode(HierarchyView.ExtendedSelection) self.setContextMenuPolicy(QtCore.Qt.CustomContextMenu) + self.setEditTriggers(HierarchyView.AllEditTriggers) column_delegates = {} column_key_to_index = {} @@ -195,13 +196,13 @@ class HierarchyView(QtWidgets.QTreeView): for idx, width in widths_by_idx.items(): self.setColumnWidth(idx, width) - def set_project(self, project_name): + def set_project(self, project_name, force=False): # Trigger helpers first self._project_doc_cache.set_project(project_name) self._tools_cache.refresh() # Trigger update of model after all data for delegates are filled - self._source_model.set_project(project_name) + self._source_model.set_project(project_name, force) def _on_project_reset(self): self.header_init() @@ -301,16 +302,6 @@ class HierarchyView(QtWidgets.QTreeView): def rowsInserted(self, parent_index, start, end): super(HierarchyView, self).rowsInserted(parent_index, start, end) - for row in range(start, end + 1): - for key, column in self._column_key_to_index.items(): - if key not in self.persistent_columns: - continue - col_index = self._source_model.index(row, column, parent_index) - if bool( - self._source_model.flags(col_index) - & QtCore.Qt.ItemIsEditable - ): - self.openPersistentEditor(col_index) # Expand parent on insert if not self.isExpanded(parent_index): @@ -365,20 +356,24 @@ class HierarchyView(QtWidgets.QTreeView): event.accept() def _copy_items(self, indexes=None): + clipboard = QtWidgets.QApplication.clipboard() try: if indexes is None: indexes = self.selectedIndexes() mime_data = self._source_model.copy_mime_data(indexes) - QtWidgets.QApplication.clipboard().setMimeData(mime_data) + clipboard.setMimeData(mime_data) self._show_message("Tasks copied") except ValueError as exc: + # Change clipboard to contain empty data + empty_mime_data = QtCore.QMimeData() + clipboard.setMimeData(empty_mime_data) self._show_message(str(exc)) def _paste_items(self): - index = self.currentIndex() mime_data = QtWidgets.QApplication.clipboard().mimeData() - self._source_model.paste_mime_data(index, mime_data) + rows = self.selectionModel().selectedRows() + self._source_model.paste(rows, mime_data) def _delete_items(self, indexes=None): if indexes is None: diff --git a/openpype/tools/project_manager/project_manager/window.py b/openpype/tools/project_manager/project_manager/window.py index c281479d4f..6a2bc29fd1 100644 --- a/openpype/tools/project_manager/project_manager/window.py +++ b/openpype/tools/project_manager/project_manager/window.py @@ -184,14 +184,14 @@ class ProjectManagerWindow(QtWidgets.QWidget): self.resize(1200, 600) self.setStyleSheet(load_stylesheet()) - def _set_project(self, project_name=None): + def _set_project(self, project_name=None, force=False): self._create_folders_btn.setEnabled(project_name is not None) self._remove_projects_btn.setEnabled(project_name is not None) self._add_asset_btn.setEnabled(project_name is not None) self._add_task_btn.setEnabled(project_name is not None) self._save_btn.setEnabled(project_name is not None) self._project_proxy_model.set_filter_default(project_name is not None) - self.hierarchy_view.set_project(project_name) + self.hierarchy_view.set_project(project_name, force) def _current_project(self): row = self._project_combobox.currentIndex() @@ -229,11 +229,11 @@ class ProjectManagerWindow(QtWidgets.QWidget): self._project_combobox.setCurrentIndex(row) selected_project = self._current_project() - self._set_project(selected_project) + self._set_project(selected_project, True) def _on_project_change(self): selected_project = self._current_project() - self._set_project(selected_project) + self._set_project(selected_project, False) def _on_project_refresh(self): self.refresh_projects() diff --git a/openpype/tools/publisher/widgets/assets_widget.py b/openpype/tools/publisher/widgets/assets_widget.py index 984da59c77..46fdcc6526 100644 --- a/openpype/tools/publisher/widgets/assets_widget.py +++ b/openpype/tools/publisher/widgets/assets_widget.py @@ -15,6 +15,7 @@ from openpype.tools.utils.assets_widget import ( class CreateDialogAssetsWidget(SingleSelectAssetsWidget): current_context_required = QtCore.Signal() + header_height_changed = QtCore.Signal(int) def __init__(self, controller, parent): self._controller = controller @@ -27,6 +28,27 @@ class CreateDialogAssetsWidget(SingleSelectAssetsWidget): self._last_selection = None self._enabled = None + self._last_filter_height = None + + def _check_header_height(self): + """Catch header height changes. + + Label on top of creaters should have same height so Creators view has + same offset. + """ + height = self.header_widget.height() + if height != self._last_filter_height: + self._last_filter_height = height + self.header_height_changed.emit(height) + + def resizeEvent(self, event): + super(CreateDialogAssetsWidget, self).resizeEvent(event) + self._check_header_height() + + def showEvent(self, event): + super(CreateDialogAssetsWidget, self).showEvent(event) + self._check_header_height() + def _on_current_asset_click(self): self.current_context_required.emit() @@ -71,6 +93,7 @@ class AssetsHierarchyModel(QtGui.QStandardItemModel): Uses controller to load asset hierarchy. All asset documents are stored by their parents. """ + def __init__(self, controller): super(AssetsHierarchyModel, self).__init__() self._controller = controller @@ -143,6 +166,7 @@ class AssetsHierarchyModel(QtGui.QStandardItemModel): class AssetsDialog(QtWidgets.QDialog): """Dialog to select asset for a context of instance.""" + def __init__(self, controller, parent): super(AssetsDialog, self).__init__(parent) self.setWindowTitle("Select asset") @@ -196,9 +220,26 @@ class AssetsDialog(QtWidgets.QDialog): # - adds ability to call reset on multiple places without repeating self._soft_reset_enabled = True + self._first_show = True + self._default_height = 500 + + def _on_first_show(self): + center = self.rect().center() + size = self.size() + size.setHeight(self._default_height) + + self.resize(size) + new_pos = self.mapToGlobal(center) + new_pos.setX(new_pos.x() - int(self.width() / 2)) + new_pos.setY(new_pos.y() - int(self.height() / 2)) + self.move(new_pos) + def showEvent(self, event): """Refresh asset model on show.""" super(AssetsDialog, self).showEvent(event) + if self._first_show: + self._first_show = False + self._on_first_show() # Refresh on show self.reset(False) diff --git a/openpype/tools/publisher/widgets/create_dialog.py b/openpype/tools/publisher/widgets/create_dialog.py index 7d98609c2c..9e357f3a56 100644 --- a/openpype/tools/publisher/widgets/create_dialog.py +++ b/openpype/tools/publisher/widgets/create_dialog.py @@ -3,6 +3,7 @@ import re import traceback import copy +import qtawesome try: import commonmark except Exception: @@ -13,8 +14,11 @@ from openpype.pipeline.create import ( CreatorError, SUBSET_NAME_ALLOWED_SYMBOLS ) - -from openpype.tools.utils import ErrorMessageBox +from openpype.tools.utils import ( + ErrorMessageBox, + MessageOverlayObject, + ClickableFrame, +) from .widgets import IconValuePixmapLabel from .assets_widget import CreateDialogAssetsWidget @@ -29,6 +33,14 @@ from ..constants import ( SEPARATORS = ("---separator---", "---") +class VariantInputsWidget(QtWidgets.QWidget): + resized = QtCore.Signal() + + def resizeEvent(self, event): + super(VariantInputsWidget, self).resizeEvent(event) + self.resized.emit() + + class CreateErrorMessageBox(ErrorMessageBox): def __init__( self, @@ -104,6 +116,8 @@ class CreateErrorMessageBox(ErrorMessageBox): # TODO add creator identifier/label to details class CreatorShortDescWidget(QtWidgets.QWidget): + height_changed = QtCore.Signal(int) + def __init__(self, parent=None): super(CreatorShortDescWidget, self).__init__(parent=parent) @@ -142,6 +156,22 @@ class CreatorShortDescWidget(QtWidgets.QWidget): self._family_label = family_label self._description_label = description_label + self._last_height = None + + def _check_height_change(self): + height = self.height() + if height != self._last_height: + self._last_height = height + self.height_changed.emit(height) + + def showEvent(self, event): + super(CreatorShortDescWidget, self).showEvent(event) + self._check_height_change() + + def resizeEvent(self, event): + super(CreatorShortDescWidget, self).resizeEvent(event) + self._check_height_change() + def set_plugin(self, plugin=None): if not plugin: self._icon_widget.set_icon_def(None) @@ -158,13 +188,43 @@ class CreatorShortDescWidget(QtWidgets.QWidget): self._description_label.setText(description) -class HelpButton(QtWidgets.QPushButton): - resized = QtCore.Signal() +class HelpButton(ClickableFrame): + resized = QtCore.Signal(int) + question_mark_icon_name = "fa.question" + help_icon_name = "fa.question-circle" + hide_icon_name = "fa.angle-left" def __init__(self, *args, **kwargs): super(HelpButton, self).__init__(*args, **kwargs) self.setObjectName("CreateDialogHelpButton") + question_mark_label = QtWidgets.QLabel(self) + help_widget = QtWidgets.QWidget(self) + + help_question = QtWidgets.QLabel(help_widget) + help_label = QtWidgets.QLabel("Help", help_widget) + hide_icon = QtWidgets.QLabel(help_widget) + + help_layout = QtWidgets.QHBoxLayout(help_widget) + help_layout.setContentsMargins(0, 0, 5, 0) + help_layout.addWidget(help_question, 0) + help_layout.addWidget(help_label, 0) + help_layout.addStretch(1) + help_layout.addWidget(hide_icon, 0) + + layout = QtWidgets.QHBoxLayout(self) + layout.setContentsMargins(0, 0, 0, 0) + layout.setSpacing(0) + layout.addWidget(question_mark_label, 0) + layout.addWidget(help_widget, 1) + + help_widget.setVisible(False) + + self._question_mark_label = question_mark_label + self._help_widget = help_widget + self._help_question = help_question + self._hide_icon = hide_icon + self._expanded = None self.set_expanded() @@ -174,31 +234,56 @@ class HelpButton(QtWidgets.QPushButton): return expanded = False self._expanded = expanded - if expanded: - text = "<" + self._help_widget.setVisible(expanded) + self._update_content() + + def _update_content(self): + width = self.get_icon_width() + if self._expanded: + question_mark_pix = QtGui.QPixmap(width, width) + question_mark_pix.fill(QtCore.Qt.transparent) + else: - text = "?" - self.setText(text) + question_mark_icon = qtawesome.icon( + self.question_mark_icon_name, color=QtCore.Qt.white + ) + question_mark_pix = question_mark_icon.pixmap(width, width) - self._update_size() + hide_icon = qtawesome.icon( + self.hide_icon_name, color=QtCore.Qt.white + ) + help_question_icon = qtawesome.icon( + self.help_icon_name, color=QtCore.Qt.white + ) + self._question_mark_label.setPixmap(question_mark_pix) + self._question_mark_label.setMaximumWidth(width) + self._hide_icon.setPixmap(hide_icon.pixmap(width, width)) + self._help_question.setPixmap(help_question_icon.pixmap(width, width)) - def _update_size(self): - new_size = self.minimumSizeHint() - if self.size() != new_size: - self.resize(new_size) - self.resized.emit() + def get_icon_width(self): + metrics = self.fontMetrics() + return metrics.height() + + def set_pos_and_size(self, pos_x, pos_y, width, height): + update_icon = self.height() != height + self.move(pos_x, pos_y) + self.resize(width, height) + + if update_icon: + self._update_content() + self.updateGeometry() def showEvent(self, event): super(HelpButton, self).showEvent(event) - self._update_size() + self.resized.emit(self.height()) def resizeEvent(self, event): super(HelpButton, self).resizeEvent(event) - self._update_size() + self.resized.emit(self.height()) class CreateDialog(QtWidgets.QDialog): - default_size = (900, 500) + default_size = (1000, 560) def __init__( self, controller, asset_name=None, task_name=None, parent=None @@ -231,6 +316,8 @@ class CreateDialog(QtWidgets.QDialog): self._name_pattern = name_pattern self._compiled_name_pattern = re.compile(name_pattern) + overlay_object = MessageOverlayObject(self) + context_widget = QtWidgets.QWidget(self) assets_widget = CreateDialogAssetsWidget(controller, context_widget) @@ -243,44 +330,50 @@ class CreateDialog(QtWidgets.QDialog): context_layout.addWidget(tasks_widget, 1) # --- Creators view --- + creators_header_widget = QtWidgets.QWidget(self) + header_label_widget = QtWidgets.QLabel( + "Choose family:", creators_header_widget + ) + creators_header_layout = QtWidgets.QHBoxLayout(creators_header_widget) + creators_header_layout.setContentsMargins(0, 0, 0, 0) + creators_header_layout.addWidget(header_label_widget, 1) + creators_view = QtWidgets.QListView(self) creators_model = QtGui.QStandardItemModel() creators_view.setModel(creators_model) - variant_input = QtWidgets.QLineEdit(self) + variant_widget = VariantInputsWidget(self) + + variant_input = QtWidgets.QLineEdit(variant_widget) variant_input.setObjectName("VariantInput") variant_input.setToolTip(VARIANT_TOOLTIP) - variant_hints_btn = QtWidgets.QPushButton(self) - variant_hints_btn.setFixedWidth(18) + variant_hints_btn = QtWidgets.QToolButton(variant_widget) + variant_hints_btn.setArrowType(QtCore.Qt.DownArrow) + variant_hints_btn.setIconSize(QtCore.QSize(12, 12)) - variant_hints_menu = QtWidgets.QMenu(variant_hints_btn) + variant_hints_menu = QtWidgets.QMenu(variant_widget) variant_hints_group = QtWidgets.QActionGroup(variant_hints_menu) - variant_hints_btn.setMenu(variant_hints_menu) - variant_layout = QtWidgets.QHBoxLayout() + variant_layout = QtWidgets.QHBoxLayout(variant_widget) variant_layout.setContentsMargins(0, 0, 0, 0) variant_layout.setSpacing(0) variant_layout.addWidget(variant_input, 1) - variant_layout.addWidget(variant_hints_btn, 0) + variant_layout.addWidget(variant_hints_btn, 0, QtCore.Qt.AlignVCenter) subset_name_input = QtWidgets.QLineEdit(self) subset_name_input.setEnabled(False) - create_btn = QtWidgets.QPushButton("Create", self) - create_btn.setEnabled(False) - form_layout = QtWidgets.QFormLayout() - form_layout.addRow("Variant:", variant_layout) + form_layout.addRow("Variant:", variant_widget) form_layout.addRow("Subset:", subset_name_input) mid_widget = QtWidgets.QWidget(self) mid_layout = QtWidgets.QVBoxLayout(mid_widget) mid_layout.setContentsMargins(0, 0, 0, 0) - mid_layout.addWidget(QtWidgets.QLabel("Choose family:", self)) + mid_layout.addWidget(creators_header_widget, 0) mid_layout.addWidget(creators_view, 1) mid_layout.addLayout(form_layout, 0) - mid_layout.addWidget(create_btn, 0) # ------------ # --- Creator short info and attr defs --- @@ -290,31 +383,62 @@ class CreateDialog(QtWidgets.QDialog): creator_attrs_widget ) - separator_widget = QtWidgets.QWidget(self) - separator_widget.setObjectName("Separator") - separator_widget.setMinimumHeight(2) - separator_widget.setMaximumHeight(2) + attr_separator_widget = QtWidgets.QWidget(self) + attr_separator_widget.setObjectName("Separator") + attr_separator_widget.setMinimumHeight(1) + attr_separator_widget.setMaximumHeight(1) # Precreate attributes widget pre_create_widget = PreCreateWidget(creator_attrs_widget) + # Create button + create_btn_wrapper = QtWidgets.QWidget(creator_attrs_widget) + create_btn = QtWidgets.QPushButton("Create", create_btn_wrapper) + create_btn.setEnabled(False) + + create_btn_wrap_layout = QtWidgets.QHBoxLayout(create_btn_wrapper) + create_btn_wrap_layout.setContentsMargins(0, 0, 0, 0) + create_btn_wrap_layout.addStretch(1) + create_btn_wrap_layout.addWidget(create_btn, 0) + creator_attrs_layout = QtWidgets.QVBoxLayout(creator_attrs_widget) creator_attrs_layout.setContentsMargins(0, 0, 0, 0) creator_attrs_layout.addWidget(creator_short_desc_widget, 0) - creator_attrs_layout.addWidget(separator_widget, 0) + creator_attrs_layout.addWidget(attr_separator_widget, 0) creator_attrs_layout.addWidget(pre_create_widget, 1) + creator_attrs_layout.addWidget(create_btn_wrapper, 0) # ------------------------------------- # --- Detailed information about creator --- # Detailed description of creator - detail_description_widget = QtWidgets.QTextEdit(self) - detail_description_widget.setObjectName("InfoText") - detail_description_widget.setTextInteractionFlags( + detail_description_widget = QtWidgets.QWidget(self) + + detail_placoholder_widget = QtWidgets.QWidget( + detail_description_widget + ) + detail_placoholder_widget.setAttribute( + QtCore.Qt.WA_TranslucentBackground + ) + + detail_description_input = QtWidgets.QTextEdit( + detail_description_widget + ) + detail_description_input.setObjectName("CreatorDetailedDescription") + detail_description_input.setTextInteractionFlags( QtCore.Qt.TextBrowserInteraction ) - detail_description_widget.setVisible(False) - # ------------------------------------------- + detail_description_layout = QtWidgets.QVBoxLayout( + detail_description_widget + ) + detail_description_layout.setContentsMargins(0, 0, 0, 0) + detail_description_layout.setSpacing(0) + detail_description_layout.addWidget(detail_placoholder_widget, 0) + detail_description_layout.addWidget(detail_description_input, 1) + + detail_description_widget.setVisible(False) + + # ------------------------------------------- splitter_widget = QtWidgets.QSplitter(self) splitter_widget.addWidget(context_widget) splitter_widget.addWidget(mid_widget) @@ -329,32 +453,50 @@ class CreateDialog(QtWidgets.QDialog): layout.addWidget(splitter_widget, 1) # Floating help button + # - Create this button as last to be fully visible help_btn = HelpButton(self) prereq_timer = QtCore.QTimer() prereq_timer.setInterval(50) prereq_timer.setSingleShot(True) + desc_width_anim_timer = QtCore.QTimer() + desc_width_anim_timer.setInterval(10) + prereq_timer.timeout.connect(self._on_prereq_timer) + desc_width_anim_timer.timeout.connect(self._on_desc_animation) + help_btn.clicked.connect(self._on_help_btn) help_btn.resized.connect(self._on_help_btn_resize) + assets_widget.header_height_changed.connect( + self._on_asset_filter_height_change + ) + create_btn.clicked.connect(self._on_create) + variant_widget.resized.connect(self._on_variant_widget_resize) variant_input.returnPressed.connect(self._on_create) variant_input.textChanged.connect(self._on_variant_change) creators_view.selectionModel().currentChanged.connect( self._on_creator_item_change ) + variant_hints_btn.clicked.connect(self._on_variant_btn_click) variant_hints_menu.triggered.connect(self._on_variant_action) assets_widget.selection_changed.connect(self._on_asset_change) assets_widget.current_context_required.connect( self._on_current_session_context_request ) tasks_widget.task_changed.connect(self._on_task_change) + creator_short_desc_widget.height_changed.connect( + self._on_description_height_change + ) + splitter_widget.splitterMoved.connect(self._on_splitter_move) controller.add_plugins_refresh_callback(self._on_plugins_refresh) + self._overlay_object = overlay_object + self._splitter_widget = splitter_widget self._context_widget = context_widget @@ -368,18 +510,36 @@ class CreateDialog(QtWidgets.QDialog): self.variant_hints_menu = variant_hints_menu self.variant_hints_group = variant_hints_group + self._creators_header_widget = creators_header_widget self.creators_model = creators_model self.creators_view = creators_view self.create_btn = create_btn self._creator_short_desc_widget = creator_short_desc_widget self._pre_create_widget = pre_create_widget + self._attr_separator_widget = attr_separator_widget + + self._detail_placoholder_widget = detail_placoholder_widget self._detail_description_widget = detail_description_widget + self._detail_description_input = detail_description_input self._help_btn = help_btn self._prereq_timer = prereq_timer self._first_show = True + # Description animation + self._description_size_policy = detail_description_widget.sizePolicy() + self._desc_width_anim_timer = desc_width_anim_timer + self._desc_widget_step = 0 + self._last_description_width = None + self._last_full_width = 0 + self._expected_description_width = 0 + self._last_desc_max_width = None + self._other_widgets_widths = [] + + def _emit_message(self, message): + self._overlay_object.add_message(message) + def _context_change_is_enabled(self): return self._context_widget.isEnabled() @@ -443,14 +603,21 @@ class CreateDialog(QtWidgets.QDialog): def _invalidate_prereq(self): self._prereq_timer.start() + def _on_asset_filter_height_change(self, height): + self._creators_header_widget.setMinimumHeight(height) + self._creators_header_widget.setMaximumHeight(height) + def _on_prereq_timer(self): prereq_available = True + creator_btn_tooltips = [] if self.creators_model.rowCount() < 1: prereq_available = False + creator_btn_tooltips.append("Creator is not selected") if self._asset_doc is None: # QUESTION how to handle invalid asset? prereq_available = False + creator_btn_tooltips.append("Context is not selected") if prereq_available != self._prereq_available: self._prereq_available = prereq_available @@ -459,6 +626,12 @@ class CreateDialog(QtWidgets.QDialog): self.creators_view.setEnabled(prereq_available) self.variant_input.setEnabled(prereq_available) self.variant_hints_btn.setEnabled(prereq_available) + + tooltip = "" + if creator_btn_tooltips: + tooltip = "\n".join(creator_btn_tooltips) + self.create_btn.setToolTip(tooltip) + self._on_variant_change() def _refresh_asset(self): @@ -540,7 +713,7 @@ class CreateDialog(QtWidgets.QDialog): identifier = index.data(CREATOR_IDENTIFIER_ROLE) - self._set_creator(identifier) + self._set_creator_by_identifier(identifier) def _on_plugins_refresh(self): # Trigger refresh only if is visible @@ -564,65 +737,211 @@ class CreateDialog(QtWidgets.QDialog): if self._task_name: self._tasks_widget.select_task_name(self._task_name) + def _on_description_height_change(self): + # Use separator's 'y' position as height + height = self._attr_separator_widget.y() + self._detail_placoholder_widget.setMinimumHeight(height) + self._detail_placoholder_widget.setMaximumHeight(height) + def _on_creator_item_change(self, new_index, _old_index): identifier = None if new_index.isValid(): identifier = new_index.data(CREATOR_IDENTIFIER_ROLE) - self._set_creator(identifier) + self._set_creator_by_identifier(identifier) def _update_help_btn(self): - pos_x = self.width() - self._help_btn.width() - point = self._creator_short_desc_widget.rect().topRight() - mapped_point = self._creator_short_desc_widget.mapTo(self, point) - pos_y = mapped_point.y() - self._help_btn.move(max(0, pos_x), max(0, pos_y)) + short_desc_rect = self._creator_short_desc_widget.rect() - def _on_help_btn_resize(self): + # point = short_desc_rect.topRight() + point = short_desc_rect.center() + mapped_point = self._creator_short_desc_widget.mapTo(self, point) + # pos_y = mapped_point.y() + center_pos_y = mapped_point.y() + icon_width = self._help_btn.get_icon_width() + + _height = int(icon_width * 2.5) + height = min(_height, short_desc_rect.height()) + pos_y = center_pos_y - int(height / 2) + + pos_x = self.width() - icon_width + if self._detail_placoholder_widget.isVisible(): + pos_x -= ( + self._detail_placoholder_widget.width() + + self._splitter_widget.handle(3).width() + ) + + width = self.width() - pos_x + + self._help_btn.set_pos_and_size( + max(0, pos_x), max(0, pos_y), + width, height + ) + + def _on_help_btn_resize(self, height): + if self._creator_short_desc_widget.height() != height: + self._update_help_btn() + + def _on_splitter_move(self, *args): self._update_help_btn() def _on_help_btn(self): + if self._desc_width_anim_timer.isActive(): + return + final_size = self.size() cur_sizes = self._splitter_widget.sizes() - spacing = self._splitter_widget.handleWidth() + + if self._desc_widget_step == 0: + now_visible = self._detail_description_widget.isVisible() + else: + now_visible = self._desc_widget_step > 0 sizes = [] for idx, value in enumerate(cur_sizes): if idx < 3: sizes.append(value) - now_visible = self._detail_description_widget.isVisible() + self._last_full_width = final_size.width() + self._other_widgets_widths = list(sizes) + if now_visible: - width = final_size.width() - ( - spacing + self._detail_description_widget.width() - ) + cur_desc_width = self._detail_description_widget.width() + if cur_desc_width < 1: + cur_desc_width = 2 + step_size = int(cur_desc_width / 5) + if step_size < 1: + step_size = 1 + + step_size *= -1 + expected_width = 0 + desc_width = cur_desc_width - 1 + width = final_size.width() - 1 + min_max = desc_width + self._last_description_width = cur_desc_width else: - last_size = self._detail_description_widget.sizeHint().width() - width = final_size.width() + spacing + last_size - sizes.append(last_size) + self._detail_description_widget.setVisible(True) + handle = self._splitter_widget.handle(3) + desc_width = handle.sizeHint().width() + if self._last_description_width: + expected_width = self._last_description_width + else: + hint = self._detail_description_widget.sizeHint() + expected_width = hint.width() + + width = final_size.width() + desc_width + step_size = int(expected_width / 5) + if step_size < 1: + step_size = 1 + min_max = 0 + + if self._last_desc_max_width is None: + self._last_desc_max_width = ( + self._detail_description_widget.maximumWidth() + ) + self._detail_description_widget.setMinimumWidth(min_max) + self._detail_description_widget.setMaximumWidth(min_max) + self._expected_description_width = expected_width + self._desc_widget_step = step_size + + self._desc_width_anim_timer.start() + + sizes.append(desc_width) final_size.setWidth(width) - self._detail_description_widget.setVisible(not now_visible) self._splitter_widget.setSizes(sizes) self.resize(final_size) self._help_btn.set_expanded(not now_visible) + def _on_desc_animation(self): + current_width = self._detail_description_widget.width() + + desc_width = None + last_step = False + growing = self._desc_widget_step > 0 + + # Growing + if growing: + if current_width < self._expected_description_width: + desc_width = current_width + self._desc_widget_step + if desc_width >= self._expected_description_width: + desc_width = self._expected_description_width + last_step = True + + # Decreasing + elif self._desc_widget_step < 0: + if current_width > self._expected_description_width: + desc_width = current_width + self._desc_widget_step + if desc_width <= self._expected_description_width: + desc_width = self._expected_description_width + last_step = True + + if desc_width is None: + self._desc_widget_step = 0 + self._desc_width_anim_timer.stop() + return + + if last_step and not growing: + self._detail_description_widget.setVisible(False) + QtWidgets.QApplication.processEvents() + + width = self._last_full_width + handle_width = self._splitter_widget.handle(3).width() + if growing: + width += (handle_width + desc_width) + else: + width -= self._last_description_width + if last_step: + width -= handle_width + else: + width += desc_width + + if not last_step or growing: + self._detail_description_widget.setMaximumWidth(desc_width) + self._detail_description_widget.setMinimumWidth(desc_width) + + window_size = self.size() + window_size.setWidth(width) + self.resize(window_size) + if not last_step: + return + + self._desc_widget_step = 0 + self._desc_width_anim_timer.stop() + + if not growing: + return + + self._detail_description_widget.setMinimumWidth(0) + self._detail_description_widget.setMaximumWidth( + self._last_desc_max_width + ) + self._detail_description_widget.setSizePolicy( + self._description_size_policy + ) + + sizes = list(self._other_widgets_widths) + sizes.append(desc_width) + self._splitter_widget.setSizes(sizes) + def _set_creator_detailed_text(self, creator): if not creator: - self._detail_description_widget.setPlainText("") + self._detail_description_input.setPlainText("") return detailed_description = creator.get_detail_description() or "" if commonmark: html = commonmark.commonmark(detailed_description) - self._detail_description_widget.setHtml(html) + self._detail_description_input.setHtml(html) else: - self._detail_description_widget.setMarkdown(detailed_description) + self._detail_description_input.setMarkdown(detailed_description) - def _set_creator(self, identifier): + def _set_creator_by_identifier(self, identifier): creator = self.controller.manual_creators.get(identifier) + self._set_creator(creator) + def _set_creator(self, creator): self._creator_short_desc_widget.set_plugin(creator) self._set_creator_detailed_text(creator) self._pre_create_widget.set_plugin(creator) @@ -660,6 +979,14 @@ class CreateDialog(QtWidgets.QDialog): self.variant_input.setText(default_variant or "Main") + def _on_variant_widget_resize(self): + self.variant_hints_btn.setFixedHeight(self.variant_input.height()) + + def _on_variant_btn_click(self): + pos = self.variant_hints_btn.rect().bottomLeft() + point = self.variant_hints_btn.mapToGlobal(pos) + self.variant_hints_menu.popup(point) + def _on_variant_action(self, action): value = action.text() if self.variant_input.text() != value: @@ -765,6 +1092,21 @@ class CreateDialog(QtWidgets.QDialog): self.variant_input.setProperty("state", state) self.variant_input.style().polish(self.variant_input) + def _on_first_show(self): + center = self.rect().center() + + width, height = self.default_size + self.resize(width, height) + part = int(width / 7) + self._splitter_widget.setSizes( + [part * 2, part * 2, width - (part * 4)] + ) + + new_pos = self.mapToGlobal(center) + new_pos.setX(new_pos.x() - int(self.width() / 2)) + new_pos.setY(new_pos.y() - int(self.height() / 2)) + self.move(new_pos) + def moveEvent(self, event): super(CreateDialog, self).moveEvent(event) self._last_pos = self.pos() @@ -773,13 +1115,7 @@ class CreateDialog(QtWidgets.QDialog): super(CreateDialog, self).showEvent(event) if self._first_show: self._first_show = False - width, height = self.default_size - self.resize(width, height) - - third_size = int(width / 3) - self._splitter_widget.setSizes( - [third_size, third_size, width - (2 * third_size)] - ) + self._on_first_show() if self._last_pos is not None: self.move(self._last_pos) @@ -840,7 +1176,10 @@ class CreateDialog(QtWidgets.QDialog): )) error_msg = str(exc_value) - if error_msg is not None: + if error_msg is None: + self._set_creator(self._selected_creator) + self._emit_message("Creation finished...") + else: box = CreateErrorMessageBox( creator_label, subset_name, diff --git a/openpype/tools/publisher/widgets/validations_widget.py b/openpype/tools/publisher/widgets/validations_widget.py index 798c1f9d92..e7ab4ecf5a 100644 --- a/openpype/tools/publisher/widgets/validations_widget.py +++ b/openpype/tools/publisher/widgets/validations_widget.py @@ -142,7 +142,7 @@ class ValidationErrorTitleWidget(QtWidgets.QWidget): self._help_text_by_instance_id = help_text_by_instance_id def sizeHint(self): - result = super().sizeHint() + result = super(ValidationErrorTitleWidget, self).sizeHint() expected_width = 0 for idx in range(self._view_layout.count()): expected_width += self._view_layout.itemAt(idx).sizeHint().width() diff --git a/openpype/tools/publisher/widgets/widgets.py b/openpype/tools/publisher/widgets/widgets.py index 5ced469b59..7096b9fb50 100644 --- a/openpype/tools/publisher/widgets/widgets.py +++ b/openpype/tools/publisher/widgets/widgets.py @@ -14,7 +14,8 @@ from openpype.tools.utils import ( PlaceholderLineEdit, IconButton, PixmapLabel, - BaseClickableFrame + BaseClickableFrame, + set_style_property, ) from openpype.pipeline.create import SUBSET_NAME_ALLOWED_SYMBOLS from .assets_widget import AssetsDialog @@ -344,21 +345,42 @@ class AssetsField(BaseClickableFrame): def __init__(self, controller, parent): super(AssetsField, self).__init__(parent) + self.setObjectName("AssetNameInputWidget") - dialog = AssetsDialog(controller, self) + # Don't use 'self' for parent! + # - this widget has specific styles + dialog = AssetsDialog(controller, parent) name_input = ClickableLineEdit(self) name_input.setObjectName("AssetNameInput") + icon_name = "fa.window-maximize" + icon = qtawesome.icon(icon_name, color="white") + icon_btn = QtWidgets.QPushButton(self) + icon_btn.setIcon(icon) + icon_btn.setObjectName("AssetNameInputButton") + layout = QtWidgets.QHBoxLayout(self) layout.setContentsMargins(0, 0, 0, 0) + layout.setSpacing(0) layout.addWidget(name_input, 1) + layout.addWidget(icon_btn, 0) + # Make sure all widgets are vertically extended to highest widget + for widget in ( + name_input, + icon_btn + ): + size_policy = widget.sizePolicy() + size_policy.setVerticalPolicy(size_policy.MinimumExpanding) + widget.setSizePolicy(size_policy) name_input.clicked.connect(self._mouse_release_callback) + icon_btn.clicked.connect(self._mouse_release_callback) dialog.finished.connect(self._on_dialog_finish) self._dialog = dialog self._name_input = name_input + self._icon_btn = icon_btn self._origin_value = [] self._origin_selection = [] @@ -406,10 +428,9 @@ class AssetsField(BaseClickableFrame): self._set_state_property(state) def _set_state_property(self, state): - current_value = self._name_input.property("state") - if current_value != state: - self._name_input.setProperty("state", state) - self._name_input.style().polish(self._name_input) + set_style_property(self, "state", state) + set_style_property(self._name_input, "state", state) + set_style_property(self._icon_btn, "state", state) def is_valid(self): """Is asset valid.""" @@ -842,6 +863,8 @@ class VariantInputWidget(PlaceholderLineEdit): self._ignore_value_change = True + self._has_value_changed = False + self._origin_value = list(variants) self._current_value = list(variants) @@ -892,11 +915,23 @@ class MultipleItemWidget(QtWidgets.QWidget): layout.setContentsMargins(0, 0, 0, 0) layout.addWidget(view) + model.rowsInserted.connect(self._on_insert) + self._view = view self._model = model self._value = [] + def _on_insert(self): + self._update_size() + + def _update_size(self): + model = self._view.model() + if model.rowCount() == 0: + return + height = self._view.sizeHintForRow(0) + self.setMaximumHeight(height + (2 * self._view.spacing())) + def showEvent(self, event): super(MultipleItemWidget, self).showEvent(event) tmp_item = None @@ -904,13 +939,15 @@ class MultipleItemWidget(QtWidgets.QWidget): # Add temp item to be able calculate maximum height of widget tmp_item = QtGui.QStandardItem("tmp") self._model.appendRow(tmp_item) - - height = self._view.sizeHintForRow(0) - self.setMaximumHeight(height + (2 * self._view.spacing())) + self._update_size() if tmp_item is not None: self._model.clear() + def resizeEvent(self, event): + super(MultipleItemWidget, self).resizeEvent(event) + self._update_size() + def set_value(self, value=None): """Set value/s of currently selected instance.""" if value is None: @@ -1235,7 +1272,11 @@ class CreatorAttrsWidget(QtWidgets.QWidget): ) content_widget = QtWidgets.QWidget(self._scroll_area) - content_layout = QtWidgets.QFormLayout(content_widget) + content_layout = QtWidgets.QGridLayout(content_widget) + content_layout.setColumnStretch(0, 0) + content_layout.setColumnStretch(1, 1) + + row = 0 for attr_def, attr_instances, values in result: widget = create_widget_for_attr_def(attr_def, content_widget) if attr_def.is_value_def: @@ -1246,10 +1287,28 @@ class CreatorAttrsWidget(QtWidgets.QWidget): else: widget.set_value(values, True) - label = attr_def.label or attr_def.key - content_layout.addRow(label, widget) - widget.value_changed.connect(self._input_value_changed) + expand_cols = 2 + if attr_def.is_value_def and attr_def.is_label_horizontal: + expand_cols = 1 + col_num = 2 - expand_cols + + label = attr_def.label or attr_def.key + if label: + label_widget = QtWidgets.QLabel(label, self) + content_layout.addWidget( + label_widget, row, 0, 1, expand_cols + ) + if not attr_def.is_label_horizontal: + row += 1 + + content_layout.addWidget( + widget, row, col_num, 1, expand_cols + ) + + row += 1 + + widget.value_changed.connect(self._input_value_changed) self._attr_def_id_to_instances[attr_def.id] = attr_instances self._attr_def_id_to_attr_def[attr_def.id] = attr_def diff --git a/openpype/tools/publisher/window.py b/openpype/tools/publisher/window.py index b74e95b227..90a36b4f01 100644 --- a/openpype/tools/publisher/window.py +++ b/openpype/tools/publisher/window.py @@ -83,8 +83,10 @@ class PublisherWindow(QtWidgets.QDialog): line_widget.setMinimumHeight(2) # Content + content_stacked_widget = QtWidgets.QWidget(self) + # Subset widget - subset_frame = QtWidgets.QFrame(self) + subset_frame = QtWidgets.QFrame(content_stacked_widget) subset_views_widget = BorderedLabelWidget( "Subsets to publish", subset_frame @@ -171,9 +173,12 @@ class PublisherWindow(QtWidgets.QDialog): subset_layout.addLayout(footer_layout, 0) # Create publish frame - publish_frame = PublishFrame(controller, self) + publish_frame = PublishFrame(controller, content_stacked_widget) - content_stacked_layout = QtWidgets.QStackedLayout() + content_stacked_layout = QtWidgets.QStackedLayout( + content_stacked_widget + ) + content_stacked_layout.setContentsMargins(0, 0, 0, 0) content_stacked_layout.setStackingMode( QtWidgets.QStackedLayout.StackAll ) @@ -186,7 +191,7 @@ class PublisherWindow(QtWidgets.QDialog): main_layout.setSpacing(0) main_layout.addWidget(header_widget, 0) main_layout.addWidget(line_widget, 0) - main_layout.addLayout(content_stacked_layout, 1) + main_layout.addWidget(content_stacked_widget, 1) creator_window = CreateDialog(controller, parent=self) @@ -228,6 +233,7 @@ class PublisherWindow(QtWidgets.QDialog): # Store header for TrayPublisher self._header_layout = header_layout + self._content_stacked_widget = content_stacked_widget self.content_stacked_layout = content_stacked_layout self.publish_frame = publish_frame self.subset_frame = subset_frame @@ -340,9 +346,23 @@ class PublisherWindow(QtWidgets.QDialog): def _set_publish_visibility(self, visible): if visible: widget = self.publish_frame + publish_frame_visible = True else: widget = self.subset_frame + publish_frame_visible = False self.content_stacked_layout.setCurrentWidget(widget) + self._set_publish_frame_visible(publish_frame_visible) + + def _set_publish_frame_visible(self, publish_frame_visible): + """Publish frame visibility has changed. + + Also used in TrayPublisher to be able handle start/end of publish + widget overlay. + """ + + # Hide creator dialog if visible + if publish_frame_visible and self.creator_window.isVisible(): + self.creator_window.close() def _on_reset_clicked(self): self.controller.reset() diff --git a/openpype/tools/resources/images/menu.png b/openpype/tools/resources/images/menu.png new file mode 100644 index 0000000000..14a991f092 Binary files /dev/null and b/openpype/tools/resources/images/menu.png differ diff --git a/openpype/tools/settings/settings/README.md b/openpype/tools/settings/settings/README.md index 1c916ddff2..c29664a907 100644 --- a/openpype/tools/settings/settings/README.md +++ b/openpype/tools/settings/settings/README.md @@ -44,8 +44,7 @@ }, { "type": "raw-json", "label": "{host_label} Environments", - "key": "{host_name}_environments", - "env_group_key": "{host_name}" + "key": "{host_name}_environments" }, { "type": "path-widget", "key": "{host_name}_executables", diff --git a/openpype/tools/settings/settings/dict_mutable_widget.py b/openpype/tools/settings/settings/dict_mutable_widget.py index 6489266131..1c704b3cd5 100644 --- a/openpype/tools/settings/settings/dict_mutable_widget.py +++ b/openpype/tools/settings/settings/dict_mutable_widget.py @@ -465,10 +465,6 @@ class ModifiableDictItem(QtWidgets.QWidget): self.entity_widget.change_key(key, self) self.update_style() - @property - def value_is_env_group(self): - return self.entity_widget.value_is_env_group - def update_key_label(self): if not self.collapsible_key: return diff --git a/openpype/tools/standalonepublish/widgets/widget_components.py b/openpype/tools/standalonepublish/widgets/widget_components.py index fbafc7142a..b3280089c3 100644 --- a/openpype/tools/standalonepublish/widgets/widget_components.py +++ b/openpype/tools/standalonepublish/widgets/widget_components.py @@ -202,6 +202,7 @@ def cli_publish(data, publish_paths, gui=True): if os.path.exists(json_data_path): with open(json_data_path, "r") as f: result = json.load(f) + os.remove(json_data_path) log.info(f"Publish result: {result}") diff --git a/openpype/tools/standalonepublish/widgets/widget_drop_frame.py b/openpype/tools/standalonepublish/widgets/widget_drop_frame.py index e6c7328e88..f8a8273b26 100644 --- a/openpype/tools/standalonepublish/widgets/widget_drop_frame.py +++ b/openpype/tools/standalonepublish/widgets/widget_drop_frame.py @@ -38,7 +38,7 @@ class DropDataFrame(QtWidgets.QFrame): } sequence_types = [ - ".bgeo", ".vdb" + ".bgeo", ".vdb", ".bgeosc", ".bgeogz" ] def __init__(self, parent): diff --git a/openpype/tools/traypublisher/window.py b/openpype/tools/traypublisher/window.py index 972e89a3ae..5934c4aa8a 100644 --- a/openpype/tools/traypublisher/window.py +++ b/openpype/tools/traypublisher/window.py @@ -54,8 +54,11 @@ class StandaloneOverlayWidget(QtWidgets.QFrame): ) confirm_btn = QtWidgets.QPushButton("Confirm", content_widget) + cancel_btn = QtWidgets.QPushButton("Cancel", content_widget) + cancel_btn.setVisible(False) btns_layout = QtWidgets.QHBoxLayout() btns_layout.addStretch(1) + btns_layout.addWidget(cancel_btn, 0) btns_layout.addWidget(confirm_btn, 0) content_layout = QtWidgets.QVBoxLayout(content_widget) @@ -77,15 +80,19 @@ class StandaloneOverlayWidget(QtWidgets.QFrame): projects_view.doubleClicked.connect(self._on_double_click) confirm_btn.clicked.connect(self._on_confirm_click) + cancel_btn.clicked.connect(self._on_cancel_click) self._projects_view = projects_view self._projects_model = projects_model + self._cancel_btn = cancel_btn self._confirm_btn = confirm_btn self._publisher_window = publisher_window + self._project_name = None def showEvent(self, event): self._projects_model.refresh() + self._cancel_btn.setVisible(self._project_name is not None) super(StandaloneOverlayWidget, self).showEvent(event) def _on_double_click(self): @@ -94,13 +101,18 @@ class StandaloneOverlayWidget(QtWidgets.QFrame): def _on_confirm_click(self): self.set_selected_project() + def _on_cancel_click(self): + self._set_project(self._project_name) + def set_selected_project(self): index = self._projects_view.currentIndex() project_name = index.data(PROJECT_NAME_ROLE) - if not project_name: - return + if project_name: + self._set_project(project_name) + def _set_project(self, project_name): + self._project_name = project_name traypublisher.set_project_name(project_name) self.setVisible(False) self.project_selected.emit(project_name) @@ -110,6 +122,13 @@ class TrayPublishWindow(PublisherWindow): def __init__(self, *args, **kwargs): super(TrayPublishWindow, self).__init__(reset_on_show=False) + flags = self.windowFlags() + # Disable always on top hint + if flags & QtCore.Qt.WindowStaysOnTopHint: + flags ^= QtCore.Qt.WindowStaysOnTopHint + + self.setWindowFlags(flags) + overlay_widget = StandaloneOverlayWidget(self) btns_widget = QtWidgets.QWidget(self) @@ -136,6 +155,12 @@ class TrayPublishWindow(PublisherWindow): self._back_to_overlay_btn = back_to_overlay_btn self._overlay_widget = overlay_widget + def _set_publish_frame_visible(self, publish_frame_visible): + super(TrayPublishWindow, self)._set_publish_frame_visible( + publish_frame_visible + ) + self._back_to_overlay_btn.setVisible(not publish_frame_visible) + def _on_back_to_overlay(self): self._overlay_widget.setVisible(True) self._resize_overlay() diff --git a/openpype/tools/utils/assets_widget.py b/openpype/tools/utils/assets_widget.py index 3d4efcdd4d..82bdcd63a2 100644 --- a/openpype/tools/utils/assets_widget.py +++ b/openpype/tools/utils/assets_widget.py @@ -494,8 +494,6 @@ class AssetModel(QtGui.QStandardItemModel): # Remove cache of removed items for asset_id in removed_asset_ids: self._items_by_asset_id.pop(asset_id) - if asset_id in self._items_with_color_by_id: - self._items_with_color_by_id.pop(asset_id) # Refresh data # - all items refresh all data except id @@ -589,10 +587,12 @@ class AssetsWidget(QtWidgets.QWidget): view = AssetsView(self) view.setModel(proxy) + header_widget = QtWidgets.QWidget(self) + current_asset_icon = qtawesome.icon( "fa.arrow-down", color=get_default_tools_icon_color() ) - current_asset_btn = QtWidgets.QPushButton(self) + current_asset_btn = QtWidgets.QPushButton(header_widget) current_asset_btn.setIcon(current_asset_icon) current_asset_btn.setToolTip("Go to Asset from current Session") # Hide by default @@ -601,25 +601,35 @@ class AssetsWidget(QtWidgets.QWidget): refresh_icon = qtawesome.icon( "fa.refresh", color=get_default_tools_icon_color() ) - refresh_btn = QtWidgets.QPushButton(self) + refresh_btn = QtWidgets.QPushButton(header_widget) refresh_btn.setIcon(refresh_icon) refresh_btn.setToolTip("Refresh items") - filter_input = PlaceholderLineEdit(self) + filter_input = PlaceholderLineEdit(header_widget) filter_input.setPlaceholderText("Filter assets..") # Header - header_layout = QtWidgets.QHBoxLayout() + header_layout = QtWidgets.QHBoxLayout(header_widget) + header_layout.setContentsMargins(0, 0, 0, 0) header_layout.addWidget(filter_input) header_layout.addWidget(current_asset_btn) header_layout.addWidget(refresh_btn) + # Make header widgets expand vertically if there is a place + for widget in ( + current_asset_btn, + refresh_btn, + filter_input, + ): + size_policy = widget.sizePolicy() + size_policy.setVerticalPolicy(size_policy.MinimumExpanding) + widget.setSizePolicy(size_policy) + # Layout layout = QtWidgets.QVBoxLayout(self) layout.setContentsMargins(0, 0, 0, 0) - layout.setSpacing(4) - layout.addLayout(header_layout) - layout.addWidget(view) + layout.addWidget(header_widget, 0) + layout.addWidget(view, 1) # Signals/Slots filter_input.textChanged.connect(self._on_filter_text_change) @@ -630,6 +640,8 @@ class AssetsWidget(QtWidgets.QWidget): current_asset_btn.clicked.connect(self._on_current_asset_click) view.doubleClicked.connect(self.double_clicked) + self._header_widget = header_widget + self._filter_input = filter_input self._refresh_btn = refresh_btn self._current_asset_btn = current_asset_btn self._model = model @@ -637,8 +649,14 @@ class AssetsWidget(QtWidgets.QWidget): self._view = view self._last_project_name = None + self._last_btns_height = None + self.model_selection = {} + @property + def header_widget(self): + return self._header_widget + def _create_source_model(self): model = AssetModel(dbcon=self.dbcon, parent=self) model.refreshed.connect(self._on_model_refresh) @@ -669,6 +687,7 @@ class AssetsWidget(QtWidgets.QWidget): This separation gives ability to override this method and use it in differnt way. """ + self.set_current_session_asset() def set_current_session_asset(self): @@ -681,6 +700,7 @@ class AssetsWidget(QtWidgets.QWidget): Some tools may have their global refresh button or do not support refresh at all. """ + if visible is None: visible = not self._refresh_btn.isVisible() self._refresh_btn.setVisible(visible) @@ -690,6 +710,7 @@ class AssetsWidget(QtWidgets.QWidget): Not all tools support using of current context asset. """ + if visible is None: visible = not self._current_asset_btn.isVisible() self._current_asset_btn.setVisible(visible) @@ -723,6 +744,7 @@ class AssetsWidget(QtWidgets.QWidget): so if you're modifying model keep in mind that this method should be called when refresh is done. """ + self._proxy.sort(0) self._set_loading_state(loading=False, empty=not has_item) self.refreshed.emit() @@ -767,6 +789,7 @@ class SingleSelectAssetsWidget(AssetsWidget): Contain single selection specific api methods. """ + def get_selected_asset_id(self): """Currently selected asset id.""" selection_model = self._view.selectionModel() diff --git a/openpype/vendor/python/common/scriptsmenu/action.py b/openpype/vendor/python/common/scriptsmenu/action.py index dc4d775f6a..5e68628406 100644 --- a/openpype/vendor/python/common/scriptsmenu/action.py +++ b/openpype/vendor/python/common/scriptsmenu/action.py @@ -119,7 +119,8 @@ module.{module_name}()""" """ # get the current application and its linked keyboard modifiers - modifiers = QtWidgets.QApplication.keyboardModifiers() + app = QtWidgets.QApplication.instance() + modifiers = app.keyboardModifiers() # If the menu has a callback registered for the current modifier # we run the callback instead of the action itself. diff --git a/openpype/vendor/python/common/scriptsmenu/launchfornuke.py b/openpype/vendor/python/common/scriptsmenu/launchfornuke.py index 23e4ed1b4d..72302a79a6 100644 --- a/openpype/vendor/python/common/scriptsmenu/launchfornuke.py +++ b/openpype/vendor/python/common/scriptsmenu/launchfornuke.py @@ -8,7 +8,7 @@ def _nuke_main_window(): if (obj.inherits('QMainWindow') and obj.metaObject().className() == 'Foundry::UI::DockMainWindow'): return obj - raise RuntimeError('Could not find Nuke MainWindow instance') + raise RuntimeError('Could not find Nuke MainWindow instance') def _nuke_main_menubar(): @@ -22,9 +22,6 @@ def _nuke_main_menubar(): def main(title="Scripts"): - # Register control + shift callback to add to shelf (Nuke behavior) - # modifiers = QtCore.Qt.ControlModifier | QtCore.Qt.ShiftModifier - # menu.register_callback(modifiers, to_shelf) nuke_main_bar = _nuke_main_menubar() for nuke_bar in nuke_main_bar.children(): if isinstance(nuke_bar, scriptsmenu.ScriptsMenu): @@ -33,4 +30,4 @@ def main(title="Scripts"): return menu menu = scriptsmenu.ScriptsMenu(title=title, parent=nuke_main_bar) - return menu \ No newline at end of file + return menu diff --git a/openpype/vendor/python/common/scriptsmenu/scriptsmenu.py b/openpype/vendor/python/common/scriptsmenu/scriptsmenu.py index e2b7ff96c7..9e7c094902 100644 --- a/openpype/vendor/python/common/scriptsmenu/scriptsmenu.py +++ b/openpype/vendor/python/common/scriptsmenu/scriptsmenu.py @@ -264,8 +264,7 @@ class ScriptsMenu(QtWidgets.QMenu): action.setVisible(True) else: for action in self._script_actions: - if not action.has_tag(search.lower()): - action.setVisible(False) + action.setVisible(action.has_tag(search.lower())) # Set visibility for all submenus for action in self.actions(): diff --git a/openpype/vendor/python/common/scriptsmenu/version.py b/openpype/vendor/python/common/scriptsmenu/version.py index 73f9426c2d..52ec49c845 100644 --- a/openpype/vendor/python/common/scriptsmenu/version.py +++ b/openpype/vendor/python/common/scriptsmenu/version.py @@ -1,6 +1,6 @@ VERSION_MAJOR = 1 VERSION_MINOR = 5 -VERSION_PATCH = 1 +VERSION_PATCH = 2 version = '{}.{}.{}'.format(VERSION_MAJOR, VERSION_MINOR, VERSION_PATCH) diff --git a/openpype/version.py b/openpype/version.py index 662adf28ca..12f25cdcea 100644 --- a/openpype/version.py +++ b/openpype/version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8 -*- """Package declaring Pype version.""" -__version__ = "3.10.0-nightly.2" +__version__ = "3.10.1-nightly.1" diff --git a/openpype/widgets/attribute_defs/__init__.py b/openpype/widgets/attribute_defs/__init__.py index 147efeb3d6..ce6b80109e 100644 --- a/openpype/widgets/attribute_defs/__init__.py +++ b/openpype/widgets/attribute_defs/__init__.py @@ -1,6 +1,10 @@ -from .widgets import create_widget_for_attr_def +from .widgets import ( + create_widget_for_attr_def, + AttributeDefinitionsWidget, +) __all__ = ( "create_widget_for_attr_def", + "AttributeDefinitionsWidget", ) diff --git a/openpype/widgets/attribute_defs/files_widget.py b/openpype/widgets/attribute_defs/files_widget.py index 34f7d159ad..23cf8342b1 100644 --- a/openpype/widgets/attribute_defs/files_widget.py +++ b/openpype/widgets/attribute_defs/files_widget.py @@ -1,15 +1,16 @@ import os import collections import uuid -import clique + from Qt import QtWidgets, QtCore, QtGui -from openpype.tools.utils import paint_image_with_color -# TODO change imports -from openpype.tools.resources import ( - get_pixmap, - get_image, +from openpype.lib import FileDefItem +from openpype.tools.utils import ( + paint_image_with_color, + ClickableLabel, ) +# TODO change imports +from openpype.tools.resources import get_image from openpype.tools.utils import ( IconButton, PixmapLabel @@ -21,7 +22,8 @@ ITEM_ICON_ROLE = QtCore.Qt.UserRole + 3 FILENAMES_ROLE = QtCore.Qt.UserRole + 4 DIRPATH_ROLE = QtCore.Qt.UserRole + 5 IS_DIR_ROLE = QtCore.Qt.UserRole + 6 -EXT_ROLE = QtCore.Qt.UserRole + 7 +IS_SEQUENCE_ROLE = QtCore.Qt.UserRole + 7 +EXT_ROLE = QtCore.Qt.UserRole + 8 class DropEmpty(QtWidgets.QWidget): @@ -73,175 +75,91 @@ class DropEmpty(QtWidgets.QWidget): class FilesModel(QtGui.QStandardItemModel): - sequence_exts = [ - ".ani", ".anim", ".apng", ".art", ".bmp", ".bpg", ".bsave", ".cal", - ".cin", ".cpc", ".cpt", ".dds", ".dpx", ".ecw", ".exr", ".fits", - ".flic", ".flif", ".fpx", ".gif", ".hdri", ".hevc", ".icer", - ".icns", ".ico", ".cur", ".ics", ".ilbm", ".jbig", ".jbig2", - ".jng", ".jpeg", ".jpeg-ls", ".2000", ".jpg", ".xr", - ".jpeg-hdr", ".kra", ".mng", ".miff", ".nrrd", - ".ora", ".pam", ".pbm", ".pgm", ".ppm", ".pnm", ".pcx", ".pgf", - ".pictor", ".png", ".psb", ".psp", ".qtvr", ".ras", - ".rgbe", ".logluv", ".tiff", ".sgi", ".tga", ".tiff", ".tiff/ep", - ".tiff/it", ".ufo", ".ufp", ".wbmp", ".webp", ".xbm", ".xcf", - ".xpm", ".xwd" - ] - - def __init__(self): + def __init__(self, single_item, allow_sequences): super(FilesModel, self).__init__() + + self._single_item = single_item + self._multivalue = False + self._allow_sequences = allow_sequences + + self._items_by_id = {} + self._file_items_by_id = {} self._filenames_by_dirpath = collections.defaultdict(set) self._items_by_dirpath = collections.defaultdict(list) - def add_filepaths(self, filepaths): - if not filepaths: + def set_multivalue(self, multivalue): + """Disable filtering.""" + + if self._multivalue == multivalue: + return + self._multivalue = multivalue + + def add_filepaths(self, items): + if not items: return - new_dirpaths = set() - for filepath in filepaths: - filename = os.path.basename(filepath) - dirpath = os.path.dirname(filepath) - filenames = self._filenames_by_dirpath[dirpath] - if filename not in filenames: - new_dirpaths.add(dirpath) - filenames.add(filename) - self._refresh_items(new_dirpaths) + file_items = FileDefItem.from_value(items, self._allow_sequences) + if not file_items: + return + + if not self._multivalue and self._single_item: + file_items = [file_items[0]] + current_ids = list(self._file_items_by_id.keys()) + if current_ids: + self.remove_item_by_ids(current_ids) + + new_model_items = [] + for file_item in file_items: + item_id, model_item = self._create_item(file_item) + new_model_items.append(model_item) + self._file_items_by_id[item_id] = file_item + self._items_by_id[item_id] = model_item + + if new_model_items: + roow_item = self.invisibleRootItem() + roow_item.appendRows(new_model_items) def remove_item_by_ids(self, item_ids): if not item_ids: return - remaining_ids = set(item_ids) - result = collections.defaultdict(list) - for dirpath, items in self._items_by_dirpath.items(): - if not remaining_ids: - break + items = [] + for item_id in set(item_ids): + if item_id not in self._items_by_id: + continue + item = self._items_by_id.pop(item_id) + self._file_items_by_id.pop(item_id) + items.append(item) + + if items: for item in items: - if not remaining_ids: - break - item_id = item.data(ITEM_ID_ROLE) - if item_id in remaining_ids: - remaining_ids.remove(item_id) - result[dirpath].append(item) - - if not result: - return - - dirpaths = set(result.keys()) - for dirpath, items in result.items(): - filenames_cache = self._filenames_by_dirpath[dirpath] - for item in items: - filenames = item.data(FILENAMES_ROLE) - - self._items_by_dirpath[dirpath].remove(item) self.removeRows(item.row(), 1) - for filename in filenames: - if filename in filenames_cache: - filenames_cache.remove(filename) - self._refresh_items(dirpaths) + def get_file_item_by_id(self, item_id): + return self._file_items_by_id.get(item_id) - def _refresh_items(self, dirpaths=None): - if dirpaths is None: - dirpaths = set(self._items_by_dirpath.keys()) - - new_items = [] - for dirpath in dirpaths: - items_to_remove = list(self._items_by_dirpath[dirpath]) - cols, remainders = clique.assemble( - self._filenames_by_dirpath[dirpath] + def _create_item(self, file_item): + if file_item.is_dir: + icon_pixmap = paint_image_with_color( + get_image(filename="folder.png"), QtCore.Qt.white + ) + else: + icon_pixmap = paint_image_with_color( + get_image(filename="file.png"), QtCore.Qt.white ) - filtered_cols = [] - for collection in cols: - filenames = set(collection) - valid_col = True - for filename in filenames: - ext = os.path.splitext(filename)[-1] - valid_col = ext in self.sequence_exts - break - - if valid_col: - filtered_cols.append(collection) - else: - for filename in filenames: - remainders.append(filename) - - for filename in remainders: - found = False - for item in items_to_remove: - item_filenames = item.data(FILENAMES_ROLE) - if filename in item_filenames and len(item_filenames) == 1: - found = True - items_to_remove.remove(item) - break - - if found: - continue - - fullpath = os.path.join(dirpath, filename) - if os.path.isdir(fullpath): - icon_pixmap = get_pixmap(filename="folder.png") - else: - icon_pixmap = get_pixmap(filename="file.png") - label = filename - filenames = [filename] - item = self._create_item( - label, filenames, dirpath, icon_pixmap - ) - new_items.append(item) - self._items_by_dirpath[dirpath].append(item) - - for collection in filtered_cols: - filenames = set(collection) - found = False - for item in items_to_remove: - item_filenames = item.data(FILENAMES_ROLE) - if item_filenames == filenames: - found = True - items_to_remove.remove(item) - break - - if found: - continue - - col_range = collection.format("{ranges}") - label = "{}<{}>{}".format( - collection.head, col_range, collection.tail - ) - icon_pixmap = get_pixmap(filename="files.png") - item = self._create_item( - label, filenames, dirpath, icon_pixmap - ) - new_items.append(item) - self._items_by_dirpath[dirpath].append(item) - - for item in items_to_remove: - self._items_by_dirpath[dirpath].remove(item) - self.removeRows(item.row(), 1) - - if new_items: - self.invisibleRootItem().appendRows(new_items) - - def _create_item(self, label, filenames, dirpath, icon_pixmap=None): - first_filename = None - for filename in filenames: - first_filename = filename - break - ext = os.path.splitext(first_filename)[-1] - is_dir = False - if len(filenames) == 1: - filepath = os.path.join(dirpath, first_filename) - is_dir = os.path.isdir(filepath) item = QtGui.QStandardItem() - item.setData(str(uuid.uuid4()), ITEM_ID_ROLE) - item.setData(label, ITEM_LABEL_ROLE) - item.setData(filenames, FILENAMES_ROLE) - item.setData(dirpath, DIRPATH_ROLE) + item_id = str(uuid.uuid4()) + item.setData(item_id, ITEM_ID_ROLE) + item.setData(file_item.label or "< empty >", ITEM_LABEL_ROLE) + item.setData(file_item.filenames, FILENAMES_ROLE) + item.setData(file_item.directory, DIRPATH_ROLE) item.setData(icon_pixmap, ITEM_ICON_ROLE) - item.setData(ext, EXT_ROLE) - item.setData(is_dir, IS_DIR_ROLE) + item.setData(file_item.ext, EXT_ROLE) + item.setData(file_item.is_dir, IS_DIR_ROLE) + item.setData(file_item.is_sequence, IS_SEQUENCE_ROLE) - return item + return item_id, item class FilesProxyModel(QtCore.QSortFilterProxyModel): @@ -249,6 +167,15 @@ class FilesProxyModel(QtCore.QSortFilterProxyModel): super(FilesProxyModel, self).__init__(*args, **kwargs) self._allow_folders = False self._allowed_extensions = None + self._multivalue = False + + def set_multivalue(self, multivalue): + """Disable filtering.""" + + if self._multivalue == multivalue: + return + self._multivalue = multivalue + self.invalidateFilter() def set_allow_folders(self, allow=None): if allow is None: @@ -267,7 +194,34 @@ class FilesProxyModel(QtCore.QSortFilterProxyModel): self._allowed_extensions = extensions self.invalidateFilter() + def are_valid_files(self, filepaths): + for filepath in filepaths: + if os.path.isfile(filepath): + _, ext = os.path.splitext(filepath) + if ext in self._allowed_extensions: + return True + + elif self._allow_folders: + return True + return False + + def filter_valid_files(self, filepaths): + filtered_paths = [] + for filepath in filepaths: + if os.path.isfile(filepath): + _, ext = os.path.splitext(filepath) + if ext in self._allowed_extensions: + filtered_paths.append(filepath) + + elif self._allow_folders: + filtered_paths.append(filepath) + return filtered_paths + def filterAcceptsRow(self, row, parent_index): + # Skip filtering if multivalue is set + if self._multivalue: + return True + model = self.sourceModel() index = model.index(row, self.filterKeyColumn(), parent_index) # First check if item is folder and if folders are enabled @@ -297,9 +251,11 @@ class FilesProxyModel(QtCore.QSortFilterProxyModel): class ItemWidget(QtWidgets.QWidget): - remove_requested = QtCore.Signal(str) + context_menu_requested = QtCore.Signal(QtCore.QPoint) - def __init__(self, item_id, label, pixmap_icon, parent=None): + def __init__( + self, item_id, label, pixmap_icon, is_sequence, multivalue, parent=None + ): self._item_id = item_id super(ItemWidget, self).__init__(parent) @@ -308,30 +264,73 @@ class ItemWidget(QtWidgets.QWidget): icon_widget = PixmapLabel(pixmap_icon, self) label_widget = QtWidgets.QLabel(label, self) - pixmap = paint_image_with_color( - get_image(filename="delete.png"), QtCore.Qt.white + + label_size_hint = label_widget.sizeHint() + height = label_size_hint.height() + actions_menu_pix = paint_image_with_color( + get_image(filename="menu.png"), QtCore.Qt.white ) - remove_btn = IconButton(self) - remove_btn.setIcon(QtGui.QIcon(pixmap)) + + split_btn = ClickableLabel(self) + split_btn.setFixedSize(height, height) + split_btn.setPixmap(actions_menu_pix) + if multivalue: + split_btn.setVisible(False) + else: + split_btn.setVisible(is_sequence) layout = QtWidgets.QHBoxLayout(self) - layout.setContentsMargins(0, 0, 0, 0) + layout.setContentsMargins(5, 5, 5, 5) layout.addWidget(icon_widget, 0) layout.addWidget(label_widget, 1) - layout.addWidget(remove_btn, 0) + layout.addWidget(split_btn, 0) - remove_btn.clicked.connect(self._on_remove_clicked) + split_btn.clicked.connect(self._on_actions_clicked) self._icon_widget = icon_widget self._label_widget = label_widget - self._remove_btn = remove_btn + self._split_btn = split_btn + self._actions_menu_pix = actions_menu_pix + self._last_scaled_pix_height = None - def _on_remove_clicked(self): - self.remove_requested.emit(self._item_id) + def _update_btn_size(self): + label_size_hint = self._label_widget.sizeHint() + height = label_size_hint.height() + if height == self._last_scaled_pix_height: + return + self._last_scaled_pix_height = height + self._split_btn.setFixedSize(height, height) + pix = self._actions_menu_pix.scaled( + height, height, + QtCore.Qt.KeepAspectRatio, + QtCore.Qt.SmoothTransformation + ) + self._split_btn.setPixmap(pix) + + def showEvent(self, event): + super(ItemWidget, self).showEvent(event) + self._update_btn_size() + + def resizeEvent(self, event): + super(ItemWidget, self).resizeEvent(event) + self._update_btn_size() + + def _on_actions_clicked(self): + pos = self._split_btn.rect().bottomLeft() + point = self._split_btn.mapToGlobal(pos) + self.context_menu_requested.emit(point) + + +class InViewButton(IconButton): + pass class FilesView(QtWidgets.QListView): """View showing instances and their groups.""" + + remove_requested = QtCore.Signal() + context_menu_requested = QtCore.Signal(QtCore.QPoint) + def __init__(self, *args, **kwargs): super(FilesView, self).__init__(*args, **kwargs) @@ -339,9 +338,51 @@ class FilesView(QtWidgets.QListView): self.setSelectionMode( QtWidgets.QAbstractItemView.ExtendedSelection ) + self.setContextMenuPolicy(QtCore.Qt.CustomContextMenu) + + remove_btn = InViewButton(self) + pix_enabled = paint_image_with_color( + get_image(filename="delete.png"), QtCore.Qt.white + ) + pix_disabled = paint_image_with_color( + get_image(filename="delete.png"), QtCore.Qt.gray + ) + icon = QtGui.QIcon(pix_enabled) + icon.addPixmap(pix_disabled, icon.Disabled, icon.Off) + remove_btn.setIcon(icon) + remove_btn.setEnabled(False) + + remove_btn.clicked.connect(self._on_remove_clicked) + self.customContextMenuRequested.connect(self._on_context_menu_request) + + self._remove_btn = remove_btn + + def setSelectionModel(self, *args, **kwargs): + """Catch selection model set to register signal callback. + + Selection model is not available during initialization. + """ + + super(FilesView, self).setSelectionModel(*args, **kwargs) + selection_model = self.selectionModel() + selection_model.selectionChanged.connect(self._on_selection_change) + + def set_multivalue(self, multivalue): + """Disable remove button on multivalue.""" + + self._remove_btn.setVisible(not multivalue) + + def has_selected_item_ids(self): + """Is any index selected.""" + for index in self.selectionModel().selectedIndexes(): + instance_id = index.data(ITEM_ID_ROLE) + if instance_id is not None: + return True + return False def get_selected_item_ids(self): """Ids of selected instances.""" + selected_item_ids = set() for index in self.selectionModel().selectedIndexes(): instance_id = index.data(ITEM_ID_ROLE) @@ -349,35 +390,63 @@ class FilesView(QtWidgets.QListView): selected_item_ids.add(instance_id) return selected_item_ids + def has_selected_sequence(self): + for index in self.selectionModel().selectedIndexes(): + if index.data(IS_SEQUENCE_ROLE): + return True + return False + def event(self, event): - if not event.type() == QtCore.QEvent.KeyPress: - pass - - elif event.key() == QtCore.Qt.Key_Space: - self.toggle_requested.emit(-1) - return True - - elif event.key() == QtCore.Qt.Key_Backspace: - self.toggle_requested.emit(0) - return True - - elif event.key() == QtCore.Qt.Key_Return: - self.toggle_requested.emit(1) - return True + if event.type() == QtCore.QEvent.KeyPress: + if ( + event.key() == QtCore.Qt.Key_Delete + and self.has_selected_item_ids() + ): + self.remove_requested.emit() + return True return super(FilesView, self).event(event) + def _on_context_menu_request(self, pos): + index = self.indexAt(pos) + if index.isValid(): + point = self.viewport().mapToGlobal(pos) + self.context_menu_requested.emit(point) -class MultiFilesWidget(QtWidgets.QFrame): + def _on_selection_change(self): + self._remove_btn.setEnabled(self.has_selected_item_ids()) + + def _on_remove_clicked(self): + self.remove_requested.emit() + + def _update_remove_btn(self): + """Position remove button to bottom right.""" + + viewport = self.viewport() + height = viewport.height() + pos_x = viewport.width() - self._remove_btn.width() - 5 + pos_y = height - self._remove_btn.height() - 5 + self._remove_btn.move(max(0, pos_x), max(0, pos_y)) + + def resizeEvent(self, event): + super(FilesView, self).resizeEvent(event) + self._update_remove_btn() + + def showEvent(self, event): + super(FilesView, self).showEvent(event) + self._update_remove_btn() + + +class FilesWidget(QtWidgets.QFrame): value_changed = QtCore.Signal() - def __init__(self, parent): - super(MultiFilesWidget, self).__init__(parent) + def __init__(self, single_item, allow_sequences, parent): + super(FilesWidget, self).__init__(parent) self.setAcceptDrops(True) empty_widget = DropEmpty(self) - files_model = FilesModel() + files_model = FilesModel(single_item, allow_sequences) files_proxy_model = FilesProxyModel() files_proxy_model.setSourceModel(files_model) files_view = FilesView(self) @@ -391,8 +460,13 @@ class MultiFilesWidget(QtWidgets.QFrame): files_proxy_model.rowsInserted.connect(self._on_rows_inserted) files_proxy_model.rowsRemoved.connect(self._on_rows_removed) - + files_view.remove_requested.connect(self._on_remove_requested) + files_view.context_menu_requested.connect( + self._on_context_menu_requested + ) self._in_set_value = False + self._single_item = single_item + self._multivalue = False self._empty_widget = empty_widget self._files_model = files_model @@ -401,39 +475,46 @@ class MultiFilesWidget(QtWidgets.QFrame): self._widgets_by_id = {} + def _set_multivalue(self, multivalue): + if self._multivalue == multivalue: + return + self._multivalue = multivalue + self._files_view.set_multivalue(multivalue) + self._files_model.set_multivalue(multivalue) + self._files_proxy_model.set_multivalue(multivalue) + def set_value(self, value, multivalue): self._in_set_value = True + widget_ids = set(self._widgets_by_id.keys()) self._remove_item_by_ids(widget_ids) - # TODO how to display multivalue? - all_same = True - if multivalue: - new_value = set() - item_row = None - for _value in value: - _value_set = set(_value) - new_value |= _value_set - if item_row is None: - item_row = _value_set - elif item_row != _value_set: - all_same = False - value = new_value + self._set_multivalue(multivalue) + + self._add_filepaths(value) - if value: - self._add_filepaths(value) self._in_set_value = False def current_value(self): model = self._files_proxy_model - filepaths = set() + item_ids = set() for row in range(model.rowCount()): index = model.index(row, 0) - dirpath = index.data(DIRPATH_ROLE) - filenames = index.data(FILENAMES_ROLE) - for filename in filenames: - filepaths.add(os.path.join(dirpath, filename)) - return list(filepaths) + item_ids.add(index.data(ITEM_ID_ROLE)) + + file_items = [] + for item_id in item_ids: + file_item = self._files_model.get_file_item_by_id(item_id) + if file_item is not None: + file_items.append(file_item.to_dict()) + + if not self._single_item: + return file_items + if file_items: + return file_items[0] + + empty_item = FileDefItem.create_empty_item() + return empty_item.to_dict() def set_filters(self, folders_allowed, exts_filter): self._files_proxy_model.set_allow_folders(folders_allowed) @@ -447,13 +528,22 @@ class MultiFilesWidget(QtWidgets.QFrame): continue label = index.data(ITEM_LABEL_ROLE) pixmap_icon = index.data(ITEM_ICON_ROLE) + is_sequence = index.data(IS_SEQUENCE_ROLE) - widget = ItemWidget(item_id, label, pixmap_icon) + widget = ItemWidget( + item_id, + label, + pixmap_icon, + is_sequence, + self._multivalue + ) + widget.context_menu_requested.connect( + self._on_context_menu_requested + ) self._files_view.setIndexWidget(index, widget) self._files_proxy_model.setData( index, widget.sizeHint(), QtCore.Qt.SizeHintRole ) - widget.remove_requested.connect(self._on_remove_request) self._widgets_by_id[item_id] = widget self._files_proxy_model.sort(0) @@ -481,27 +571,51 @@ class MultiFilesWidget(QtWidgets.QFrame): if not self._in_set_value: self.value_changed.emit() - def _on_remove_request(self, item_id): - found_index = None - for row in range(self._files_model.rowCount()): - index = self._files_model.index(row, 0) - _item_id = index.data(ITEM_ID_ROLE) - if item_id == _item_id: - found_index = index - break + def _on_split_request(self): + if self._multivalue: + return - if found_index is None: + item_ids = self._files_view.get_selected_item_ids() + if not item_ids: + return + + for item_id in item_ids: + file_item = self._files_model.get_file_item_by_id(item_id) + if not file_item: + return + + new_items = file_item.split_sequence() + self._add_filepaths(new_items) + self._remove_item_by_ids(item_ids) + + def _on_remove_requested(self): + if self._multivalue: return items_to_delete = self._files_view.get_selected_item_ids() - if item_id not in items_to_delete: - items_to_delete = [item_id] + if items_to_delete: + self._remove_item_by_ids(items_to_delete) - self._remove_item_by_ids(items_to_delete) + def _on_context_menu_requested(self, pos): + if self._multivalue: + return + + menu = QtWidgets.QMenu(self._files_view) + + if self._files_view.has_selected_sequence(): + split_action = QtWidgets.QAction("Split sequence", menu) + split_action.triggered.connect(self._on_split_request) + menu.addAction(split_action) + + remove_action = QtWidgets.QAction("Remove", menu) + remove_action.triggered.connect(self._on_remove_requested) + menu.addAction(remove_action) + + menu.popup(pos) def sizeHint(self): # Get size hints of widget and visible widgets - result = super(MultiFilesWidget, self).sizeHint() + result = super(FilesWidget, self).sizeHint() if not self._files_view.isVisible(): not_visible_hint = self._files_view.sizeHint() else: @@ -523,15 +637,9 @@ class MultiFilesWidget(QtWidgets.QFrame): return result def dragEnterEvent(self, event): - mime_data = event.mimeData() - if mime_data.hasUrls(): - event.setDropAction(QtCore.Qt.CopyAction) - event.accept() + if self._multivalue: + return - def dragLeaveEvent(self, event): - event.accept() - - def dropEvent(self, event): mime_data = event.mimeData() if mime_data.hasUrls(): filepaths = [] @@ -539,6 +647,25 @@ class MultiFilesWidget(QtWidgets.QFrame): filepath = url.toLocalFile() if os.path.exists(filepath): filepaths.append(filepath) + + if self._files_proxy_model.are_valid_files(filepaths): + event.setDropAction(QtCore.Qt.CopyAction) + event.accept() + + def dragLeaveEvent(self, event): + event.accept() + + def dropEvent(self, event): + mime_data = event.mimeData() + if not self._multivalue and mime_data.hasUrls(): + filepaths = [] + for url in mime_data.urls(): + filepath = url.toLocalFile() + if os.path.exists(filepath): + filepaths.append(filepath) + + # Filter filepaths before passing it to model + filepaths = self._files_proxy_model.filter_valid_files(filepaths) if filepaths: self._add_filepaths(filepaths) event.accept() @@ -555,92 +682,3 @@ class MultiFilesWidget(QtWidgets.QFrame): files_exists = self._files_proxy_model.rowCount() > 0 self._files_view.setVisible(files_exists) self._empty_widget.setVisible(not files_exists) - - -class SingleFileWidget(QtWidgets.QWidget): - value_changed = QtCore.Signal() - - def __init__(self, parent): - super(SingleFileWidget, self).__init__(parent) - - self.setAcceptDrops(True) - - filepath_input = QtWidgets.QLineEdit(self) - - browse_btn = QtWidgets.QPushButton("Browse", self) - browse_btn.setVisible(False) - - layout = QtWidgets.QHBoxLayout(self) - layout.setContentsMargins(0, 0, 0, 0) - layout.addWidget(filepath_input, 1) - layout.addWidget(browse_btn, 0) - - browse_btn.clicked.connect(self._on_browse_clicked) - filepath_input.textChanged.connect(self._on_text_change) - - self._in_set_value = False - - self._filepath_input = filepath_input - self._folders_allowed = False - self._exts_filter = [] - - def set_value(self, value, multivalue): - self._in_set_value = True - - if multivalue: - set_value = set(value) - if len(set_value) == 1: - value = tuple(set_value)[0] - else: - value = "< Multiselection >" - self._filepath_input.setText(value) - - self._in_set_value = False - - def current_value(self): - return self._filepath_input.text() - - def set_filters(self, folders_allowed, exts_filter): - self._folders_allowed = folders_allowed - self._exts_filter = exts_filter - - def _on_text_change(self, text): - if not self._in_set_value: - self.value_changed.emit() - - def _on_browse_clicked(self): - # TODO implement file dialog logic in '_on_browse_clicked' - print("_on_browse_clicked") - - def dragEnterEvent(self, event): - mime_data = event.mimeData() - if not mime_data.hasUrls(): - return - - filepaths = [] - for url in mime_data.urls(): - filepath = url.toLocalFile() - if os.path.exists(filepath): - filepaths.append(filepath) - - # TODO add folder, extensions check - if len(filepaths) == 1: - event.setDropAction(QtCore.Qt.CopyAction) - event.accept() - - def dragLeaveEvent(self, event): - event.accept() - - def dropEvent(self, event): - mime_data = event.mimeData() - if mime_data.hasUrls(): - filepaths = [] - for url in mime_data.urls(): - filepath = url.toLocalFile() - if os.path.exists(filepath): - filepaths.append(filepath) - # TODO filter check - if len(filepaths) == 1: - self._filepath_input.setText(filepaths[0]) - - event.accept() diff --git a/openpype/widgets/attribute_defs/widgets.py b/openpype/widgets/attribute_defs/widgets.py index 23f025967d..b6493b80a8 100644 --- a/openpype/widgets/attribute_defs/widgets.py +++ b/openpype/widgets/attribute_defs/widgets.py @@ -1,4 +1,5 @@ import uuid +import copy from Qt import QtWidgets, QtCore @@ -10,11 +11,14 @@ from openpype.lib.attribute_definitions import ( EnumDef, BoolDef, FileDef, + UIDef, UISeparatorDef, UILabelDef ) from openpype.widgets.nice_checkbox import NiceCheckbox +from .files_widget import FilesWidget + def create_widget_for_attr_def(attr_def, parent=None): if not isinstance(attr_def, AbtractAttrDef): @@ -51,6 +55,108 @@ def create_widget_for_attr_def(attr_def, parent=None): )) +class AttributeDefinitionsWidget(QtWidgets.QWidget): + """Create widgets for attribute definitions in grid layout. + + Widget creates input widgets for passed attribute definitions. + + Widget can't handle multiselection values. + """ + + def __init__(self, attr_defs=None, parent=None): + super(AttributeDefinitionsWidget, self).__init__(parent) + + self._widgets = [] + self._current_keys = set() + + self.set_attr_defs(attr_defs) + + def clear_attr_defs(self): + """Remove all existing widgets and reset layout if needed.""" + self._widgets = [] + self._current_keys = set() + + layout = self.layout() + if layout is not None: + if layout.count() == 0: + return + + while layout.count(): + item = layout.takeAt(0) + widget = item.widget() + if widget: + widget.setVisible(False) + widget.deleteLater() + + layout.deleteLater() + + new_layout = QtWidgets.QGridLayout() + new_layout.setColumnStretch(0, 0) + new_layout.setColumnStretch(1, 1) + self.setLayout(new_layout) + + def set_attr_defs(self, attr_defs): + """Replace current attribute definitions with passed.""" + self.clear_attr_defs() + if attr_defs: + self.add_attr_defs(attr_defs) + + def add_attr_defs(self, attr_defs): + """Add attribute definitions to current.""" + layout = self.layout() + + row = 0 + for attr_def in attr_defs: + if attr_def.key in self._current_keys: + raise KeyError("Duplicated key \"{}\"".format(attr_def.key)) + + self._current_keys.add(attr_def.key) + widget = create_widget_for_attr_def(attr_def, self) + + expand_cols = 2 + if attr_def.is_value_def and attr_def.is_label_horizontal: + expand_cols = 1 + + col_num = 2 - expand_cols + + if attr_def.label: + label_widget = QtWidgets.QLabel(attr_def.label, self) + layout.addWidget( + label_widget, row, 0, 1, expand_cols + ) + if not attr_def.is_label_horizontal: + row += 1 + + layout.addWidget( + widget, row, col_num, 1, expand_cols + ) + self._widgets.append(widget) + row += 1 + + def set_value(self, value): + new_value = copy.deepcopy(value) + unused_keys = set(new_value.keys()) + for widget in self._widgets: + attr_def = widget.attr_def + if attr_def.key not in new_value: + continue + unused_keys.remove(attr_def.key) + + widget_value = new_value[attr_def.key] + if widget_value is None: + widget_value = copy.deepcopy(attr_def.default) + widget.set_value(widget_value) + + def current_value(self): + output = {} + for widget in self._widgets: + attr_def = widget.attr_def + if not isinstance(attr_def, UIDef): + output[attr_def.key] = widget.current_value() + + return output + + class _BaseAttrDefWidget(QtWidgets.QWidget): # Type 'object' may not work with older PySide versions value_changed = QtCore.Signal(object, uuid.UUID) @@ -336,16 +442,9 @@ class UnknownAttrWidget(_BaseAttrDefWidget): class FileAttrWidget(_BaseAttrDefWidget): def _ui_init(self): - self.multipath = self.attr_def.multipath - if self.multipath: - from .files_widget import MultiFilesWidget - - input_widget = MultiFilesWidget(self) - - else: - from .files_widget import SingleFileWidget - - input_widget = SingleFileWidget(self) + input_widget = FilesWidget( + self.attr_def.single_item, self.attr_def.allow_sequences, self + ) if self.attr_def.tooltip: input_widget.setToolTip(self.attr_def.tooltip) diff --git a/openpype/widgets/project_settings.py b/openpype/widgets/project_settings.py deleted file mode 100644 index 43ff9f2789..0000000000 --- a/openpype/widgets/project_settings.py +++ /dev/null @@ -1,494 +0,0 @@ -import os -import getpass -import platform - -from Qt import QtCore, QtGui, QtWidgets - -from avalon import style -import ftrack_api - - -class Project_name_getUI(QtWidgets.QWidget): - ''' - Project setting ui: here all the neceserry ui widgets are created - they are going to be used i later proces for dynamic linking of project - in list to project's attributes - ''' - - def __init__(self, parent=None): - super(Project_name_getUI, self).__init__(parent) - - self.platform = platform.system() - self.new_index = 0 - # get projects from ftrack - self.session = ftrack_api.Session() - self.projects_from_ft = self.session.query( - 'Project where status is active') - self.disks_from_ft = self.session.query('Disk') - self.schemas_from_ft = self.session.query('ProjectSchema') - self.projects = self._get_projects_ftrack() - - # define window geometry - self.setWindowTitle('Set project attributes') - self.setWindowFlags(QtCore.Qt.WindowStaysOnTopHint) - self.resize(550, 340) - self.setStyleSheet(style.load_stylesheet()) - - # define disk combobox widget - self.disks = self._get_all_disks() - self.disk_combobox_label = QtWidgets.QLabel('Destination storage:') - self.disk_combobox = QtWidgets.QComboBox() - - # define schema combobox widget - self.schemas = self._get_all_schemas() - self.schema_combobox_label = QtWidgets.QLabel('Project schema:') - self.schema_combobox = QtWidgets.QComboBox() - - # define fps widget - self.fps_label = QtWidgets.QLabel('Fps:') - self.fps_label.setAlignment( - QtCore.Qt.AlignRight | QtCore.Qt.AlignVCenter) - self.fps = QtWidgets.QLineEdit() - - # define project dir widget - self.project_dir_label = QtWidgets.QLabel('Project dir:') - self.project_dir_label.setAlignment( - QtCore.Qt.AlignRight | QtCore.Qt.AlignVCenter) - self.project_dir = QtWidgets.QLineEdit() - - self.project_path_label = QtWidgets.QLabel( - 'Project_path (if not then created):') - self.project_path_label.setAlignment( - QtCore.Qt.AlignHCenter | QtCore.Qt.AlignVCenter) - project_path_font = QtGui.QFont( - "Helvetica [Cronyx]", 12, QtGui.QFont.Bold) - self.project_path = QtWidgets.QLabel() - self.project_path.setObjectName('nom_plan_label') - self.project_path.setStyleSheet( - 'QtWidgets.QLabel#nom_plan_label {color: red}') - self.project_path.setAlignment( - QtCore.Qt.AlignHCenter | QtCore.Qt.AlignVCenter) - self.project_path.setFont(project_path_font) - - # define handles widget - self.handles_label = QtWidgets.QLabel('Handles:') - self.handles_label.setAlignment( - QtCore.Qt.AlignRight | QtCore.Qt.AlignVCenter) - self.handles = QtWidgets.QLineEdit() - - # define resolution widget - self.resolution_w_label = QtWidgets.QLabel('W:') - self.resolution_w = QtWidgets.QLineEdit() - self.resolution_h_label = QtWidgets.QLabel('H:') - self.resolution_h = QtWidgets.QLineEdit() - - devider = QtWidgets.QFrame() - # devider.Shape(QFrame.HLine) - devider.setFrameShape(QtWidgets.QFrame.HLine) - devider.setFrameShadow(QtWidgets.QFrame.Sunken) - - self.generate_lines() - - # define push buttons - self.set_pushbutton = QtWidgets.QPushButton('Set project') - self.cancel_pushbutton = QtWidgets.QPushButton('Cancel') - - # definition of layouts - ############################################ - action_layout = QtWidgets.QHBoxLayout() - action_layout.addWidget(self.set_pushbutton) - action_layout.addWidget(self.cancel_pushbutton) - - # schema property - schema_layout = QtWidgets.QGridLayout() - schema_layout.addWidget(self.schema_combobox, 0, 1) - schema_layout.addWidget(self.schema_combobox_label, 0, 0) - - # storage property - storage_layout = QtWidgets.QGridLayout() - storage_layout.addWidget(self.disk_combobox, 0, 1) - storage_layout.addWidget(self.disk_combobox_label, 0, 0) - - # fps property - fps_layout = QtWidgets.QGridLayout() - fps_layout.addWidget(self.fps, 1, 1) - fps_layout.addWidget(self.fps_label, 1, 0) - - # project dir property - project_dir_layout = QtWidgets.QGridLayout() - project_dir_layout.addWidget(self.project_dir, 1, 1) - project_dir_layout.addWidget(self.project_dir_label, 1, 0) - - # project path property - project_path_layout = QtWidgets.QGridLayout() - spacer_1_item = QtWidgets.QSpacerItem(10, 10) - project_path_layout.addItem(spacer_1_item, 0, 1) - project_path_layout.addWidget(self.project_path_label, 1, 1) - project_path_layout.addWidget(self.project_path, 2, 1) - spacer_2_item = QtWidgets.QSpacerItem(20, 20) - project_path_layout.addItem(spacer_2_item, 3, 1) - - # handles property - handles_layout = QtWidgets.QGridLayout() - handles_layout.addWidget(self.handles, 1, 1) - handles_layout.addWidget(self.handles_label, 1, 0) - - # resolution property - resolution_layout = QtWidgets.QGridLayout() - resolution_layout.addWidget(self.resolution_w_label, 1, 1) - resolution_layout.addWidget(self.resolution_w, 2, 1) - resolution_layout.addWidget(self.resolution_h_label, 1, 2) - resolution_layout.addWidget(self.resolution_h, 2, 2) - - # form project property layout - p_layout = QtWidgets.QGridLayout() - p_layout.addLayout(storage_layout, 1, 0) - p_layout.addLayout(schema_layout, 2, 0) - p_layout.addLayout(project_dir_layout, 3, 0) - p_layout.addLayout(fps_layout, 4, 0) - p_layout.addLayout(handles_layout, 5, 0) - p_layout.addLayout(resolution_layout, 6, 0) - p_layout.addWidget(devider, 7, 0) - spacer_item = QtWidgets.QSpacerItem( - 150, - 40, - QtWidgets.QSizePolicy.Minimum, - QtWidgets.QSizePolicy.Expanding - ) - p_layout.addItem(spacer_item, 8, 0) - - # form with list to one layout with project property - list_layout = QtWidgets.QGridLayout() - list_layout.addLayout(p_layout, 1, 0) - list_layout.addWidget(self.listWidget, 1, 1) - - root_layout = QtWidgets.QVBoxLayout() - root_layout.addLayout(project_path_layout) - root_layout.addWidget(devider) - root_layout.addLayout(list_layout) - root_layout.addLayout(action_layout) - - self.setLayout(root_layout) - - def generate_lines(self): - ''' - Will generate lines of project list - ''' - - self.listWidget = QtWidgets.QListWidget() - for self.index, p in enumerate(self.projects): - item = QtWidgets.QListWidgetItem("{full_name}".format(**p)) - # item.setSelected(False) - self.listWidget.addItem(item) - print(self.listWidget.indexFromItem(item)) - # self.listWidget.setCurrentItem(self.listWidget.itemFromIndex(1)) - - # add options to schemas widget - self.schema_combobox.addItems(self.schemas) - - # add options to disk widget - self.disk_combobox.addItems(self.disks) - - # populate content of project info widgets - self.projects[1] = self._fill_project_attributes_widgets(p, None) - - def _fill_project_attributes_widgets(self, p=None, index=None): - ''' - will generate actual informations wich are saved on ftrack - ''' - - if index is None: - self.new_index = 1 - - if not p: - pass - # change schema selection - for i, schema in enumerate(self.schemas): - if p['project_schema']['name'] in schema: - break - self.schema_combobox.setCurrentIndex(i) - - disk_name, disk_path = self._build_disk_path() - for i, disk in enumerate(self.disks): - if disk_name in disk: - break - # change disk selection - self.disk_combobox.setCurrentIndex(i) - - # change project_dir selection - if "{root}".format(**p): - self.project_dir.setPlaceholderText("{root}".format(**p)) - else: - print("not root so it was replaced with name") - self.project_dir.setPlaceholderText("{name}".format(**p)) - p['root'] = p['name'] - - # set project path to show where it will be created - self.project_path.setText( - os.path.join(self.disks[i].split(' ')[-1], - self.project_dir.text())) - - # change fps selection - self.fps.setPlaceholderText("{custom_attributes[fps]}".format(**p)) - - # change handles selection - self.handles.setPlaceholderText( - "{custom_attributes[handles]}".format(**p)) - - # change resolution selection - self.resolution_w.setPlaceholderText( - "{custom_attributes[resolution_width]}".format(**p)) - self.resolution_h.setPlaceholderText( - "{custom_attributes[resolution_height]}".format(**p)) - - self.update_disk() - - return p - - def fix_project_path_literals(self, dir): - return dir.replace(' ', '_').lower() - - def update_disk(self): - disk = self.disk_combobox.currentText().split(' ')[-1] - - dir = self.project_dir.text() - if not dir: - dir = "{root}".format(**self.projects[self.new_index]) - self.projects[self.new_index]['project_path'] = os.path.normpath( - self.fix_project_path_literals(os.path.join(disk, dir))) - else: - self.projects[self.new_index]['project_path'] = os.path.normpath( - self.fix_project_path_literals(os.path.join(disk, dir))) - - self.projects[self.new_index]['disk'] = self.disks_from_ft[ - self.disk_combobox.currentIndex()] - self.projects[self.new_index]['disk_id'] = self.projects[ - self.new_index]['disk']['id'] - - # set project path to show where it will be created - self.project_path.setText( - self.projects[self.new_index]['project_path']) - - def update_resolution(self): - # update all values in resolution - if self.resolution_w.text(): - self.projects[self.new_index]['custom_attributes'][ - "resolutionWidth"] = int(self.resolution_w.text()) - if self.resolution_h.text(): - self.projects[self.new_index]['custom_attributes'][ - "resolutionHeight"] = int(self.resolution_h.text()) - - def _update_attributes_by_list_selection(self): - # generate actual selection index - self.new_index = self.listWidget.currentRow() - self.project_dir.setText('') - self.fps.setText('') - self.handles.setText('') - self.resolution_w.setText('') - self.resolution_h.setText('') - - # update project properities widgets and write changes - # into project dictionaries - self.projects[self.new_index] = self._fill_project_attributes_widgets( - self.projects[self.new_index], self.new_index) - - self.update_disk() - - def _build_disk_path(self): - if self.platform == "Windows": - print(self.projects[self.index].keys()) - print(self.projects[self.new_index]['disk']) - return self.projects[self.new_index]['disk'][ - 'name'], self.projects[self.new_index]['disk']['windows'] - else: - return self.projects[self.new_index]['disk'][ - 'name'], self.projects[self.new_index]['disk']['unix'] - - def _get_all_schemas(self): - schemas_list = [] - - for s in self.schemas_from_ft: - # print d.keys() - # if 'Pokus' in s['name']: - # continue - schemas_list.append('{}'.format(s['name'])) - print("\nschemas in ftrack: {}\n".format(schemas_list)) - return schemas_list - - def _get_all_disks(self): - disks_list = [] - for d in self.disks_from_ft: - # print d.keys() - if self.platform == "Windows": - if 'Local drive' in d['name']: - d['windows'] = os.path.join(d['windows'], - os.getenv('USERNAME') - or os.getenv('USER') - or os.getenv('LOGNAME')) - disks_list.append('"{}" at {}'.format(d['name'], d['windows'])) - else: - if 'Local drive' in d['name']: - d['unix'] = os.path.join(d['unix'], getpass.getuser()) - disks_list.append('"{}" at {}'.format(d['name'], d['unix'])) - return disks_list - - def _get_projects_ftrack(self): - - projects_lst = [] - for project in self.projects_from_ft: - # print project.keys() - projects_dict = {} - - for k in project.keys(): - ''' # TODO: delete this in production version ''' - - # if 'test' not in project['name']: - # continue - - # print '{}: {}\n'.format(k, project[k]) - - if '_link' == k: - # print project[k] - content = project[k] - for kc in content[0].keys(): - if content[0]['name']: - content[0][kc] = content[0][kc].encode( - 'ascii', 'ignore').decode('ascii') - print('{}: {}\n'.format(kc, content[0][kc])) - projects_dict[k] = content - print(project[k]) - print(projects_dict[k]) - elif 'root' == k: - print('{}: {}\n'.format(k, project[k])) - projects_dict[k] = project[k] - elif 'disk' == k: - print('{}: {}\n'.format(k, project[k])) - projects_dict[k] = project[k] - elif 'name' == k: - print('{}: {}\n'.format(k, project[k])) - projects_dict[k] = project[k].encode( - 'ascii', 'ignore').decode('ascii') - elif 'disk_id' == k: - print('{}: {}\n'.format(k, project[k])) - projects_dict[k] = project[k] - elif 'id' == k: - print('{}: {}\n'.format(k, project[k])) - projects_dict[k] = project[k] - elif 'full_name' == k: - print('{}: {}\n'.format(k, project[k])) - projects_dict[k] = project[k].encode( - 'ascii', 'ignore').decode('ascii') - elif 'project_schema_id' == k: - print('{}: {}\n'.format(k, project[k])) - projects_dict[k] = project[k] - elif 'project_schema' == k: - print('{}: {}\n'.format(k, project[k])) - projects_dict[k] = project[k] - elif 'custom_attributes' == k: - print('{}: {}\n'.format(k, project[k])) - projects_dict[k] = project[k] - else: - pass - - if projects_dict: - projects_lst.append(projects_dict) - - return projects_lst - - -class Project_name_get(Project_name_getUI): - def __init__(self, parent=None): - super(Project_name_get, self).__init__(parent) - # self.input_project_name.textChanged.connect(self.input_project_name.placeholderText) - - self.set_pushbutton.clicked.connect(lambda: self.execute()) - self.cancel_pushbutton.clicked.connect(self.close) - - self.listWidget.itemSelectionChanged.connect( - self._update_attributes_by_list_selection) - self.disk_combobox.currentIndexChanged.connect(self.update_disk) - self.schema_combobox.currentIndexChanged.connect(self.update_schema) - self.project_dir.textChanged.connect(self.update_disk) - self.fps.textChanged.connect(self.update_fps) - self.handles.textChanged.connect(self.update_handles) - self.resolution_w.textChanged.connect(self.update_resolution) - self.resolution_h.textChanged.connect(self.update_resolution) - - def update_handles(self): - self.projects[self.new_index]['custom_attributes']['handles'] = int( - self.handles.text()) - - def update_fps(self): - self.projects[self.new_index]['custom_attributes']['fps'] = int( - self.fps.text()) - - def update_schema(self): - self.projects[self.new_index]['project_schema'] = self.schemas_from_ft[ - self.schema_combobox.currentIndex()] - self.projects[self.new_index]['project_schema_id'] = self.projects[ - self.new_index]['project_schema']['id'] - - def execute(self): - # import ft_utils - # import hiero - # get the project which has been selected - print("well and what") - # set the project as context and create entity - # entity is task created with the name of user which is creating it - - # get the project_path and create dir if there is not any - print(self.projects[self.new_index]['project_path'].replace( - self.disk_combobox.currentText().split(' ')[-1].lower(), '')) - - # get the schema and recreate a starting project regarding the selection - # set_hiero_template(project_schema=self.projects[self.new_index][ - # 'project_schema']['name']) - - # set all project properities - # project = hiero.core.Project() - # project.setFramerate( - # int(self.projects[self.new_index]['custom_attributes']['fps'])) - # project.projectRoot() - # print 'handles: {}'.format(self.projects[self.new_index]['custom_attributes']['handles']) - # print 'resolution_width: {}'.format(self.projects[self.new_index]['custom_attributes']["resolutionWidth"]) - # print 'resolution_width: {}'.format(self.projects[self.new_index]['custom_attributes']["resolutionHeight"]) - # print "<< {}".format(self.projects[self.new_index]) - - # get path for the hrox file - # root = context.data('ftrackData')['Project']['root'] - # hrox_script_path = ft_utils.getPathsYaml(taskid, templateList=templates, root=root) - - # save the hrox into the correct path - self.session.commit() - self.close() - -# -# def set_hiero_template(project_schema=None): -# import hiero -# hiero.core.closeAllProjects() -# hiero_plugin_path = [ -# p for p in os.environ['HIERO_PLUGIN_PATH'].split(';') -# if 'hiero_plugin_path' in p -# ][0] -# path = os.path.normpath( -# os.path.join(hiero_plugin_path, 'Templates', project_schema + '.hrox')) -# print('---> path to template: {}'.format(path)) -# return hiero.core.openProject(path) - - -# def set_out_ft_session(): -# session = ftrack_api.Session() -# projects_to_ft = session.query('Project where status is active') - - -def main(): - import sys - app = QtWidgets.QApplication(sys.argv) - panel = Project_name_get() - panel.show() - - sys.exit(app.exec_()) - - -if __name__ == "__main__": - main() diff --git a/poetry.lock b/poetry.lock index 7998ede693..a79c865a9e 100644 --- a/poetry.lock +++ b/poetry.lock @@ -820,7 +820,7 @@ six = "*" [[package]] name = "pillow" -version = "9.0.0" +version = "9.0.1" description = "Python Imaging Library (Fork)" category = "main" optional = false @@ -2310,38 +2310,41 @@ pathlib2 = [ {file = "pathlib2-2.3.6.tar.gz", hash = "sha256:7d8bcb5555003cdf4a8d2872c538faa3a0f5d20630cb360e518ca3b981795e5f"}, ] pillow = [ - {file = "Pillow-9.0.0-cp310-cp310-macosx_10_10_universal2.whl", hash = "sha256:113723312215b25c22df1fdf0e2da7a3b9c357a7d24a93ebbe80bfda4f37a8d4"}, - {file = "Pillow-9.0.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:bb47a548cea95b86494a26c89d153fd31122ed65255db5dcbc421a2d28eb3379"}, - {file = "Pillow-9.0.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:31b265496e603985fad54d52d11970383e317d11e18e856971bdbb86af7242a4"}, - {file = "Pillow-9.0.0-cp310-cp310-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d154ed971a4cc04b93a6d5b47f37948d1f621f25de3e8fa0c26b2d44f24e3e8f"}, - {file = "Pillow-9.0.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:80fe92813d208ce8aa7d76da878bdc84b90809f79ccbad2a288e9bcbeac1d9bd"}, - {file = "Pillow-9.0.0-cp310-cp310-win32.whl", hash = "sha256:d5dcea1387331c905405b09cdbfb34611050cc52c865d71f2362f354faee1e9f"}, - {file = "Pillow-9.0.0-cp310-cp310-win_amd64.whl", hash = "sha256:52abae4c96b5da630a8b4247de5428f593465291e5b239f3f843a911a3cf0105"}, - {file = "Pillow-9.0.0-cp37-cp37m-macosx_10_10_x86_64.whl", hash = "sha256:72c3110228944019e5f27232296c5923398496b28be42535e3b2dc7297b6e8b6"}, - {file = "Pillow-9.0.0-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:97b6d21771da41497b81652d44191489296555b761684f82b7b544c49989110f"}, - {file = "Pillow-9.0.0-cp37-cp37m-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:72f649d93d4cc4d8cf79c91ebc25137c358718ad75f99e99e043325ea7d56100"}, - {file = "Pillow-9.0.0-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7aaf07085c756f6cb1c692ee0d5a86c531703b6e8c9cae581b31b562c16b98ce"}, - {file = "Pillow-9.0.0-cp37-cp37m-win32.whl", hash = "sha256:03b27b197deb4ee400ed57d8d4e572d2d8d80f825b6634daf6e2c18c3c6ccfa6"}, - {file = "Pillow-9.0.0-cp37-cp37m-win_amd64.whl", hash = "sha256:a09a9d4ec2b7887f7a088bbaacfd5c07160e746e3d47ec5e8050ae3b2a229e9f"}, - {file = "Pillow-9.0.0-cp38-cp38-macosx_10_10_x86_64.whl", hash = "sha256:490e52e99224858f154975db61c060686df8a6b3f0212a678e5d2e2ce24675c9"}, - {file = "Pillow-9.0.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:500d397ddf4bbf2ca42e198399ac13e7841956c72645513e8ddf243b31ad2128"}, - {file = "Pillow-9.0.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0ebd8b9137630a7bbbff8c4b31e774ff05bbb90f7911d93ea2c9371e41039b52"}, - {file = "Pillow-9.0.0-cp38-cp38-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:fd0e5062f11cb3e730450a7d9f323f4051b532781026395c4323b8ad055523c4"}, - {file = "Pillow-9.0.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9f3b4522148586d35e78313db4db0df4b759ddd7649ef70002b6c3767d0fdeb7"}, - {file = "Pillow-9.0.0-cp38-cp38-win32.whl", hash = "sha256:0b281fcadbb688607ea6ece7649c5d59d4bbd574e90db6cd030e9e85bde9fecc"}, - {file = "Pillow-9.0.0-cp38-cp38-win_amd64.whl", hash = "sha256:b5050d681bcf5c9f2570b93bee5d3ec8ae4cf23158812f91ed57f7126df91762"}, - {file = "Pillow-9.0.0-cp39-cp39-macosx_10_10_x86_64.whl", hash = "sha256:c2067b3bb0781f14059b112c9da5a91c80a600a97915b4f48b37f197895dd925"}, - {file = "Pillow-9.0.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:2d16b6196fb7a54aff6b5e3ecd00f7c0bab1b56eee39214b2b223a9d938c50af"}, - {file = "Pillow-9.0.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:98cb63ca63cb61f594511c06218ab4394bf80388b3d66cd61d0b1f63ee0ea69f"}, - {file = "Pillow-9.0.0-cp39-cp39-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:bc462d24500ba707e9cbdef436c16e5c8cbf29908278af053008d9f689f56dee"}, - {file = "Pillow-9.0.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3586e12d874ce2f1bc875a3ffba98732ebb12e18fb6d97be482bd62b56803281"}, - {file = "Pillow-9.0.0-cp39-cp39-win32.whl", hash = "sha256:68e06f8b2248f6dc8b899c3e7ecf02c9f413aab622f4d6190df53a78b93d97a5"}, - {file = "Pillow-9.0.0-cp39-cp39-win_amd64.whl", hash = "sha256:6579f9ba84a3d4f1807c4aab4be06f373017fc65fff43498885ac50a9b47a553"}, - {file = "Pillow-9.0.0-pp37-pypy37_pp73-macosx_10_10_x86_64.whl", hash = "sha256:47f5cf60bcb9fbc46011f75c9b45a8b5ad077ca352a78185bd3e7f1d294b98bb"}, - {file = "Pillow-9.0.0-pp37-pypy37_pp73-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2fd8053e1f8ff1844419842fd474fc359676b2e2a2b66b11cc59f4fa0a301315"}, - {file = "Pillow-9.0.0-pp37-pypy37_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6c5439bfb35a89cac50e81c751317faea647b9a3ec11c039900cd6915831064d"}, - {file = "Pillow-9.0.0-pp38-pypy38_pp73-win_amd64.whl", hash = "sha256:95545137fc56ce8c10de646074d242001a112a92de169986abd8c88c27566a05"}, - {file = "Pillow-9.0.0.tar.gz", hash = "sha256:ee6e2963e92762923956fe5d3479b1fdc3b76c83f290aad131a2f98c3df0593e"}, + {file = "Pillow-9.0.1-1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:a5d24e1d674dd9d72c66ad3ea9131322819ff86250b30dc5821cbafcfa0b96b4"}, + {file = "Pillow-9.0.1-1-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:2632d0f846b7c7600edf53c48f8f9f1e13e62f66a6dbc15191029d950bfed976"}, + {file = "Pillow-9.0.1-1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:b9618823bd237c0d2575283f2939655f54d51b4527ec3972907a927acbcc5bfc"}, + {file = "Pillow-9.0.1-cp310-cp310-macosx_10_10_universal2.whl", hash = "sha256:9bfdb82cdfeccec50aad441afc332faf8606dfa5e8efd18a6692b5d6e79f00fd"}, + {file = "Pillow-9.0.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:5100b45a4638e3c00e4d2320d3193bdabb2d75e79793af7c3eb139e4f569f16f"}, + {file = "Pillow-9.0.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:528a2a692c65dd5cafc130de286030af251d2ee0483a5bf50c9348aefe834e8a"}, + {file = "Pillow-9.0.1-cp310-cp310-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0f29d831e2151e0b7b39981756d201f7108d3d215896212ffe2e992d06bfe049"}, + {file = "Pillow-9.0.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:855c583f268edde09474b081e3ddcd5cf3b20c12f26e0d434e1386cc5d318e7a"}, + {file = "Pillow-9.0.1-cp310-cp310-win32.whl", hash = "sha256:d9d7942b624b04b895cb95af03a23407f17646815495ce4547f0e60e0b06f58e"}, + {file = "Pillow-9.0.1-cp310-cp310-win_amd64.whl", hash = "sha256:81c4b81611e3a3cb30e59b0cf05b888c675f97e3adb2c8672c3154047980726b"}, + {file = "Pillow-9.0.1-cp37-cp37m-macosx_10_10_x86_64.whl", hash = "sha256:413ce0bbf9fc6278b2d63309dfeefe452835e1c78398efb431bab0672fe9274e"}, + {file = "Pillow-9.0.1-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:80fe64a6deb6fcfdf7b8386f2cf216d329be6f2781f7d90304351811fb591360"}, + {file = "Pillow-9.0.1-cp37-cp37m-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:cef9c85ccbe9bee00909758936ea841ef12035296c748aaceee535969e27d31b"}, + {file = "Pillow-9.0.1-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1d19397351f73a88904ad1aee421e800fe4bbcd1aeee6435fb62d0a05ccd1030"}, + {file = "Pillow-9.0.1-cp37-cp37m-win32.whl", hash = "sha256:d21237d0cd37acded35154e29aec853e945950321dd2ffd1a7d86fe686814669"}, + {file = "Pillow-9.0.1-cp37-cp37m-win_amd64.whl", hash = "sha256:ede5af4a2702444a832a800b8eb7f0a7a1c0eed55b644642e049c98d589e5092"}, + {file = "Pillow-9.0.1-cp38-cp38-macosx_10_10_x86_64.whl", hash = "sha256:b5b3f092fe345c03bca1e0b687dfbb39364b21ebb8ba90e3fa707374b7915204"}, + {file = "Pillow-9.0.1-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:335ace1a22325395c4ea88e00ba3dc89ca029bd66bd5a3c382d53e44f0ccd77e"}, + {file = "Pillow-9.0.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:db6d9fac65bd08cea7f3540b899977c6dee9edad959fa4eaf305940d9cbd861c"}, + {file = "Pillow-9.0.1-cp38-cp38-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f154d173286a5d1863637a7dcd8c3437bb557520b01bddb0be0258dcb72696b5"}, + {file = "Pillow-9.0.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:14d4b1341ac07ae07eb2cc682f459bec932a380c3b122f5540432d8977e64eae"}, + {file = "Pillow-9.0.1-cp38-cp38-win32.whl", hash = "sha256:effb7749713d5317478bb3acb3f81d9d7c7f86726d41c1facca068a04cf5bb4c"}, + {file = "Pillow-9.0.1-cp38-cp38-win_amd64.whl", hash = "sha256:7f7609a718b177bf171ac93cea9fd2ddc0e03e84d8fa4e887bdfc39671d46b00"}, + {file = "Pillow-9.0.1-cp39-cp39-macosx_10_10_x86_64.whl", hash = "sha256:80ca33961ced9c63358056bd08403ff866512038883e74f3a4bf88ad3eb66838"}, + {file = "Pillow-9.0.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:1c3c33ac69cf059bbb9d1a71eeaba76781b450bc307e2291f8a4764d779a6b28"}, + {file = "Pillow-9.0.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:12875d118f21cf35604176872447cdb57b07126750a33748bac15e77f90f1f9c"}, + {file = "Pillow-9.0.1-cp39-cp39-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:514ceac913076feefbeaf89771fd6febde78b0c4c1b23aaeab082c41c694e81b"}, + {file = "Pillow-9.0.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d3c5c79ab7dfce6d88f1ba639b77e77a17ea33a01b07b99840d6ed08031cb2a7"}, + {file = "Pillow-9.0.1-cp39-cp39-win32.whl", hash = "sha256:718856856ba31f14f13ba885ff13874be7fefc53984d2832458f12c38205f7f7"}, + {file = "Pillow-9.0.1-cp39-cp39-win_amd64.whl", hash = "sha256:f25ed6e28ddf50de7e7ea99d7a976d6a9c415f03adcaac9c41ff6ff41b6d86ac"}, + {file = "Pillow-9.0.1-pp37-pypy37_pp73-macosx_10_10_x86_64.whl", hash = "sha256:011233e0c42a4a7836498e98c1acf5e744c96a67dd5032a6f666cc1fb97eab97"}, + {file = "Pillow-9.0.1-pp37-pypy37_pp73-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:253e8a302a96df6927310a9d44e6103055e8fb96a6822f8b7f514bb7ef77de56"}, + {file = "Pillow-9.0.1-pp37-pypy37_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6295f6763749b89c994fcb6d8a7f7ce03c3992e695f89f00b741b4580b199b7e"}, + {file = "Pillow-9.0.1-pp38-pypy38_pp73-win_amd64.whl", hash = "sha256:a9f44cd7e162ac6191491d7249cceb02b8116b0f7e847ee33f739d7cb1ea1f70"}, + {file = "Pillow-9.0.1.tar.gz", hash = "sha256:6c8bc8238a7dfdaf7a75f5ec5a663f4173f8c367e5a39f87e720495e1eed75fa"}, ] platformdirs = [ {file = "platformdirs-2.4.1-py3-none-any.whl", hash = "sha256:1d7385c7db91728b83efd0ca99a5afb296cab9d0ed8313a45ed8ba17967ecfca"}, diff --git a/pyproject.toml b/pyproject.toml index f32e385e80..47d678b5e8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "OpenPype" -version = "3.10.0-nightly.2" # OpenPype +version = "3.10.1-nightly.1" # OpenPype description = "Open VFX and Animation pipeline with support." authors = ["OpenPype Team "] license = "MIT License" diff --git a/schema/session-3.0.json b/schema/session-3.0.json new file mode 100644 index 0000000000..9f785939e4 --- /dev/null +++ b/schema/session-3.0.json @@ -0,0 +1,81 @@ +{ + "$schema": "http://json-schema.org/draft-04/schema#", + + "title": "openpype:session-3.0", + "description": "The Avalon environment", + + "type": "object", + + "additionalProperties": true, + + "required": [ + "AVALON_PROJECT", + "AVALON_ASSET" + ], + + "properties": { + "AVALON_PROJECTS": { + "description": "Absolute path to root of project directories", + "type": "string", + "example": "/nas/projects" + }, + "AVALON_PROJECT": { + "description": "Name of project", + "type": "string", + "pattern": "^\\w*$", + "example": "Hulk" + }, + "AVALON_ASSET": { + "description": "Name of asset", + "type": "string", + "pattern": "^\\w*$", + "example": "Bruce" + }, + "AVALON_TASK": { + "description": "Name of task", + "type": "string", + "pattern": "^\\w*$", + "example": "modeling" + }, + "AVALON_APP": { + "description": "Name of host", + "type": "string", + "pattern": "^\\w*$", + "example": "maya2016" + }, + "AVALON_DB": { + "description": "Name of database", + "type": "string", + "pattern": "^\\w*$", + "example": "avalon", + "default": "avalon" + }, + "AVALON_LABEL": { + "description": "Nice name of Avalon, used in e.g. graphical user interfaces", + "type": "string", + "example": "Mindbender", + "default": "Avalon" + }, + "AVALON_TIMEOUT": { + "description": "Wherever there is a need for a timeout, this is the default value.", + "type": "string", + "pattern": "^[0-9]*$", + "default": "1000", + "example": "1000" + }, + "AVALON_INSTANCE_ID": { + "description": "Unique identifier for instances in a working file", + "type": "string", + "pattern": "^[\\w.]*$", + "default": "avalon.instance", + "example": "avalon.instance" + }, + "AVALON_CONTAINER_ID": { + "description": "Unique identifier for a loaded representation in a working file", + "type": "string", + "pattern": "^[\\w.]*$", + "default": "avalon.container", + "example": "avalon.container" + } + } +} diff --git a/setup.py b/setup.py index 899e9375c0..8b5a545c16 100644 --- a/setup.py +++ b/setup.py @@ -106,7 +106,9 @@ install_requires = [ "dns", # Python defaults (cx_Freeze skip them by default) "dbm", - "sqlite3" + "sqlite3", + "dataclasses", + "timeit" ] includes = [] diff --git a/start.py b/start.py index 38eb9e9bf4..ace33ab92a 100644 --- a/start.py +++ b/start.py @@ -266,18 +266,9 @@ def set_openpype_global_environments() -> None: """Set global OpenPype's environments.""" import acre - try: - from openpype.settings import get_general_environments + from openpype.settings import get_general_environments - general_env = get_general_environments() - - except Exception: - # Backwards compatibility for OpenPype versions where - # `get_general_environments` does not exists yet - from openpype.settings import get_environments - - all_env = get_environments() - general_env = all_env["global"] + general_env = get_general_environments() merged_env = acre.merge( acre.parse(general_env), @@ -395,18 +386,6 @@ def set_modules_environments(): modules_manager = ModulesManager() module_envs = modules_manager.collect_global_environments() - publish_plugin_dirs = modules_manager.collect_plugin_paths()["publish"] - - # Set pyblish plugins paths if any module want to register them - if publish_plugin_dirs: - publish_paths_str = os.environ.get("PYBLISHPLUGINPATH") or "" - publish_paths = publish_paths_str.split(os.pathsep) - _publish_paths = { - os.path.normpath(path) for path in publish_paths if path - } - for path in publish_plugin_dirs: - _publish_paths.add(os.path.normpath(path)) - module_envs["PYBLISHPLUGINPATH"] = os.pathsep.join(_publish_paths) # Merge environments with current environments and update values if module_envs: @@ -906,6 +885,56 @@ def _bootstrap_from_code(use_version, use_staging): return version_path +def _boot_validate_versions(use_version, local_version): + _print(f">>> Validating version [ {use_version} ]") + openpype_versions = bootstrap.find_openpype(include_zips=True, + staging=True) + openpype_versions += bootstrap.find_openpype(include_zips=True, + staging=False) + v: OpenPypeVersion + found = [v for v in openpype_versions if str(v) == use_version] + if not found: + _print(f"!!! Version [ {use_version} ] not found.") + list_versions(openpype_versions, local_version) + sys.exit(1) + + # print result + version_path = bootstrap.get_version_path_from_list( + use_version, openpype_versions + ) + valid, message = bootstrap.validate_openpype_version(version_path) + _print("{}{}".format(">>> " if valid else "!!! ", message)) + + +def _boot_print_versions(use_staging, local_version, openpype_root): + if not use_staging: + _print("--- This will list only non-staging versions detected.") + _print(" To see staging versions, use --use-staging argument.") + else: + _print("--- This will list only staging versions detected.") + _print(" To see other version, omit --use-staging argument.") + + openpype_versions = bootstrap.find_openpype(include_zips=True, + staging=use_staging) + if getattr(sys, 'frozen', False): + local_version = bootstrap.get_version(Path(openpype_root)) + else: + local_version = OpenPypeVersion.get_installed_version_str() + + list_versions(openpype_versions, local_version) + + +def _boot_handle_missing_version(local_version, use_staging, message): + _print(message) + if os.environ.get("OPENPYPE_HEADLESS_MODE") == "1": + openpype_versions = bootstrap.find_openpype( + include_zips=True, staging=use_staging + ) + list_versions(openpype_versions, local_version) + else: + igniter.show_message_dialog("Version not found", message) + + def boot(): """Bootstrap OpenPype.""" @@ -975,30 +1004,7 @@ def boot(): local_version = OpenPypeVersion.get_installed_version_str() if "validate" in commands: - _print(f">>> Validating version [ {use_version} ]") - openpype_versions = bootstrap.find_openpype(include_zips=True, - staging=True) - openpype_versions += bootstrap.find_openpype(include_zips=True, - staging=False) - v: OpenPypeVersion - found = [v for v in openpype_versions if str(v) == use_version] - if not found: - _print(f"!!! Version [ {use_version} ] not found.") - list_versions(openpype_versions, local_version) - sys.exit(1) - - # print result - result = bootstrap.validate_openpype_version( - bootstrap.get_version_path_from_list( - use_version, openpype_versions)) - - _print("{}{}".format( - ">>> " if result[0] else "!!! ", - bootstrap.validate_openpype_version( - bootstrap.get_version_path_from_list( - use_version, openpype_versions) - )[1]) - ) + _boot_validate_versions(use_version, local_version) sys.exit(1) if not openpype_path: @@ -1008,21 +1014,7 @@ def boot(): os.environ["OPENPYPE_PATH"] = openpype_path if "print_versions" in commands: - if not use_staging: - _print("--- This will list only non-staging versions detected.") - _print(" To see staging versions, use --use-staging argument.") - else: - _print("--- This will list only staging versions detected.") - _print(" To see other version, omit --use-staging argument.") - _openpype_root = OPENPYPE_ROOT - openpype_versions = bootstrap.find_openpype(include_zips=True, - staging=use_staging) - if getattr(sys, 'frozen', False): - local_version = bootstrap.get_version(Path(_openpype_root)) - else: - local_version = OpenPypeVersion.get_installed_version_str() - - list_versions(openpype_versions, local_version) + _boot_print_versions(use_staging, local_version, OPENPYPE_ROOT) sys.exit(1) # ------------------------------------------------------------------------ @@ -1035,12 +1027,7 @@ def boot(): try: version_path = _find_frozen_openpype(use_version, use_staging) except OpenPypeVersionNotFound as exc: - message = str(exc) - _print(message) - if os.environ.get("OPENPYPE_HEADLESS_MODE") == "1": - list_versions(openpype_versions, local_version) - else: - igniter.show_message_dialog("Version not found", message) + _boot_handle_missing_version(local_version, use_staging, str(exc)) sys.exit(1) except RuntimeError as e: @@ -1059,12 +1046,7 @@ def boot(): version_path = _bootstrap_from_code(use_version, use_staging) except OpenPypeVersionNotFound as exc: - message = str(exc) - _print(message) - if os.environ.get("OPENPYPE_HEADLESS_MODE") == "1": - list_versions(openpype_versions, local_version) - else: - igniter.show_message_dialog("Version not found", message) + _boot_handle_missing_version(local_version, use_staging, str(exc)) sys.exit(1) # set this to point either to `python` from venv in case of live code diff --git a/tests/lib/testing_classes.py b/tests/lib/testing_classes.py index 7dfbf6fd0d..f991f02227 100644 --- a/tests/lib/testing_classes.py +++ b/tests/lib/testing_classes.py @@ -153,7 +153,7 @@ class ModuleUnitTest(BaseTest): Database prepared from dumps with 'db_setup' fixture. """ - from avalon.api import AvalonMongoDB + from openpype.pipeline import AvalonMongoDB dbcon = AvalonMongoDB() dbcon.Session["AVALON_PROJECT"] = self.TEST_PROJECT_NAME yield dbcon diff --git a/website/docs/admin_hosts_nuke.md b/website/docs/admin_hosts_nuke.md new file mode 100644 index 0000000000..46f596a2dc --- /dev/null +++ b/website/docs/admin_hosts_nuke.md @@ -0,0 +1,14 @@ +--- +id: admin_hosts_nuke +title: Nuke +sidebar_label: Nuke +--- + +## Custom Menu +You can add your custom tools menu into Nuke by extending definitions in **Nuke -> Scripts Menu Definition**. +![Custom menu definition](assets/nuke-admin_scriptsmenu.png) + +:::note Work in progress +This is still work in progress. Menu definition will be handled more friendly with widgets and not +raw json. +::: diff --git a/website/docs/assets/nuke-admin_scriptsmenu.png b/website/docs/assets/nuke-admin_scriptsmenu.png new file mode 100644 index 0000000000..cad2a4411d Binary files /dev/null and b/website/docs/assets/nuke-admin_scriptsmenu.png differ diff --git a/website/sidebars.js b/website/sidebars.js index 105afc30eb..0e33bed949 100644 --- a/website/sidebars.js +++ b/website/sidebars.js @@ -88,6 +88,7 @@ module.exports = { items: [ "admin_hosts_blender", "admin_hosts_maya", + "admin_hosts_nuke", "admin_hosts_resolve", "admin_hosts_harmony", "admin_hosts_aftereffects",