diff --git a/.github_changelog_generator b/.github_changelog_generator new file mode 100644 index 0000000000..cd09ebcbfa --- /dev/null +++ b/.github_changelog_generator @@ -0,0 +1,7 @@ +pr-wo-labels=False +exclude-labels=duplicate,question,invalid,wontfix,weekly-digest +author=False +unreleased=False +since-tag=2.11.0 +release-branch=master +enhancement-label=**Enhancements:** diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000000..ba86b85eec --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,565 @@ +# Changelog + +## [2.12.0](https://github.com/pypeclub/pype/tree/2.12.0) (2020-09-09) + +[Full Changelog](https://github.com/pypeclub/pype/compare/2.11.8...2.12.0) + +**Enhancements:** + +- Less mongo connections [\#509](https://github.com/pypeclub/pype/pull/509) +- Nuke: adding image loader [\#499](https://github.com/pypeclub/pype/pull/499) +- Move launcher window to top if launcher action is clicked [\#450](https://github.com/pypeclub/pype/pull/450) +- Maya: better tile rendering support in Pype [\#446](https://github.com/pypeclub/pype/pull/446) +- Implementation of non QML launcher [\#443](https://github.com/pypeclub/pype/pull/443) +- Optional skip review on renders. [\#441](https://github.com/pypeclub/pype/pull/441) +- Ftrack: Option to push status from task to latest version [\#440](https://github.com/pypeclub/pype/pull/440) +- Properly containerize image plane loads. [\#434](https://github.com/pypeclub/pype/pull/434) +- Option to keep the review files. [\#426](https://github.com/pypeclub/pype/pull/426) +- Isolate view on instance members. [\#425](https://github.com/pypeclub/pype/pull/425) +- ftrack group is bcw compatible [\#418](https://github.com/pypeclub/pype/pull/418) +- Maya: Publishing of tile renderings on Deadline [\#398](https://github.com/pypeclub/pype/pull/398) +- Feature/little bit better logging gui [\#383](https://github.com/pypeclub/pype/pull/383) + +**Fixed bugs:** + +- Maya: Fix tile order for Draft Tile Assembler [\#511](https://github.com/pypeclub/pype/pull/511) +- Remove extra dash [\#501](https://github.com/pypeclub/pype/pull/501) +- Fix: strip dot from repre names in single frame renders [\#498](https://github.com/pypeclub/pype/pull/498) +- Better handling of destination during integrating [\#485](https://github.com/pypeclub/pype/pull/485) +- Fix: allow thumbnail creation for single frame renders [\#460](https://github.com/pypeclub/pype/pull/460) +- added missing argument to launch\_application in ftrack app handler [\#453](https://github.com/pypeclub/pype/pull/453) +- Burnins: Copy bit rate of input video to match quality. [\#448](https://github.com/pypeclub/pype/pull/448) +- Standalone publisher is now independent from tray [\#442](https://github.com/pypeclub/pype/pull/442) +- Bugfix/empty enumerator attributes [\#436](https://github.com/pypeclub/pype/pull/436) +- Fixed wrong order of "other" category collapssing in publisher [\#435](https://github.com/pypeclub/pype/pull/435) +- Multiple reviews where being overwritten to one. [\#424](https://github.com/pypeclub/pype/pull/424) +- Cleanup plugin fail on instances without staging dir [\#420](https://github.com/pypeclub/pype/pull/420) +- deprecated -intra parameter in ffmpeg to new `-g` [\#417](https://github.com/pypeclub/pype/pull/417) +- Delivery action can now work with entered path [\#397](https://github.com/pypeclub/pype/pull/397) + +**Merged pull requests:** + +- Review on instance.data [\#473](https://github.com/pypeclub/pype/pull/473) + +## [2.11.8](https://github.com/pypeclub/pype/tree/2.11.8) (2020-08-27) + +[Full Changelog](https://github.com/pypeclub/pype/compare/2.11.7...2.11.8) + +**Enhancements:** + +- DWAA support for Maya [\#382](https://github.com/pypeclub/pype/issues/382) +- Isolate View on Playblast [\#367](https://github.com/pypeclub/pype/issues/367) +- Maya: Tile rendering [\#297](https://github.com/pypeclub/pype/issues/297) +- single pype instance running [\#47](https://github.com/pypeclub/pype/issues/47) +- PYPE-649: projects don't guarantee backwards compatible environment [\#8](https://github.com/pypeclub/pype/issues/8) +- PYPE-663: separate venv for each deployed version [\#7](https://github.com/pypeclub/pype/issues/7) + +**Fixed bugs:** + +- pyblish pype - other group is collapsed before plugins are done [\#431](https://github.com/pypeclub/pype/issues/431) +- Alpha white edges in harmony on PNGs [\#412](https://github.com/pypeclub/pype/issues/412) +- harmony image loader picks wrong representations [\#404](https://github.com/pypeclub/pype/issues/404) +- Clockify crash when response contain symbol not allowed by UTF-8 [\#81](https://github.com/pypeclub/pype/issues/81) + +## [2.11.7](https://github.com/pypeclub/pype/tree/2.11.7) (2020-08-21) + +[Full Changelog](https://github.com/pypeclub/pype/compare/2.11.6...2.11.7) + +**Fixed bugs:** + +- Clean Up Baked Movie [\#369](https://github.com/pypeclub/pype/issues/369) +- celaction last workfile [\#459](https://github.com/pypeclub/pype/pull/459) + +## [2.11.6](https://github.com/pypeclub/pype/tree/2.11.6) (2020-08-18) + +[Full Changelog](https://github.com/pypeclub/pype/compare/2.11.5...2.11.6) + +**Enhancements:** + +- publisher app [\#56](https://github.com/pypeclub/pype/issues/56) + +## [2.11.5](https://github.com/pypeclub/pype/tree/2.11.5) (2020-08-13) + +[Full Changelog](https://github.com/pypeclub/pype/compare/2.11.4...2.11.5) + +**Enhancements:** + +- Switch from master to equivalent [\#220](https://github.com/pypeclub/pype/issues/220) +- Standalone publisher now only groups sequence if the extension is known [\#439](https://github.com/pypeclub/pype/pull/439) + +**Fixed bugs:** + +- Logs have been disable for editorial by default to speed up publishing [\#433](https://github.com/pypeclub/pype/pull/433) +- additional fixes for celaction [\#430](https://github.com/pypeclub/pype/pull/430) +- Harmony: invalid variable scope in validate scene settings [\#428](https://github.com/pypeclub/pype/pull/428) +- new representation name for audio was not accepted [\#427](https://github.com/pypeclub/pype/pull/427) + +## [2.11.4](https://github.com/pypeclub/pype/tree/2.11.4) (2020-08-10) + +[Full Changelog](https://github.com/pypeclub/pype/compare/2.11.3...2.11.4) + +**Enhancements:** + +- WebSocket server [\#135](https://github.com/pypeclub/pype/issues/135) +- standalonepublisher: editorial family features expansion \[master branch\] [\#411](https://github.com/pypeclub/pype/pull/411) + +## [2.11.3](https://github.com/pypeclub/pype/tree/2.11.3) (2020-08-04) + +[Full Changelog](https://github.com/pypeclub/pype/compare/2.11.2...2.11.3) + +**Fixed bugs:** + +- Harmony: publishing performance issues [\#408](https://github.com/pypeclub/pype/pull/408) + +## [2.11.2](https://github.com/pypeclub/pype/tree/2.11.2) (2020-07-31) + +[Full Changelog](https://github.com/pypeclub/pype/compare/2.11.1...2.11.2) + +**Fixed bugs:** + +- Ftrack to Avalon bug [\#406](https://github.com/pypeclub/pype/issues/406) + +## [2.11.1](https://github.com/pypeclub/pype/tree/2.11.1) (2020-07-29) + +[Full Changelog](https://github.com/pypeclub/pype/compare/2.11.0...2.11.1) + +**Merged pull requests:** + +- Celaction: metadata json folder fixes on path [\#393](https://github.com/pypeclub/pype/pull/393) +- CelAction - version up method taken fro pype.lib [\#391](https://github.com/pypeclub/pype/pull/391) + + +## 2.11.0 ## + +_**release date:** 27 July 2020_ + +**new:** +- _(blender)_ namespace support [\#341](https://github.com/pypeclub/pype/pull/341) +- _(blender)_ start end frames [\#330](https://github.com/pypeclub/pype/pull/330) +- _(blender)_ camera asset [\#322](https://github.com/pypeclub/pype/pull/322) +- _(pype)_ toggle instances per family in pyblish GUI [\#320](https://github.com/pypeclub/pype/pull/320) +- _(pype)_ current release version is now shown in the tray menu [#379](https://github.com/pypeclub/pype/pull/379) + + +**improved:** +- _(resolve)_ tagging for publish [\#239](https://github.com/pypeclub/pype/issues/239) +- _(pype)_ Support publishing a subset of shots with standalone editorial [\#336](https://github.com/pypeclub/pype/pull/336) +- _(harmony)_ Basic support for palettes [\#324](https://github.com/pypeclub/pype/pull/324) +- _(photoshop)_ Flag outdated containers on startup and publish. [\#309](https://github.com/pypeclub/pype/pull/309) +- _(harmony)_ Flag Outdated containers [\#302](https://github.com/pypeclub/pype/pull/302) +- _(photoshop)_ Publish review [\#298](https://github.com/pypeclub/pype/pull/298) +- _(pype)_ Optional Last workfile launch [\#365](https://github.com/pypeclub/pype/pull/365) + + +**fixed:** +- _(premiere)_ workflow fixes [\#346](https://github.com/pypeclub/pype/pull/346) +- _(pype)_ pype-setup does not work with space in path [\#327](https://github.com/pypeclub/pype/issues/327) +- _(ftrack)_ Ftrack delete action cause circular error [\#206](https://github.com/pypeclub/pype/issues/206) +- _(nuke)_ Priority was forced to 50 [\#345](https://github.com/pypeclub/pype/pull/345) +- _(nuke)_ Fix ValidateNukeWriteKnobs [\#340](https://github.com/pypeclub/pype/pull/340) +- _(maya)_ If camera attributes are connected, we can ignore them. [\#339](https://github.com/pypeclub/pype/pull/339) +- _(pype)_ stop appending of tools environment to existing env [\#337](https://github.com/pypeclub/pype/pull/337) +- _(ftrack)_ Ftrack timeout needs to look at AVALON\_TIMEOUT [\#325](https://github.com/pypeclub/pype/pull/325) +- _(harmony)_ Only zip files are supported. [\#310](https://github.com/pypeclub/pype/pull/310) +- _(pype)_ hotfix/Fix event server mongo uri [\#305](https://github.com/pypeclub/pype/pull/305) +- _(photoshop)_ Subset was not named or validated correctly. [\#304](https://github.com/pypeclub/pype/pull/304) + + + + +## 2.10.0 ## + +_**release date:** 17 June 2020_ + +**new:** +- _(harmony)_ **Toon Boom Harmony** has been greatly extended to support rigging, scene build, animation and rendering workflows. [#270](https://github.com/pypeclub/pype/issues/270) [#271](https://github.com/pypeclub/pype/issues/271) [#190](https://github.com/pypeclub/pype/issues/190) [#191](https://github.com/pypeclub/pype/issues/191) [#172](https://github.com/pypeclub/pype/issues/172) [#168](https://github.com/pypeclub/pype/issues/168) +- _(pype)_ Added support for rudimentary **edl publishing** into individual shots. [#265](https://github.com/pypeclub/pype/issues/265) +- _(celaction)_ Simple **Celaction** integration has been added with support for workfiles and rendering. [#255](https://github.com/pypeclub/pype/issues/255) +- _(maya)_ Support for multiple job types when submitting to the farm. We can now render Maya or Standalone render jobs for Vray and Arnold (limited support for arnold) [#204](https://github.com/pypeclub/pype/issues/204) +- _(photoshop)_ Added initial support for Photoshop [#232](https://github.com/pypeclub/pype/issues/232) + +**improved:** +- _(blender)_ Updated support for rigs and added support Layout family [#233](https://github.com/pypeclub/pype/issues/233) [#226](https://github.com/pypeclub/pype/issues/226) +- _(premiere)_ It is now possible to choose different storage root for workfiles of different task types. [#255](https://github.com/pypeclub/pype/issues/255) +- _(maya)_ Support for unmerged AOVs in Redshift multipart EXRs [#197](https://github.com/pypeclub/pype/issues/197) +- _(pype)_ Pype repository has been refactored in preparation for 3.0 release [#169](https://github.com/pypeclub/pype/issues/169) +- _(deadline)_ All file dependencies are now passed to deadline from maya to prevent premature start of rendering if caches or textures haven't been coppied over yet. [#195](https://github.com/pypeclub/pype/issues/195) +- _(nuke)_ Script validation can now be made optional. [#194](https://github.com/pypeclub/pype/issues/194) +- _(pype)_ Publishing can now be stopped at any time. [#194](https://github.com/pypeclub/pype/issues/194) + +**fix:** +- _(pype)_ Pyblish-lite has been integrated into pype repository, plus various publishing GUI fixes. [#274](https://github.com/pypeclub/pype/issues/274) [#275](https://github.com/pypeclub/pype/issues/275) [#268](https://github.com/pypeclub/pype/issues/268) [#227](https://github.com/pypeclub/pype/issues/227) [#238](https://github.com/pypeclub/pype/issues/238) +- _(maya)_ Alembic extractor was getting wrong frame range type in certain scenarios [#254](https://github.com/pypeclub/pype/issues/254) +- _(maya)_ Attaching a render to subset in maya was not passing validation in certain scenarios [#256](https://github.com/pypeclub/pype/issues/256) +- _(ftrack)_ Various small fixes to ftrack sync [#263](https://github.com/pypeclub/pype/issues/263) [#259](https://github.com/pypeclub/pype/issues/259) +- _(maya)_ Look extraction is now able to skp invalid connections in shaders [#207](https://github.com/pypeclub/pype/issues/207) + + + + +## 2.9.0 ## + +_**release date:** 25 May 2020_ + +**new:** +- _(pype)_ Support for **Multiroot projects**. You can now store project data on multiple physical or virtual storages and target individual publishes to these locations. For instance render can be stored on a faster storage than the rest of the project. [#145](https://github.com/pypeclub/pype/issues/145), [#38](https://github.com/pypeclub/pype/issues/38) +- _(harmony)_ Basic implementation of **Toon Boom Harmony** has been added. [#142](https://github.com/pypeclub/pype/issues/142) +- _(pype)_ OSX support is in public beta now. There are issues to be expected, but the main implementation should be functional. [#141](https://github.com/pypeclub/pype/issues/141) + + +**improved:** + +- _(pype)_ **Review extractor** has been completely rebuilt. It now supports granular filtering so you can create **multiple outputs** for different tasks, families or hosts. [#103](https://github.com/pypeclub/pype/issues/103), [#166](https://github.com/pypeclub/pype/issues/166), [#165](https://github.com/pypeclub/pype/issues/165) +- _(pype)_ **Burnin** generation had been extended to **support same multi-output filtering** as review extractor [#103](https://github.com/pypeclub/pype/issues/103) +- _(pype)_ Publishing file templates can now be specified in config for each individual family [#114](https://github.com/pypeclub/pype/issues/114) +- _(pype)_ Studio specific plugins can now be appended to pype standard publishing plugins. [#112](https://github.com/pypeclub/pype/issues/112) +- _(nukestudio)_ Reviewable clips no longer need to be previously cut, exported and re-imported to timeline. **Pype can now dynamically cut reviewable quicktimes** from continuous offline footage during publishing. [#23](https://github.com/pypeclub/pype/issues/23) +- _(deadline)_ Deadline can now correctly differentiate between staging and production pype. [#154](https://github.com/pypeclub/pype/issues/154) +- _(deadline)_ `PYPE_PYTHON_EXE` env variable can now be used to direct publishing to explicit python installation. [#120](https://github.com/pypeclub/pype/issues/120) +- _(nuke)_ Nuke now check for new version of loaded data on file open. [#140](https://github.com/pypeclub/pype/issues/140) +- _(nuke)_ frame range and limit checkboxes are now exposed on write node. [#119](https://github.com/pypeclub/pype/issues/119) + + + +**fix:** + +- _(nukestudio)_ Project Location was using backslashes which was breaking nukestudio native exporting in certains configurations [#82](https://github.com/pypeclub/pype/issues/82) +- _(nukestudio)_ Duplicity in hierarchy tags was prone to throwing publishing error [#130](https://github.com/pypeclub/pype/issues/130), [#144](https://github.com/pypeclub/pype/issues/144) +- _(ftrack)_ multiple stability improvements [#157](https://github.com/pypeclub/pype/issues/157), [#159](https://github.com/pypeclub/pype/issues/159), [#128](https://github.com/pypeclub/pype/issues/128), [#118](https://github.com/pypeclub/pype/issues/118), [#127](https://github.com/pypeclub/pype/issues/127) +- _(deadline)_ multipart EXRs were stopping review publishing on the farm. They are still not supported for automatic review generation, but the publish will go through correctly without the quicktime. [#155](https://github.com/pypeclub/pype/issues/155) +- _(deadline)_ If deadline is non-responsive it will no longer freeze host when publishing [#149](https://github.com/pypeclub/pype/issues/149) +- _(deadline)_ Sometimes deadline was trying to launch render before all the source data was coppied over. [#137](https://github.com/pypeclub/pype/issues/137) _(harmony)_ Basic implementation of **Toon Boom Harmony** has been added. [#142](https://github.com/pypeclub/pype/issues/142) +- _(nuke)_ Filepath knob wasn't updated properly. [#131](https://github.com/pypeclub/pype/issues/131) +- _(maya)_ When extracting animation, the "Write Color Set" options on the instance were not respected. [#108](https://github.com/pypeclub/pype/issues/108) +- _(maya)_ Attribute overrides for AOV only worked for the legacy render layers. Now it works for new render setup as well [#132](https://github.com/pypeclub/pype/issues/132) +- _(maya)_ Stability and usability improvements in yeti workflow [#104](https://github.com/pypeclub/pype/issues/104) + + + + +## 2.8.0 ## + +_**release date:** 20 April 2020_ + +**new:** + +- _(pype)_ Option to generate slates from json templates. [PYPE-628] [#26](https://github.com/pypeclub/pype/issues/26) +- _(pype)_ It is now possible to automate loading of published subsets into any scene. Documentation will follow :). [PYPE-611] [#24](https://github.com/pypeclub/pype/issues/24) + +**fix:** + +- _(maya)_ Some Redshift render tokens could break publishing. [PYPE-778] [#33](https://github.com/pypeclub/pype/issues/33) +- _(maya)_ Publish was not preserving maya file extension. [#39](https://github.com/pypeclub/pype/issues/39) +- _(maya)_ Rig output validator was failing on nodes without shapes. [#40](https://github.com/pypeclub/pype/issues/40) +- _(maya)_ Yeti caches can now be properly versioned up in the scene inventory. [#40](https://github.com/pypeclub/pype/issues/40) +- _(nuke)_ Build first workfiles was not accepting jpeg sequences. [#34](https://github.com/pypeclub/pype/issues/34) +- _(deadline)_ Trying to generate ffmpeg review from multipart EXRs no longer crashes publishing. [PYPE-781] +- _(deadline)_ Render publishing is more stable in multiplatform environments. [PYPE-775] + + + + +## 2.7.0 ## + +_**release date:** 30 March 2020_ + +**new:** + +- _(maya)_ Artist can now choose to load multiple references of the same subset at once [PYPE-646, PYPS-81] +- _(nuke)_ Option to use named OCIO colorspaces for review colour baking. [PYPS-82] +- _(pype)_ Pype can now work with `master` versions for publishing and loading. These are non-versioned publishes that are overwritten with the latest version during publish. These are now supported in all the GUIs, but their publishing is deactivated by default. [PYPE-653] +- _(blender)_ Added support for basic blender workflow. We currently support `rig`, `model` and `animation` families. [PYPE-768] +- _(pype)_ Source timecode can now be used in burn-ins. [PYPE-777] +- _(pype)_ Review outputs profiles can now specify delivery resolution different than project setting [PYPE-759] +- _(nuke)_ Bookmark to current context is now added automatically to all nuke browser windows. [PYPE-712] + +**change:** + +- _(maya)_ It is now possible to publish camera without. baking. Keep in mind that unbaked cameras can't be guaranteed to work in other hosts. [PYPE-595] +- _(maya)_ All the renders from maya are now grouped in the loader by their Layer name. [PYPE-482] +- _(nuke/hiero)_ Any publishes from nuke and hiero can now be versioned independently of the workfile. [PYPE-728] + + +**fix:** + +- _(nuke)_ Mixed slashes caused issues in ocio config path. +- _(pype)_ Intent field in pyblish GUI was passing label instead of value to ftrack. [PYPE-733] +- _(nuke)_ Publishing of pre-renders was inconsistent. [PYPE-766] +- _(maya)_ Handles and frame ranges were inconsistent in various places during publishing. +- _(nuke)_ Nuke was crashing if it ran into certain missing knobs. For example DPX output missing `autocrop` [PYPE-774] +- _(deadline)_ Project overrides were not working properly with farm render publishing. +- _(hiero)_ Problems with single frame plates publishing. +- _(maya)_ Redshift RenderPass token were breaking render publishing. [PYPE-778] +- _(nuke)_ Build first workfile was not accepting jpeg sequences. +- _(maya)_ Multipart (Multilayer) EXRs were breaking review publishing due to FFMPEG incompatiblity [PYPE-781] + + + +## 2.6.0 ## + +_**release date:** 9 March 2020_ + +**change:** +- _(maya)_ render publishing has been simplified and made more robust. Render setup layers are now automatically added to publishing subsets and `render globals` family has been replaced with simple `render` [PYPE-570] +- _(avalon)_ change context and workfiles apps, have been merged into one, that allows both actions to be performed at the same time. [PYPE-747] +- _(pype)_ thumbnails are now automatically propagate to asset from the last published subset in the loader +- _(ftrack)_ publishing comment and intent are now being published to ftrack note as well as describtion. [PYPE-727] +- _(pype)_ when overriding existing version new old representations are now overriden, instead of the new ones just being appended. (to allow this behaviour, the version validator need to be disabled. [PYPE-690]) +- _(pype)_ burnin preset has been significantly simplified. It now doesn't require passing function to each field, but only need the actual text template. to use this, all the current burnin PRESETS MUST BE UPDATED for all the projects. +- _(ftrack)_ credentials are now stored on a per server basis, so it's possible to switch between ftrack servers without having to log in and out. [PYPE-723] + + +**new:** +- _(pype)_ production and development deployments now have different colour of the tray icon. Orange for Dev and Green for production [PYPE-718] +- _(maya)_ renders can now be attached to a publishable subset rather than creating their own subset. For example it is possible to create a reviewable `look` or `model` render and have it correctly attached as a representation of the subsets [PYPE-451] +- _(maya)_ after saving current scene into a new context (as a new shot for instance), all the scene publishing subsets data gets re-generated automatically to match the new context [PYPE-532] +- _(pype)_ we now support project specific publish, load and create plugins [PYPE-740] +- _(ftrack)_ new action that allow archiving/deleting old published versions. User can keep how many of the latest version to keep when the action is ran. [PYPE-748, PYPE-715] +- _(ftrack)_ it is now possible to monitor and restart ftrack event server using ftrack action. [PYPE-658] +- _(pype)_ validator that prevent accidental overwrites of previously published versions. [PYPE-680] +- _(avalon)_ avalon core updated to version 5.6.0 +- _(maya)_ added validator to make sure that relative paths are used when publishing arnold standins. +- _(nukestudio)_ it is now possible to extract and publish audio family from clip in nuke studio [PYPE-682] + +**fix**: +- _(maya)_ maya set framerange button was ignoring handles [PYPE-719] +- _(ftrack)_ sync to avalon was sometime crashing when ran on empty project +- _(nukestudio)_ publishing same shots after they've been previously archived/deleted would result in a crash. [PYPE-737] +- _(nuke)_ slate workflow was breaking in certain scenarios. [PYPE-730] +- _(pype)_ rendering publish workflow has been significantly improved to prevent error resulting from implicit render collection. [PYPE-665, PYPE-746] +- _(pype)_ launching application on a non-synced project resulted in obscure [PYPE-528] +- _(pype)_ missing keys in burnins no longer result in an error. [PYPE-706] +- _(ftrack)_ create folder structure action was sometimes failing for project managers due to wrong permissions. +- _(Nukestudio)_ using `source` in the start frame tag could result in wrong frame range calculation +- _(ftrack)_ sync to avalon action and event have been improved by catching more edge cases and provessing them properly. + + + +## 2.5.0 ## + +_**release date:** 11 Feb 2020_ + +**change:** +- _(pype)_ added many logs for easier debugging +- _(pype)_ review presets can now be separated between 2d and 3d renders [PYPE-693] +- _(pype)_ anatomy module has been greatly improved to allow for more dynamic pulblishing and faster debugging [PYPE-685] +- _(pype)_ avalon schemas have been moved from `pype-config` to `pype` repository, for simplification. [PYPE-670] +- _(ftrack)_ updated to latest ftrack API +- _(ftrack)_ publishing comments now appear in ftrack also as a note on version with customisable category [PYPE-645] +- _(ftrack)_ delete asset/subset action had been improved. It is now able to remove multiple entities and descendants of the selected entities [PYPE-361, PYPS-72] +- _(workfiles)_ added date field to workfiles app [PYPE-603] +- _(maya)_ old deprecated loader have been removed in favour of a single unified reference loader (old scenes will upgrade automatically to the new loader upon opening) [PYPE-633, PYPE-697] +- _(avalon)_ core updated to 5.5.15 [PYPE-671] +- _(nuke)_ library loader is now available in nuke [PYPE-698] + + +**new:** +- _(pype)_ added pype render wrapper to allow rendering on mixed platform farms. [PYPE-634] +- _(pype)_ added `pype launch` command. It let's admin run applications with dynamically built environment based on the given context. [PYPE-634] +- _(pype)_ added support for extracting review sequences with burnins [PYPE-657] +- _(publish)_ users can now set intent next to a comment when publishing. This will then be reflected on an attribute in ftrack. [PYPE-632] +- _(burnin)_ timecode can now be added to burnin +- _(burnin)_ datetime keys can now be added to burnin and anatomy [PYPE-651] +- _(burnin)_ anatomy templates can now be used in burnins. [PYPE=626] +- _(nuke)_ new validator for render resolution +- _(nuke)_ support for attach slate to nuke renders [PYPE-630] +- _(nuke)_ png sequences were added to loaders +- _(maya)_ added maya 2020 compatibility [PYPE-677] +- _(maya)_ ability to publish and load .ASS standin sequences [PYPS-54] +- _(pype)_ thumbnails can now be published and are visible in the loader. `AVALON_THUMBNAIL_ROOT` environment variable needs to be set for this to work [PYPE-573, PYPE-132] +- _(blender)_ base implementation of blender was added with publishing and loading of .blend files [PYPE-612] +- _(ftrack)_ new action for preparing deliveries [PYPE-639] + + +**fix**: +- _(burnin)_ more robust way of finding ffmpeg for burnins. +- _(pype)_ improved UNC paths remapping when sending to farm. +- _(pype)_ float frames sometimes made their way to representation context in database, breaking loaders [PYPE-668] +- _(pype)_ `pype install --force` was failing sometimes [PYPE-600] +- _(pype)_ padding in published files got calculated wrongly sometimes. It is now instead being always read from project anatomy. [PYPE-667] +- _(publish)_ comment publishing was failing in certain situations +- _(ftrack)_ multiple edge case scenario fixes in auto sync and sync-to-avalon action +- _(ftrack)_ sync to avalon now works on empty projects +- _(ftrack)_ thumbnail update event was failing when deleting entities [PYPE-561] +- _(nuke)_ loader applies proper colorspaces from Presets +- _(nuke)_ publishing handles didn't always work correctly [PYPE-686] +- _(maya)_ assembly publishing and loading wasn't working correctly + + + + + +## 2.4.0 ## + +_**release date:** 9 Dec 2019_ + +**change:** +- _(ftrack)_ version to status ftrack event can now be configured from Presets + - based on preset `presets/ftracc/ftrack_config.json["status_version_to_task"]` +- _(ftrack)_ sync to avalon event has been completely re-written. It now supports most of the project management situations on ftrack including moving, renaming and deleting entities, updating attributes and working with tasks. +- _(ftrack)_ sync to avalon action has been also re-writen. It is now much faster (up to 100 times depending on a project structure), has much better logging and reporting on encountered problems, and is able to handle much more complex situations. +- _(ftrack)_ sync to avalon trigger by checking `auto-sync` toggle on ftrack [PYPE-504] +- _(pype)_ various new features in the REST api +- _(pype)_ new visual identity used across pype +- _(pype)_ started moving all requirements to pip installation rather than vendorising them in pype repository. Due to a few yet unreleased packages, this means that pype can temporarily be only installed in the offline mode. + +**new:** +- _(nuke)_ support for publishing gizmos and loading them as viewer processes +- _(nuke)_ support for publishing nuke nodes from backdrops and loading them back +- _(pype)_ burnins can now work with start and end frames as keys + - use keys `{frame_start}`, `{frame_end}` and `{current_frame}` in burnin preset to use them. [PYPS-44,PYPS-73, PYPE-602] +- _(pype)_ option to filter logs by user and level in loggin GUI +- _(pype)_ image family added to standalone publisher [PYPE-574] +- _(pype)_ matchmove family added to standalone publisher [PYPE-574] +- _(nuke)_ validator for comparing arbitrary knobs with values from presets +- _(maya)_ option to force maya to copy textures in the new look publish rather than hardlinking them +- _(pype)_ comments from pyblish GUI are now being added to ftrack version +- _(maya)_ validator for checking outdated containers in the scene +- _(maya)_ option to publish and load arnold standin sequence [PYPE-579, PYPS-54] + +**fix**: +- _(pype)_ burnins were not respecting codec of the input video +- _(nuke)_ lot's of various nuke and nuke studio fixes across the board [PYPS-45] +- _(pype)_ workfiles app is not launching with the start of the app by default [PYPE-569] +- _(ftrack)_ ftrack integration during publishing was failing under certain situations [PYPS-66] +- _(pype)_ minor fixes in REST api +- _(ftrack)_ status change event was crashing when the target status was missing [PYPS-68] +- _(ftrack)_ actions will try to reconnect if they fail for some reason +- _(maya)_ problems with fps mapping when using float FPS values +- _(deadline)_ overall improvements to deadline publishing +- _(setup)_ environment variables are now remapped on the fly based on the platform pype is running on. This fixes many issues in mixed platform environments. + + + +## 2.3.6 # + +_**release date:** 27 Nov 2019_ + +**hotfix**: +- _(ftrack)_ was hiding important debug logo +- _(nuke)_ crashes during workfile publishing +- _(ftrack)_ event server crashes because of signal problems +- _(muster)_ problems with muster render submissions +- _(ftrack)_ thumbnail update event syntax errors + + +## 2.3.0 ## +_release date: 6 Oct 2019_ + +**new**: +- _(maya)_ support for yeti rigs and yeti caches +- _(maya)_ validator for comparing arbitrary attributes against ftrack +- _(pype)_ burnins can now show current date and time +- _(muster)_ pools can now be set in render globals in maya +- _(pype)_ Rest API has been implemented in beta stage +- _(nuke)_ LUT loader has been added +- _(pype)_ rudimentary user module has been added as preparation for user management +- _(pype)_ a simple logging GUI has been added to pype tray +- _(nuke)_ nuke can now bake input process into mov +- _(maya)_ imported models now have selection handle displayed by defaulting +- _(avalon)_ it's is now possible to load multiple assets at once using loader +- _(maya)_ added ability to automatically connect yeti rig to a mesh upon loading + +**changed**: +- _(ftrack)_ event server now runs two parallel processes and is able to keep queue of events to process. +- _(nuke)_ task name is now added to all rendered subsets +- _(pype)_ adding more families to standalone publisher +- _(pype)_ standalone publisher now uses pyblish-lite +- _(pype)_ standalone publisher can now create review quicktimes +- _(ftrack)_ queries to ftrack were sped up +- _(ftrack)_ multiple ftrack action have been deprecated +- _(avalon)_ avalon upstream has been updated to 5.5.0 +- _(nukestudio)_ published transforms can now be animated +- + +**fix**: +- _(maya)_ fps popup button didn't work in some cases +- _(maya)_ geometry instances and references in maya were losing shader assignments +- _(muster)_ muster rendering templates were not working correctly +- _(maya)_ arnold tx texture conversion wasn't respecting colorspace set by the artist +- _(pype)_ problems with avalon db sync +- _(maya)_ ftrack was rounding FPS making it inconsistent +- _(pype)_ wrong icon names in Creator +- _(maya)_ scene inventory wasn't showing anything if representation was removed from database after it's been loaded to the scene +- _(nukestudio)_ multiple bugs squashed +- _(loader)_ loader was taking long time to show all the loading action when first launcher in maya + +## 2.2.0 ## +_release date: 8 Sept 2019_ + +**new**: +- _(pype)_ add customisable workflow for creating quicktimes from renders or playblasts +- _(nuke)_ option to choose deadline chunk size on write nodes +- _(nukestudio)_ added option to publish soft effects (subTrackItems) from NukeStudio as subsets including LUT files. these can then be loaded in nuke or NukeStudio +- _(nuke)_ option to build nuke script from previously published latest versions of plate and render subsets. +- _(nuke)_ nuke writes now have deadline tab. +- _(ftrack)_ Prepare Project action can now be used for creating the base folder structure on disk and in ftrack, setting up all the initial project attributes and it automatically prepares `pype_project_config` folder for the given project. +- _(clockify)_ Added support for time tracking in clockify. This currently in addition to ftrack time logs, but does not completely replace them. +- _(pype)_ any attributes in Creator and Loader plugins can now be customised using pype preset system + +**changed**: +- nukestudio now uses workio API for workfiles +- _(maya)_ "FIX FPS" prompt in maya now appears in the middle of the screen +- _(muster)_ can now be configured with custom templates +- _(pype)_ global publishing plugins can now be configured using presets as well as host specific ones + + +**fix**: +- wrong version retrieval from path in certain scenarios +- nuke reset resolution wasn't working in certain scenarios + +## 2.1.0 ## +_release date: 6 Aug 2019_ + +A large cleanup release. Most of the change are under the hood. + +**new**: +- _(pype)_ add customisable workflow for creating quicktimes from renders or playblasts +- _(pype)_ Added configurable option to add burnins to any generated quicktimes +- _(ftrack)_ Action that identifies what machines pype is running on. +- _(system)_ unify subprocess calls +- _(maya)_ add audio to review quicktimes +- _(nuke)_ add crop before write node to prevent overscan problems in ffmpeg +- **Nuke Studio** publishing and workfiles support +- **Muster** render manager support +- _(nuke)_ Framerange, FPS and Resolution are set automatically at startup +- _(maya)_ Ability to load published sequences as image planes +- _(system)_ Ftrack event that sets asset folder permissions based on task assignees in ftrack. +- _(maya)_ Pyblish plugin that allow validation of maya attributes +- _(system)_ added better startup logging to tray debug, including basic connection information +- _(avalon)_ option to group published subsets to groups in the loader +- _(avalon)_ loader family filters are working now + +**changed**: +- change multiple key attributes to unify their behaviour across the pipeline + - `frameRate` to `fps` + - `startFrame` to `frameStart` + - `endFrame` to `frameEnd` + - `fstart` to `frameStart` + - `fend` to `frameEnd` + - `handle_start` to `handleStart` + - `handle_end` to `handleEnd` + - `resolution_width` to `resolutionWidth` + - `resolution_height` to `resolutionHeight` + - `pixel_aspect` to `pixelAspect` + +- _(nuke)_ write nodes are now created inside group with only some attributes editable by the artist +- rendered frames are now deleted from temporary location after their publishing is finished. +- _(ftrack)_ RV action can now be launched from any entity +- after publishing only refresh button is now available in pyblish UI +- added context instance pyblish-lite so that artist knows if context plugin fails +- _(avalon)_ allow opening selected files using enter key +- _(avalon)_ core updated to v5.2.9 with our forked changes on top + +**fix**: +- faster hierarchy retrieval from db +- _(nuke)_ A lot of stability enhancements +- _(nuke studio)_ A lot of stability enhancements +- _(nuke)_ now only renders a single write node on farm +- _(ftrack)_ pype would crash when launcher project level task +- work directory was sometimes not being created correctly +- major pype.lib cleanup. Removing of unused functions, merging those that were doing the same and general house cleaning. +- _(avalon)_ subsets in maya 2019 weren't behaving correctly in the outliner + + +\* *This Changelog was automatically generated by [github_changelog_generator](https://github.com/github-changelog-generator/github-changelog-generator)* diff --git a/HISTORY.md b/HISTORY.md new file mode 100644 index 0000000000..d60bd7b0c7 --- /dev/null +++ b/HISTORY.md @@ -0,0 +1,432 @@ + +## 2.11.0 ## + +_**release date:** 27 July 2020_ + +**new:** +- _(blender)_ namespace support [\#341](https://github.com/pypeclub/pype/pull/341) +- _(blender)_ start end frames [\#330](https://github.com/pypeclub/pype/pull/330) +- _(blender)_ camera asset [\#322](https://github.com/pypeclub/pype/pull/322) +- _(pype)_ toggle instances per family in pyblish GUI [\#320](https://github.com/pypeclub/pype/pull/320) +- _(pype)_ current release version is now shown in the tray menu [#379](https://github.com/pypeclub/pype/pull/379) + + +**improved:** +- _(resolve)_ tagging for publish [\#239](https://github.com/pypeclub/pype/issues/239) +- _(pype)_ Support publishing a subset of shots with standalone editorial [\#336](https://github.com/pypeclub/pype/pull/336) +- _(harmony)_ Basic support for palettes [\#324](https://github.com/pypeclub/pype/pull/324) +- _(photoshop)_ Flag outdated containers on startup and publish. [\#309](https://github.com/pypeclub/pype/pull/309) +- _(harmony)_ Flag Outdated containers [\#302](https://github.com/pypeclub/pype/pull/302) +- _(photoshop)_ Publish review [\#298](https://github.com/pypeclub/pype/pull/298) +- _(pype)_ Optional Last workfile launch [\#365](https://github.com/pypeclub/pype/pull/365) + + +**fixed:** +- _(premiere)_ workflow fixes [\#346](https://github.com/pypeclub/pype/pull/346) +- _(pype)_ pype-setup does not work with space in path [\#327](https://github.com/pypeclub/pype/issues/327) +- _(ftrack)_ Ftrack delete action cause circular error [\#206](https://github.com/pypeclub/pype/issues/206) +- _(nuke)_ Priority was forced to 50 [\#345](https://github.com/pypeclub/pype/pull/345) +- _(nuke)_ Fix ValidateNukeWriteKnobs [\#340](https://github.com/pypeclub/pype/pull/340) +- _(maya)_ If camera attributes are connected, we can ignore them. [\#339](https://github.com/pypeclub/pype/pull/339) +- _(pype)_ stop appending of tools environment to existing env [\#337](https://github.com/pypeclub/pype/pull/337) +- _(ftrack)_ Ftrack timeout needs to look at AVALON\_TIMEOUT [\#325](https://github.com/pypeclub/pype/pull/325) +- _(harmony)_ Only zip files are supported. [\#310](https://github.com/pypeclub/pype/pull/310) +- _(pype)_ hotfix/Fix event server mongo uri [\#305](https://github.com/pypeclub/pype/pull/305) +- _(photoshop)_ Subset was not named or validated correctly. [\#304](https://github.com/pypeclub/pype/pull/304) + + + + +## 2.10.0 ## + +_**release date:** 17 June 2020_ + +**new:** +- _(harmony)_ **Toon Boom Harmony** has been greatly extended to support rigging, scene build, animation and rendering workflows. [#270](https://github.com/pypeclub/pype/issues/270) [#271](https://github.com/pypeclub/pype/issues/271) [#190](https://github.com/pypeclub/pype/issues/190) [#191](https://github.com/pypeclub/pype/issues/191) [#172](https://github.com/pypeclub/pype/issues/172) [#168](https://github.com/pypeclub/pype/issues/168) +- _(pype)_ Added support for rudimentary **edl publishing** into individual shots. [#265](https://github.com/pypeclub/pype/issues/265) +- _(celaction)_ Simple **Celaction** integration has been added with support for workfiles and rendering. [#255](https://github.com/pypeclub/pype/issues/255) +- _(maya)_ Support for multiple job types when submitting to the farm. We can now render Maya or Standalone render jobs for Vray and Arnold (limited support for arnold) [#204](https://github.com/pypeclub/pype/issues/204) +- _(photoshop)_ Added initial support for Photoshop [#232](https://github.com/pypeclub/pype/issues/232) + +**improved:** +- _(blender)_ Updated support for rigs and added support Layout family [#233](https://github.com/pypeclub/pype/issues/233) [#226](https://github.com/pypeclub/pype/issues/226) +- _(premiere)_ It is now possible to choose different storage root for workfiles of different task types. [#255](https://github.com/pypeclub/pype/issues/255) +- _(maya)_ Support for unmerged AOVs in Redshift multipart EXRs [#197](https://github.com/pypeclub/pype/issues/197) +- _(pype)_ Pype repository has been refactored in preparation for 3.0 release [#169](https://github.com/pypeclub/pype/issues/169) +- _(deadline)_ All file dependencies are now passed to deadline from maya to prevent premature start of rendering if caches or textures haven't been coppied over yet. [#195](https://github.com/pypeclub/pype/issues/195) +- _(nuke)_ Script validation can now be made optional. [#194](https://github.com/pypeclub/pype/issues/194) +- _(pype)_ Publishing can now be stopped at any time. [#194](https://github.com/pypeclub/pype/issues/194) + +**fix:** +- _(pype)_ Pyblish-lite has been integrated into pype repository, plus various publishing GUI fixes. [#274](https://github.com/pypeclub/pype/issues/274) [#275](https://github.com/pypeclub/pype/issues/275) [#268](https://github.com/pypeclub/pype/issues/268) [#227](https://github.com/pypeclub/pype/issues/227) [#238](https://github.com/pypeclub/pype/issues/238) +- _(maya)_ Alembic extractor was getting wrong frame range type in certain scenarios [#254](https://github.com/pypeclub/pype/issues/254) +- _(maya)_ Attaching a render to subset in maya was not passing validation in certain scenarios [#256](https://github.com/pypeclub/pype/issues/256) +- _(ftrack)_ Various small fixes to ftrack sync [#263](https://github.com/pypeclub/pype/issues/263) [#259](https://github.com/pypeclub/pype/issues/259) +- _(maya)_ Look extraction is now able to skp invalid connections in shaders [#207](https://github.com/pypeclub/pype/issues/207) + + + + +## 2.9.0 ## + +_**release date:** 25 May 2020_ + +**new:** +- _(pype)_ Support for **Multiroot projects**. You can now store project data on multiple physical or virtual storages and target individual publishes to these locations. For instance render can be stored on a faster storage than the rest of the project. [#145](https://github.com/pypeclub/pype/issues/145), [#38](https://github.com/pypeclub/pype/issues/38) +- _(harmony)_ Basic implementation of **Toon Boom Harmony** has been added. [#142](https://github.com/pypeclub/pype/issues/142) +- _(pype)_ OSX support is in public beta now. There are issues to be expected, but the main implementation should be functional. [#141](https://github.com/pypeclub/pype/issues/141) + + +**improved:** + +- _(pype)_ **Review extractor** has been completely rebuilt. It now supports granular filtering so you can create **multiple outputs** for different tasks, families or hosts. [#103](https://github.com/pypeclub/pype/issues/103), [#166](https://github.com/pypeclub/pype/issues/166), [#165](https://github.com/pypeclub/pype/issues/165) +- _(pype)_ **Burnin** generation had been extended to **support same multi-output filtering** as review extractor [#103](https://github.com/pypeclub/pype/issues/103) +- _(pype)_ Publishing file templates can now be specified in config for each individual family [#114](https://github.com/pypeclub/pype/issues/114) +- _(pype)_ Studio specific plugins can now be appended to pype standard publishing plugins. [#112](https://github.com/pypeclub/pype/issues/112) +- _(nukestudio)_ Reviewable clips no longer need to be previously cut, exported and re-imported to timeline. **Pype can now dynamically cut reviewable quicktimes** from continuous offline footage during publishing. [#23](https://github.com/pypeclub/pype/issues/23) +- _(deadline)_ Deadline can now correctly differentiate between staging and production pype. [#154](https://github.com/pypeclub/pype/issues/154) +- _(deadline)_ `PYPE_PYTHON_EXE` env variable can now be used to direct publishing to explicit python installation. [#120](https://github.com/pypeclub/pype/issues/120) +- _(nuke)_ Nuke now check for new version of loaded data on file open. [#140](https://github.com/pypeclub/pype/issues/140) +- _(nuke)_ frame range and limit checkboxes are now exposed on write node. [#119](https://github.com/pypeclub/pype/issues/119) + + + +**fix:** + +- _(nukestudio)_ Project Location was using backslashes which was breaking nukestudio native exporting in certains configurations [#82](https://github.com/pypeclub/pype/issues/82) +- _(nukestudio)_ Duplicity in hierarchy tags was prone to throwing publishing error [#130](https://github.com/pypeclub/pype/issues/130), [#144](https://github.com/pypeclub/pype/issues/144) +- _(ftrack)_ multiple stability improvements [#157](https://github.com/pypeclub/pype/issues/157), [#159](https://github.com/pypeclub/pype/issues/159), [#128](https://github.com/pypeclub/pype/issues/128), [#118](https://github.com/pypeclub/pype/issues/118), [#127](https://github.com/pypeclub/pype/issues/127) +- _(deadline)_ multipart EXRs were stopping review publishing on the farm. They are still not supported for automatic review generation, but the publish will go through correctly without the quicktime. [#155](https://github.com/pypeclub/pype/issues/155) +- _(deadline)_ If deadline is non-responsive it will no longer freeze host when publishing [#149](https://github.com/pypeclub/pype/issues/149) +- _(deadline)_ Sometimes deadline was trying to launch render before all the source data was coppied over. [#137](https://github.com/pypeclub/pype/issues/137) _(harmony)_ Basic implementation of **Toon Boom Harmony** has been added. [#142](https://github.com/pypeclub/pype/issues/142) +- _(nuke)_ Filepath knob wasn't updated properly. [#131](https://github.com/pypeclub/pype/issues/131) +- _(maya)_ When extracting animation, the "Write Color Set" options on the instance were not respected. [#108](https://github.com/pypeclub/pype/issues/108) +- _(maya)_ Attribute overrides for AOV only worked for the legacy render layers. Now it works for new render setup as well [#132](https://github.com/pypeclub/pype/issues/132) +- _(maya)_ Stability and usability improvements in yeti workflow [#104](https://github.com/pypeclub/pype/issues/104) + + + + +## 2.8.0 ## + +_**release date:** 20 April 2020_ + +**new:** + +- _(pype)_ Option to generate slates from json templates. [PYPE-628] [#26](https://github.com/pypeclub/pype/issues/26) +- _(pype)_ It is now possible to automate loading of published subsets into any scene. Documentation will follow :). [PYPE-611] [#24](https://github.com/pypeclub/pype/issues/24) + +**fix:** + +- _(maya)_ Some Redshift render tokens could break publishing. [PYPE-778] [#33](https://github.com/pypeclub/pype/issues/33) +- _(maya)_ Publish was not preserving maya file extension. [#39](https://github.com/pypeclub/pype/issues/39) +- _(maya)_ Rig output validator was failing on nodes without shapes. [#40](https://github.com/pypeclub/pype/issues/40) +- _(maya)_ Yeti caches can now be properly versioned up in the scene inventory. [#40](https://github.com/pypeclub/pype/issues/40) +- _(nuke)_ Build first workfiles was not accepting jpeg sequences. [#34](https://github.com/pypeclub/pype/issues/34) +- _(deadline)_ Trying to generate ffmpeg review from multipart EXRs no longer crashes publishing. [PYPE-781] +- _(deadline)_ Render publishing is more stable in multiplatform environments. [PYPE-775] + + + + +## 2.7.0 ## + +_**release date:** 30 March 2020_ + +**new:** + +- _(maya)_ Artist can now choose to load multiple references of the same subset at once [PYPE-646, PYPS-81] +- _(nuke)_ Option to use named OCIO colorspaces for review colour baking. [PYPS-82] +- _(pype)_ Pype can now work with `master` versions for publishing and loading. These are non-versioned publishes that are overwritten with the latest version during publish. These are now supported in all the GUIs, but their publishing is deactivated by default. [PYPE-653] +- _(blender)_ Added support for basic blender workflow. We currently support `rig`, `model` and `animation` families. [PYPE-768] +- _(pype)_ Source timecode can now be used in burn-ins. [PYPE-777] +- _(pype)_ Review outputs profiles can now specify delivery resolution different than project setting [PYPE-759] +- _(nuke)_ Bookmark to current context is now added automatically to all nuke browser windows. [PYPE-712] + +**change:** + +- _(maya)_ It is now possible to publish camera without. baking. Keep in mind that unbaked cameras can't be guaranteed to work in other hosts. [PYPE-595] +- _(maya)_ All the renders from maya are now grouped in the loader by their Layer name. [PYPE-482] +- _(nuke/hiero)_ Any publishes from nuke and hiero can now be versioned independently of the workfile. [PYPE-728] + + +**fix:** + +- _(nuke)_ Mixed slashes caused issues in ocio config path. +- _(pype)_ Intent field in pyblish GUI was passing label instead of value to ftrack. [PYPE-733] +- _(nuke)_ Publishing of pre-renders was inconsistent. [PYPE-766] +- _(maya)_ Handles and frame ranges were inconsistent in various places during publishing. +- _(nuke)_ Nuke was crashing if it ran into certain missing knobs. For example DPX output missing `autocrop` [PYPE-774] +- _(deadline)_ Project overrides were not working properly with farm render publishing. +- _(hiero)_ Problems with single frame plates publishing. +- _(maya)_ Redshift RenderPass token were breaking render publishing. [PYPE-778] +- _(nuke)_ Build first workfile was not accepting jpeg sequences. +- _(maya)_ Multipart (Multilayer) EXRs were breaking review publishing due to FFMPEG incompatiblity [PYPE-781] + + + +## 2.6.0 ## + +_**release date:** 9 March 2020_ + +**change:** +- _(maya)_ render publishing has been simplified and made more robust. Render setup layers are now automatically added to publishing subsets and `render globals` family has been replaced with simple `render` [PYPE-570] +- _(avalon)_ change context and workfiles apps, have been merged into one, that allows both actions to be performed at the same time. [PYPE-747] +- _(pype)_ thumbnails are now automatically propagate to asset from the last published subset in the loader +- _(ftrack)_ publishing comment and intent are now being published to ftrack note as well as describtion. [PYPE-727] +- _(pype)_ when overriding existing version new old representations are now overriden, instead of the new ones just being appended. (to allow this behaviour, the version validator need to be disabled. [PYPE-690]) +- _(pype)_ burnin preset has been significantly simplified. It now doesn't require passing function to each field, but only need the actual text template. to use this, all the current burnin PRESETS MUST BE UPDATED for all the projects. +- _(ftrack)_ credentials are now stored on a per server basis, so it's possible to switch between ftrack servers without having to log in and out. [PYPE-723] + + +**new:** +- _(pype)_ production and development deployments now have different colour of the tray icon. Orange for Dev and Green for production [PYPE-718] +- _(maya)_ renders can now be attached to a publishable subset rather than creating their own subset. For example it is possible to create a reviewable `look` or `model` render and have it correctly attached as a representation of the subsets [PYPE-451] +- _(maya)_ after saving current scene into a new context (as a new shot for instance), all the scene publishing subsets data gets re-generated automatically to match the new context [PYPE-532] +- _(pype)_ we now support project specific publish, load and create plugins [PYPE-740] +- _(ftrack)_ new action that allow archiving/deleting old published versions. User can keep how many of the latest version to keep when the action is ran. [PYPE-748, PYPE-715] +- _(ftrack)_ it is now possible to monitor and restart ftrack event server using ftrack action. [PYPE-658] +- _(pype)_ validator that prevent accidental overwrites of previously published versions. [PYPE-680] +- _(avalon)_ avalon core updated to version 5.6.0 +- _(maya)_ added validator to make sure that relative paths are used when publishing arnold standins. +- _(nukestudio)_ it is now possible to extract and publish audio family from clip in nuke studio [PYPE-682] + +**fix**: +- _(maya)_ maya set framerange button was ignoring handles [PYPE-719] +- _(ftrack)_ sync to avalon was sometime crashing when ran on empty project +- _(nukestudio)_ publishing same shots after they've been previously archived/deleted would result in a crash. [PYPE-737] +- _(nuke)_ slate workflow was breaking in certain scenarios. [PYPE-730] +- _(pype)_ rendering publish workflow has been significantly improved to prevent error resulting from implicit render collection. [PYPE-665, PYPE-746] +- _(pype)_ launching application on a non-synced project resulted in obscure [PYPE-528] +- _(pype)_ missing keys in burnins no longer result in an error. [PYPE-706] +- _(ftrack)_ create folder structure action was sometimes failing for project managers due to wrong permissions. +- _(Nukestudio)_ using `source` in the start frame tag could result in wrong frame range calculation +- _(ftrack)_ sync to avalon action and event have been improved by catching more edge cases and provessing them properly. + + + +## 2.5.0 ## + +_**release date:** 11 Feb 2020_ + +**change:** +- _(pype)_ added many logs for easier debugging +- _(pype)_ review presets can now be separated between 2d and 3d renders [PYPE-693] +- _(pype)_ anatomy module has been greatly improved to allow for more dynamic pulblishing and faster debugging [PYPE-685] +- _(pype)_ avalon schemas have been moved from `pype-config` to `pype` repository, for simplification. [PYPE-670] +- _(ftrack)_ updated to latest ftrack API +- _(ftrack)_ publishing comments now appear in ftrack also as a note on version with customisable category [PYPE-645] +- _(ftrack)_ delete asset/subset action had been improved. It is now able to remove multiple entities and descendants of the selected entities [PYPE-361, PYPS-72] +- _(workfiles)_ added date field to workfiles app [PYPE-603] +- _(maya)_ old deprecated loader have been removed in favour of a single unified reference loader (old scenes will upgrade automatically to the new loader upon opening) [PYPE-633, PYPE-697] +- _(avalon)_ core updated to 5.5.15 [PYPE-671] +- _(nuke)_ library loader is now available in nuke [PYPE-698] + + +**new:** +- _(pype)_ added pype render wrapper to allow rendering on mixed platform farms. [PYPE-634] +- _(pype)_ added `pype launch` command. It let's admin run applications with dynamically built environment based on the given context. [PYPE-634] +- _(pype)_ added support for extracting review sequences with burnins [PYPE-657] +- _(publish)_ users can now set intent next to a comment when publishing. This will then be reflected on an attribute in ftrack. [PYPE-632] +- _(burnin)_ timecode can now be added to burnin +- _(burnin)_ datetime keys can now be added to burnin and anatomy [PYPE-651] +- _(burnin)_ anatomy templates can now be used in burnins. [PYPE=626] +- _(nuke)_ new validator for render resolution +- _(nuke)_ support for attach slate to nuke renders [PYPE-630] +- _(nuke)_ png sequences were added to loaders +- _(maya)_ added maya 2020 compatibility [PYPE-677] +- _(maya)_ ability to publish and load .ASS standin sequences [PYPS-54] +- _(pype)_ thumbnails can now be published and are visible in the loader. `AVALON_THUMBNAIL_ROOT` environment variable needs to be set for this to work [PYPE-573, PYPE-132] +- _(blender)_ base implementation of blender was added with publishing and loading of .blend files [PYPE-612] +- _(ftrack)_ new action for preparing deliveries [PYPE-639] + + +**fix**: +- _(burnin)_ more robust way of finding ffmpeg for burnins. +- _(pype)_ improved UNC paths remapping when sending to farm. +- _(pype)_ float frames sometimes made their way to representation context in database, breaking loaders [PYPE-668] +- _(pype)_ `pype install --force` was failing sometimes [PYPE-600] +- _(pype)_ padding in published files got calculated wrongly sometimes. It is now instead being always read from project anatomy. [PYPE-667] +- _(publish)_ comment publishing was failing in certain situations +- _(ftrack)_ multiple edge case scenario fixes in auto sync and sync-to-avalon action +- _(ftrack)_ sync to avalon now works on empty projects +- _(ftrack)_ thumbnail update event was failing when deleting entities [PYPE-561] +- _(nuke)_ loader applies proper colorspaces from Presets +- _(nuke)_ publishing handles didn't always work correctly [PYPE-686] +- _(maya)_ assembly publishing and loading wasn't working correctly + + + + + +## 2.4.0 ## + +_**release date:** 9 Dec 2019_ + +**change:** +- _(ftrack)_ version to status ftrack event can now be configured from Presets + - based on preset `presets/ftracc/ftrack_config.json["status_version_to_task"]` +- _(ftrack)_ sync to avalon event has been completely re-written. It now supports most of the project management situations on ftrack including moving, renaming and deleting entities, updating attributes and working with tasks. +- _(ftrack)_ sync to avalon action has been also re-writen. It is now much faster (up to 100 times depending on a project structure), has much better logging and reporting on encountered problems, and is able to handle much more complex situations. +- _(ftrack)_ sync to avalon trigger by checking `auto-sync` toggle on ftrack [PYPE-504] +- _(pype)_ various new features in the REST api +- _(pype)_ new visual identity used across pype +- _(pype)_ started moving all requirements to pip installation rather than vendorising them in pype repository. Due to a few yet unreleased packages, this means that pype can temporarily be only installed in the offline mode. + +**new:** +- _(nuke)_ support for publishing gizmos and loading them as viewer processes +- _(nuke)_ support for publishing nuke nodes from backdrops and loading them back +- _(pype)_ burnins can now work with start and end frames as keys + - use keys `{frame_start}`, `{frame_end}` and `{current_frame}` in burnin preset to use them. [PYPS-44,PYPS-73, PYPE-602] +- _(pype)_ option to filter logs by user and level in loggin GUI +- _(pype)_ image family added to standalone publisher [PYPE-574] +- _(pype)_ matchmove family added to standalone publisher [PYPE-574] +- _(nuke)_ validator for comparing arbitrary knobs with values from presets +- _(maya)_ option to force maya to copy textures in the new look publish rather than hardlinking them +- _(pype)_ comments from pyblish GUI are now being added to ftrack version +- _(maya)_ validator for checking outdated containers in the scene +- _(maya)_ option to publish and load arnold standin sequence [PYPE-579, PYPS-54] + +**fix**: +- _(pype)_ burnins were not respecting codec of the input video +- _(nuke)_ lot's of various nuke and nuke studio fixes across the board [PYPS-45] +- _(pype)_ workfiles app is not launching with the start of the app by default [PYPE-569] +- _(ftrack)_ ftrack integration during publishing was failing under certain situations [PYPS-66] +- _(pype)_ minor fixes in REST api +- _(ftrack)_ status change event was crashing when the target status was missing [PYPS-68] +- _(ftrack)_ actions will try to reconnect if they fail for some reason +- _(maya)_ problems with fps mapping when using float FPS values +- _(deadline)_ overall improvements to deadline publishing +- _(setup)_ environment variables are now remapped on the fly based on the platform pype is running on. This fixes many issues in mixed platform environments. + + + +## 2.3.6 # + +_**release date:** 27 Nov 2019_ + +**hotfix**: +- _(ftrack)_ was hiding important debug logo +- _(nuke)_ crashes during workfile publishing +- _(ftrack)_ event server crashes because of signal problems +- _(muster)_ problems with muster render submissions +- _(ftrack)_ thumbnail update event syntax errors + + +## 2.3.0 ## +_release date: 6 Oct 2019_ + +**new**: +- _(maya)_ support for yeti rigs and yeti caches +- _(maya)_ validator for comparing arbitrary attributes against ftrack +- _(pype)_ burnins can now show current date and time +- _(muster)_ pools can now be set in render globals in maya +- _(pype)_ Rest API has been implemented in beta stage +- _(nuke)_ LUT loader has been added +- _(pype)_ rudimentary user module has been added as preparation for user management +- _(pype)_ a simple logging GUI has been added to pype tray +- _(nuke)_ nuke can now bake input process into mov +- _(maya)_ imported models now have selection handle displayed by defaulting +- _(avalon)_ it's is now possible to load multiple assets at once using loader +- _(maya)_ added ability to automatically connect yeti rig to a mesh upon loading + +**changed**: +- _(ftrack)_ event server now runs two parallel processes and is able to keep queue of events to process. +- _(nuke)_ task name is now added to all rendered subsets +- _(pype)_ adding more families to standalone publisher +- _(pype)_ standalone publisher now uses pyblish-lite +- _(pype)_ standalone publisher can now create review quicktimes +- _(ftrack)_ queries to ftrack were sped up +- _(ftrack)_ multiple ftrack action have been deprecated +- _(avalon)_ avalon upstream has been updated to 5.5.0 +- _(nukestudio)_ published transforms can now be animated +- + +**fix**: +- _(maya)_ fps popup button didn't work in some cases +- _(maya)_ geometry instances and references in maya were losing shader assignments +- _(muster)_ muster rendering templates were not working correctly +- _(maya)_ arnold tx texture conversion wasn't respecting colorspace set by the artist +- _(pype)_ problems with avalon db sync +- _(maya)_ ftrack was rounding FPS making it inconsistent +- _(pype)_ wrong icon names in Creator +- _(maya)_ scene inventory wasn't showing anything if representation was removed from database after it's been loaded to the scene +- _(nukestudio)_ multiple bugs squashed +- _(loader)_ loader was taking long time to show all the loading action when first launcher in maya + +## 2.2.0 ## +_release date: 8 Sept 2019_ + +**new**: +- _(pype)_ add customisable workflow for creating quicktimes from renders or playblasts +- _(nuke)_ option to choose deadline chunk size on write nodes +- _(nukestudio)_ added option to publish soft effects (subTrackItems) from NukeStudio as subsets including LUT files. these can then be loaded in nuke or NukeStudio +- _(nuke)_ option to build nuke script from previously published latest versions of plate and render subsets. +- _(nuke)_ nuke writes now have deadline tab. +- _(ftrack)_ Prepare Project action can now be used for creating the base folder structure on disk and in ftrack, setting up all the initial project attributes and it automatically prepares `pype_project_config` folder for the given project. +- _(clockify)_ Added support for time tracking in clockify. This currently in addition to ftrack time logs, but does not completely replace them. +- _(pype)_ any attributes in Creator and Loader plugins can now be customised using pype preset system + +**changed**: +- nukestudio now uses workio API for workfiles +- _(maya)_ "FIX FPS" prompt in maya now appears in the middle of the screen +- _(muster)_ can now be configured with custom templates +- _(pype)_ global publishing plugins can now be configured using presets as well as host specific ones + + +**fix**: +- wrong version retrieval from path in certain scenarios +- nuke reset resolution wasn't working in certain scenarios + +## 2.1.0 ## +_release date: 6 Aug 2019_ + +A large cleanup release. Most of the change are under the hood. + +**new**: +- _(pype)_ add customisable workflow for creating quicktimes from renders or playblasts +- _(pype)_ Added configurable option to add burnins to any generated quicktimes +- _(ftrack)_ Action that identifies what machines pype is running on. +- _(system)_ unify subprocess calls +- _(maya)_ add audio to review quicktimes +- _(nuke)_ add crop before write node to prevent overscan problems in ffmpeg +- **Nuke Studio** publishing and workfiles support +- **Muster** render manager support +- _(nuke)_ Framerange, FPS and Resolution are set automatically at startup +- _(maya)_ Ability to load published sequences as image planes +- _(system)_ Ftrack event that sets asset folder permissions based on task assignees in ftrack. +- _(maya)_ Pyblish plugin that allow validation of maya attributes +- _(system)_ added better startup logging to tray debug, including basic connection information +- _(avalon)_ option to group published subsets to groups in the loader +- _(avalon)_ loader family filters are working now + +**changed**: +- change multiple key attributes to unify their behaviour across the pipeline + - `frameRate` to `fps` + - `startFrame` to `frameStart` + - `endFrame` to `frameEnd` + - `fstart` to `frameStart` + - `fend` to `frameEnd` + - `handle_start` to `handleStart` + - `handle_end` to `handleEnd` + - `resolution_width` to `resolutionWidth` + - `resolution_height` to `resolutionHeight` + - `pixel_aspect` to `pixelAspect` + +- _(nuke)_ write nodes are now created inside group with only some attributes editable by the artist +- rendered frames are now deleted from temporary location after their publishing is finished. +- _(ftrack)_ RV action can now be launched from any entity +- after publishing only refresh button is now available in pyblish UI +- added context instance pyblish-lite so that artist knows if context plugin fails +- _(avalon)_ allow opening selected files using enter key +- _(avalon)_ core updated to v5.2.9 with our forked changes on top + +**fix**: +- faster hierarchy retrieval from db +- _(nuke)_ A lot of stability enhancements +- _(nuke studio)_ A lot of stability enhancements +- _(nuke)_ now only renders a single write node on farm +- _(ftrack)_ pype would crash when launcher project level task +- work directory was sometimes not being created correctly +- major pype.lib cleanup. Removing of unused functions, merging those that were doing the same and general house cleaning. +- _(avalon)_ subsets in maya 2019 weren't behaving correctly in the outliner diff --git a/changelog.md b/changelog.md deleted file mode 100644 index bdee041615..0000000000 --- a/changelog.md +++ /dev/null @@ -1,120 +0,0 @@ -# Pype changelog # -Welcome to pype changelog - -## 2.3.0 ## -_release date: 6 Oct 2019_ - -**new**: -- _(maya)_ support for yeti rigs and yeti caches -- _(maya)_ validator for comparing arbitrary attributes against ftrack -- _(pype)_ burnins can now show current date and time -- _(muster)_ pools can now be set in render globals in maya -- _(pype)_ Rest API has been implemented in beta stage -- _(nuke)_ LUT loader has been added -- _(pype)_ rudimentary user module has been added as preparation for user management -- _(pype)_ a simple logging GUI has been added to pype tray -- _(nuke)_ nuke can now bake input process into mov -- _(maya)_ imported models now have selection handle displayed by defaulting -- _(avalon)_ it's is now possible to load multiple assets at once using loader -- _(maya)_ added ability to automatically connect yeti rig to a mesh upon loading - -**changed**: -- _(ftrack)_ event server now runs two parallel processes and is able to keep queue of events to process. -- _(nuke)_ task name is now added to all rendered subsets -- _(pype)_ adding more families to standalone publisher -- _(pype)_ standalone publisher now uses pyblish-lite -- _(pype)_ standalone publisher can now create review quicktimes -- _(ftrack)_ queries to ftrack were sped up -- _(ftrack)_ multiple ftrack action have been deprecated -- _(avalon)_ avalon upstream has been updated to 5.5.0 -- _(nukestudio)_ published transforms can now be animated -- - -**fix**: -- _(maya)_ fps popup button didn't work in some cases -- _(maya)_ geometry instances and references in maya were losing shader assignments -- _(muster)_ muster rendering templates were not working correctly -- _(maya)_ arnold tx texture conversion wasn't respecting colorspace set by the artist -- _(pype)_ problems with avalon db sync -- _(maya)_ ftrack was rounding FPS making it inconsistent -- _(pype)_ wrong icon names in Creator -- _(maya)_ scene inventory wasn't showing anything if representation was removed from database after it's been loaded to the scene -- _(nukestudio)_ multiple bugs squashed -- _(loader)_ loader was taking long time to show all the loading action when first launcher in maya - -## 2.2.0 ## -_release date: 8 Sept 2019_ - -**new**: -- _(pype)_ add customisable workflow for creating quicktimes from renders or playblasts -- _(nuke)_ option to choose deadline chunk size on write nodes -- _(nukestudio)_ added option to publish soft effects (subTrackItems) from NukeStudio as subsets including LUT files. these can then be loaded in nuke or NukeStudio -- _(nuke)_ option to build nuke script from previously published latest versions of plate and render subsets. -- _(nuke)_ nuke writes now have deadline tab. -- _(ftrack)_ Prepare Project action can now be used for creating the base folder structure on disk and in ftrack, setting up all the initial project attributes and it automatically prepares `pype_project_config` folder for the given project. -- _(clockify)_ Added support for time tracking in clockify. This currently in addition to ftrack time logs, but does not completely replace them. -- _(pype)_ any attributes in Creator and Loader plugins can now be customised using pype preset system - -**changed**: -- nukestudio now uses workio API for workfiles -- _(maya)_ "FIX FPS" prompt in maya now appears in the middle of the screen -- _(muster)_ can now be configured with custom templates -- _(pype)_ global publishing plugins can now be configured using presets as well as host specific ones - - -**fix**: -- wrong version retrieval from path in certain scenarios -- nuke reset resolution wasn't working in certain scenarios - -## 2.1.0 ## -_release date: 6 Aug 2019_ - -A large cleanup release. Most of the change are under the hood. - -**new**: -- _(pype)_ add customisable workflow for creating quicktimes from renders or playblasts -- _(pype)_ Added configurable option to add burnins to any generated quicktimes -- _(ftrack)_ Action that identifies what machines pype is running on. -- _(system)_ unify subprocess calls -- _(maya)_ add audio to review quicktimes -- _(nuke)_ add crop before write node to prevent overscan problems in ffmpeg -- **Nuke Studio** publishing and workfiles support -- **Muster** render manager support -- _(nuke)_ Framerange, FPS and Resolution are set automatically at startup -- _(maya)_ Ability to load published sequences as image planes -- _(system)_ Ftrack event that sets asset folder permissions based on task assignees in ftrack. -- _(maya)_ Pyblish plugin that allow validation of maya attributes -- _(system)_ added better startup logging to tray debug, including basic connection information -- _(avalon)_ option to group published subsets to groups in the loader -- _(avalon)_ loader family filters are working now - -**changed**: -- change multiple key attributes to unify their behaviour across the pipeline - - `frameRate` to `fps` - - `startFrame` to `frameStart` - - `endFrame` to `frameEnd` - - `fstart` to `frameStart` - - `fend` to `frameEnd` - - `handle_start` to `handleStart` - - `handle_end` to `handleEnd` - - `resolution_width` to `resolutionWidth` - - `resolution_height` to `resolutionHeight` - - `pixel_aspect` to `pixelAspect` - -- _(nuke)_ write nodes are now created inside group with only some attributes editable by the artist -- rendered frames are now deleted from temporary location after their publishing is finished. -- _(ftrack)_ RV action can now be launched from any entity -- after publishing only refresh button is now available in pyblish UI -- added context instance pyblish-lite so that artist knows if context plugin fails -- _(avalon)_ allow opening selected files using enter key -- _(avalon)_ core updated to v5.2.9 with our forked changes on top - -**fix**: -- faster hierarchy retrieval from db -- _(nuke)_ A lot of stability enhancements -- _(nuke studio)_ A lot of stability enhancements -- _(nuke)_ now only renders a single write node on farm -- _(ftrack)_ pype would crash when launcher project level task -- work directory was sometimes not being created correctly -- major pype.lib cleanup. Removing of unused functions, merging those that were doing the same and general house cleaning. -- _(avalon)_ subsets in maya 2019 weren't behaving correctly in the outliner diff --git a/pype/api.py b/pype/api.py index c722757a3c..021080b4d5 100644 --- a/pype/api.py +++ b/pype/api.py @@ -1,3 +1,7 @@ +from .settings import ( + system_settings, + project_settings +) from pypeapp import ( Logger, Anatomy, @@ -49,6 +53,9 @@ from .lib import ( from .lib import _subprocess as subprocess __all__ = [ + "system_settings", + "project_settings", + "Logger", "Anatomy", "project_overrides_dir_path", diff --git a/pype/hooks/resolve/prelaunch.py b/pype/hooks/resolve/prelaunch.py index bddeccf4a3..a122b87868 100644 --- a/pype/hooks/resolve/prelaunch.py +++ b/pype/hooks/resolve/prelaunch.py @@ -46,13 +46,14 @@ class ResolvePrelaunch(PypeHook): "`RESOLVE_UTILITY_SCRIPTS_DIR` or reinstall DaVinci Resolve. \n" f"RESOLVE_UTILITY_SCRIPTS_DIR: `{us_dir}`" ) + self.log.debug(f"-- us_dir: `{us_dir}`") # correctly format path for pre python script pre_py_sc = os.path.normpath(env.get("PRE_PYTHON_SCRIPT", "")) env["PRE_PYTHON_SCRIPT"] = pre_py_sc - + self.log.debug(f"-- pre_py_sc: `{pre_py_sc}`...") try: - __import__("pype.resolve") + __import__("pype.hosts.resolve") __import__("pyblish") except ImportError as e: @@ -62,6 +63,7 @@ class ResolvePrelaunch(PypeHook): else: # Resolve Setup integration importlib.reload(utils) + self.log.debug(f"-- utils.__file__: `{utils.__file__}`") utils.setup(env) return True diff --git a/pype/hosts/harmony/__init__.py b/pype/hosts/harmony/__init__.py index d4b7d91fdb..fbf5ca6f12 100644 --- a/pype/hosts/harmony/__init__.py +++ b/pype/hosts/harmony/__init__.py @@ -6,10 +6,13 @@ from avalon.vendor import Qt import avalon.tools.sceneinventory import pyblish.api from pype import lib +from pype.api import config def set_scene_settings(settings): - func = """function func(args) + + signature = harmony.signature("set_scene_settings") + func = """function %s(args) { if (args[0]["fps"]) { @@ -18,12 +21,7 @@ def set_scene_settings(settings): if (args[0]["frameStart"] && args[0]["frameEnd"]) { var duration = args[0]["frameEnd"] - args[0]["frameStart"] + 1 - if (frame.numberOf() > duration) - { - frame.remove( - duration, frame.numberOf() - duration - ); - } + if (frame.numberOf() < duration) { frame.insert( @@ -41,8 +39,8 @@ def set_scene_settings(settings): ) } } - func - """ + %s + """ % (signature, signature) harmony.send({"function": func, "args": [settings]}) @@ -54,7 +52,7 @@ def get_asset_settings(): resolution_width = asset_data.get("resolutionWidth") resolution_height = asset_data.get("resolutionHeight") - return { + scene_data = { "fps": fps, "frameStart": frame_start, "frameEnd": frame_end, @@ -62,6 +60,18 @@ def get_asset_settings(): "resolutionHeight": resolution_height } + try: + skip_resolution_check = \ + config.get_presets()["harmony"]["general"]["skip_resolution_check"] + except KeyError: + skip_resolution_check = [] + + if os.getenv('AVALON_TASK') in skip_resolution_check: + scene_data.pop("resolutionWidth") + scene_data.pop("resolutionHeight") + + return scene_data + def ensure_scene_settings(): settings = get_asset_settings() @@ -75,21 +85,20 @@ def ensure_scene_settings(): valid_settings[key] = value # Warn about missing attributes. - print("Starting new QApplication..") - app = Qt.QtWidgets.QApplication(sys.argv) - - message_box = Qt.QtWidgets.QMessageBox() - message_box.setIcon(Qt.QtWidgets.QMessageBox.Warning) - msg = "Missing attributes:" if invalid_settings: + print("Starting new QApplication..") + app = Qt.QtWidgets.QApplication.instance() + if not app: + app = Qt.QtWidgets.QApplication(sys.argv) + + message_box = Qt.QtWidgets.QMessageBox() + message_box.setIcon(Qt.QtWidgets.QMessageBox.Warning) + msg = "Missing attributes:" for item in invalid_settings: msg += f"\n{item}" message_box.setText(msg) message_box.exec_() - # Garbage collect QApplication. - del app - set_scene_settings(valid_settings) @@ -112,15 +121,17 @@ def check_inventory(): outdated_containers.append(container) # Colour nodes. - func = """function func(args){ + sig = harmony.signature("set_color") + func = """function %s(args){ + for( var i =0; i <= args[0].length - 1; ++i) { var red_color = new ColorRGBA(255, 0, 0, 255); node.setColor(args[0][i], red_color); } } - func - """ + %s + """ % (sig, sig) outdated_nodes = [] for container in outdated_containers: if container["loader"] == "ImageSequenceLoader": @@ -149,7 +160,9 @@ def application_launch(): def export_template(backdrops, nodes, filepath): - func = """function func(args) + + sig = harmony.signature("set_color") + func = """function %s(args) { var temp_node = node.add("Top", "temp_note", "NOTE", 0, 0, 0); @@ -184,8 +197,8 @@ def export_template(backdrops, nodes, filepath): Action.perform("onActionUpToParent()", "Node View"); node.deleteNode(template_group, true, true); } - func - """ + %s + """ % (sig, sig) harmony.send({ "function": func, "args": [ @@ -226,12 +239,17 @@ def install(): def on_pyblish_instance_toggled(instance, old_value, new_value): """Toggle node enabling on instance toggles.""" - func = """function func(args) + + sig = harmony.signature("enable_node") + func = """function %s(args) { node.setEnable(args[0], args[1]) } - func - """ - harmony.send( - {"function": func, "args": [instance[0], new_value]} - ) + %s + """ % (sig, sig) + try: + harmony.send( + {"function": func, "args": [instance[0], new_value]} + ) + except IndexError: + print(f"Instance '{instance}' is missing node") diff --git a/pype/hosts/maya/menu.py b/pype/hosts/maya/menu.py index 70ad8d31ca..9dadd8d1f5 100644 --- a/pype/hosts/maya/menu.py +++ b/pype/hosts/maya/menu.py @@ -32,8 +32,19 @@ def deferred(): command=lambda *args: BuildWorkfile().process() ) + def add_look_assigner_item(): + import mayalookassigner + cmds.menuItem( + "Look assigner", + parent=pipeline._menu, + command=lambda *args: mayalookassigner.show() + ) + log.info("Attempting to install scripts menu..") + add_build_workfiles_item() + add_look_assigner_item() + try: import scriptsmenu.launchformaya as launchformaya import scriptsmenu.scriptsmenu as scriptsmenu @@ -42,7 +53,6 @@ def deferred(): "Skipping studio.menu install, because " "'scriptsmenu' module seems unavailable." ) - add_build_workfiles_item() return # load configuration of custom menu diff --git a/pype/hosts/maya/plugin.py b/pype/hosts/maya/plugin.py index ed244d56df..a5c57f1ab8 100644 --- a/pype/hosts/maya/plugin.py +++ b/pype/hosts/maya/plugin.py @@ -174,6 +174,25 @@ class ReferenceLoader(api.Loader): assert os.path.exists(path), "%s does not exist." % path + # Need to save alembic settings and reapply, cause referencing resets + # them to incoming data. + alembic_attrs = ["speed", "offset", "cycleType"] + alembic_data = {} + if representation["name"] == "abc": + alembic_nodes = cmds.ls( + "{}:*".format(members[0].split(":")[0]), type="AlembicNode" + ) + if alembic_nodes: + for attr in alembic_attrs: + node_attr = "{}.{}".format(alembic_nodes[0], attr) + alembic_data[attr] = cmds.getAttr(node_attr) + else: + cmds.warning( + "No alembic nodes found in {}".format( + cmds.ls("{}:*".format(members[0].split(":")[0])) + ) + ) + try: content = cmds.file(path, loadReference=reference_node, @@ -195,6 +214,16 @@ class ReferenceLoader(api.Loader): self.log.warning("Ignoring file read error:\n%s", exc) + # Reapply alembic settings. + if representation["name"] == "abc": + alembic_nodes = cmds.ls( + "{}:*".format(members[0].split(":")[0]), type="AlembicNode" + ) + if alembic_nodes: + for attr in alembic_attrs: + value = alembic_data[attr] + cmds.setAttr("{}.{}".format(alembic_nodes[0], attr), value) + # Fix PLN-40 for older containers created with Avalon that had the # `.verticesOnlySet` set to True. if cmds.getAttr("{}.verticesOnlySet".format(node)): diff --git a/pype/hosts/nuke/lib.py b/pype/hosts/nuke/lib.py index 8c0e37b15d..19a0784327 100644 --- a/pype/hosts/nuke/lib.py +++ b/pype/hosts/nuke/lib.py @@ -1,7 +1,6 @@ import os import re import sys -import getpass from collections import OrderedDict from avalon import api, io, lib @@ -1060,310 +1059,6 @@ def get_write_node_template_attr(node): return avalon.nuke.lib.fix_data_for_node_create(correct_data) -class BuildWorkfile(WorkfileSettings): - """ - Building first version of workfile. - - Settings are taken from presets and db. It will add all subsets - in last version for defined representaions - - Arguments: - variable (type): description - - """ - xpos = 0 - ypos = 0 - xpos_size = 80 - ypos_size = 90 - xpos_gap = 50 - ypos_gap = 50 - pos_layer = 10 - - def __init__(self, - root_path=None, - root_node=None, - nodes=None, - to_script=None, - **kwargs): - """ - A short description. - - A bit longer description. - - Argumetns: - root_path (str): description - root_node (nuke.Node): description - nodes (list): list of nuke.Node - nodes_effects (dict): dictionary with subsets - - Example: - nodes_effects = { - "plateMain": { - "nodes": [ - [("Class", "Reformat"), - ("resize", "distort"), - ("flip", True)], - - [("Class", "Grade"), - ("blackpoint", 0.5), - ("multiply", 0.4)] - ] - }, - } - - """ - - WorkfileSettings.__init__(self, - root_node=root_node, - nodes=nodes, - **kwargs) - self.to_script = to_script - # collect data for formating - self.data_tmp = { - "project": {"name": self._project["name"], - "code": self._project["data"].get("code", "")}, - "asset": self._asset or os.environ["AVALON_ASSET"], - "task": kwargs.get("task") or api.Session["AVALON_TASK"], - "hierarchy": kwargs.get("hierarchy") or pype.get_hierarchy(), - "version": kwargs.get("version", {}).get("name", 1), - "user": getpass.getuser(), - "comment": "firstBuild", - "ext": "nk" - } - - # get presets from anatomy - anatomy = get_anatomy() - # format anatomy - anatomy_filled = anatomy.format(self.data_tmp) - - # get dir and file for workfile - self.work_dir = anatomy_filled["work"]["folder"] - self.work_file = anatomy_filled["work"]["file"] - - def save_script_as(self, path=None): - # first clear anything in open window - nuke.scriptClear() - - if not path: - dir = self.work_dir - path = os.path.join( - self.work_dir, - self.work_file).replace("\\", "/") - else: - dir = os.path.dirname(path) - - # check if folder is created - if not os.path.exists(dir): - os.makedirs(dir) - - # save script to path - nuke.scriptSaveAs(path) - - def process(self, - regex_filter=None, - version=None, - representations=["exr", "dpx", "lutJson", "mov", - "preview", "png", "jpeg", "jpg"]): - """ - A short description. - - A bit longer description. - - Args: - regex_filter (raw string): regex pattern to filter out subsets - version (int): define a particular version, None gets last - representations (list): - - Returns: - type: description - - Raises: - Exception: description - - """ - - if not self.to_script: - # save the script - self.save_script_as() - - # create viewer and reset frame range - viewer = self.get_nodes(nodes_filter=["Viewer"]) - if not viewer: - vn = nuke.createNode("Viewer") - vn["xpos"].setValue(self.xpos) - vn["ypos"].setValue(self.ypos) - else: - vn = viewer[-1] - - # move position - self.position_up() - - wn = self.write_create() - wn["xpos"].setValue(self.xpos) - wn["ypos"].setValue(self.ypos) - wn["render"].setValue(True) - vn.setInput(0, wn) - - # adding backdrop under write - self.create_backdrop(label="Render write \n\n\n\nOUTPUT", - color='0xcc1102ff', layer=-1, - nodes=[wn]) - - # move position - self.position_up(4) - - # set frame range for new viewer - self.reset_frame_range_handles() - - # get all available representations - subsets = pype.get_subsets(self._asset, - regex_filter=regex_filter, - version=version, - representations=representations) - - for name, subset in subsets.items(): - log.debug("___________________") - log.debug(name) - log.debug(subset["version"]) - - nodes_backdrop = list() - for name, subset in subsets.items(): - if "lut" in name: - continue - log.info("Building Loader to: `{}`".format(name)) - version = subset["version"] - log.info("Version to: `{}`".format(version["name"])) - representations = subset["representaions"] - for repr in representations: - rn = self.read_loader(repr) - rn["xpos"].setValue(self.xpos) - rn["ypos"].setValue(self.ypos) - wn.setInput(0, rn) - - # get editional nodes - lut_subset = [s for n, s in subsets.items() - if "lut{}".format(name.lower()) in n.lower()] - log.debug(">> lut_subset: `{}`".format(lut_subset)) - - if len(lut_subset) > 0: - lsub = lut_subset[0] - fxn = self.effect_loader(lsub["representaions"][-1]) - fxn_ypos = fxn["ypos"].value() - fxn["ypos"].setValue(fxn_ypos - 100) - nodes_backdrop.append(fxn) - - nodes_backdrop.append(rn) - # move position - self.position_right() - - # adding backdrop under all read nodes - self.create_backdrop(label="Loaded Reads", - color='0x2d7702ff', layer=-1, - nodes=nodes_backdrop) - - def read_loader(self, representation): - """ - Gets Loader plugin for image sequence or mov - - Arguments: - representation (dict): avalon db entity - - """ - context = representation["context"] - - loader_name = "LoadSequence" - if "mov" in context["representation"]: - loader_name = "LoadMov" - - loader_plugin = None - for Loader in api.discover(api.Loader): - if Loader.__name__ != loader_name: - continue - - loader_plugin = Loader - - return api.load(Loader=loader_plugin, - representation=representation["_id"]) - - def effect_loader(self, representation): - """ - Gets Loader plugin for effects - - Arguments: - representation (dict): avalon db entity - - """ - loader_name = "LoadLuts" - - loader_plugin = None - for Loader in api.discover(api.Loader): - if Loader.__name__ != loader_name: - continue - - loader_plugin = Loader - - return api.load(Loader=loader_plugin, - representation=representation["_id"]) - - def write_create(self): - """ - Create render write - - Arguments: - representation (dict): avalon db entity - - """ - task = self.data_tmp["task"] - sanitized_task = re.sub('[^0-9a-zA-Z]+', '', task) - subset_name = "render{}Main".format( - sanitized_task.capitalize()) - - Create_name = "CreateWriteRender" - - creator_plugin = None - for Creator in api.discover(api.Creator): - if Creator.__name__ != Create_name: - continue - - creator_plugin = Creator - - # return api.create() - return creator_plugin(subset_name, self._asset).process() - - def create_backdrop(self, label="", color=None, layer=0, - nodes=None): - """ - Create Backdrop node - - Arguments: - color (str): nuke compatible string with color code - layer (int): layer of node usually used (self.pos_layer - 1) - label (str): the message - nodes (list): list of nodes to be wrapped into backdrop - - """ - assert isinstance(nodes, list), "`nodes` should be a list of nodes" - layer = self.pos_layer + layer - - create_backdrop(label=label, color=color, layer=layer, nodes=nodes) - - def position_reset(self, xpos=0, ypos=0): - self.xpos = xpos - self.ypos = ypos - - def position_right(self, multiply=1): - self.xpos += (self.xpos_size * multiply) + self.xpos_gap - - def position_left(self, multiply=1): - self.xpos -= (self.xpos_size * multiply) + self.xpos_gap - - def position_down(self, multiply=1): - self.ypos -= (self.ypos_size * multiply) + self.ypos_gap - - def position_up(self, multiply=1): - self.ypos -= (self.ypos_size * multiply) + self.ypos_gap - - class ExporterReview: """ Base class object for generating review data from Nuke diff --git a/pype/hosts/nuke/menu.py b/pype/hosts/nuke/menu.py index 7306add9fe..b1ef7f47c4 100644 --- a/pype/hosts/nuke/menu.py +++ b/pype/hosts/nuke/menu.py @@ -2,10 +2,12 @@ import nuke from avalon.api import Session from pype.hosts.nuke import lib +from ...lib import BuildWorkfile from pype.api import Logger log = Logger().get_logger(__name__, "nuke") + def install(): menubar = nuke.menu("Nuke") menu = menubar.findItem(Session["AVALON_LABEL"]) @@ -20,7 +22,11 @@ def install(): log.debug("Changing Item: {}".format(rm_item)) # rm_item[1].setEnabled(False) menu.removeItem(rm_item[1].name()) - menu.addCommand(new_name, lambda: workfile_settings().reset_resolution(), index=(rm_item[0])) + menu.addCommand( + new_name, + lambda: workfile_settings().reset_resolution(), + index=(rm_item[0]) + ) # replace reset frame range from avalon core to pype's name = "Reset Frame Range" @@ -31,33 +37,38 @@ def install(): log.debug("Changing Item: {}".format(rm_item)) # rm_item[1].setEnabled(False) menu.removeItem(rm_item[1].name()) - menu.addCommand(new_name, lambda: workfile_settings().reset_frame_range_handles(), index=(rm_item[0])) + menu.addCommand( + new_name, + lambda: workfile_settings().reset_frame_range_handles(), + index=(rm_item[0]) + ) # add colorspace menu item - name = "Set colorspace" + name = "Set Colorspace" menu.addCommand( name, lambda: workfile_settings().set_colorspace(), - index=(rm_item[0]+2) + index=(rm_item[0] + 2) ) log.debug("Adding menu item: {}".format(name)) # add workfile builder menu item - name = "Build First Workfile.." + name = "Build Workfile" menu.addCommand( - name, lambda: lib.BuildWorkfile().process(), - index=(rm_item[0]+7) + name, lambda: BuildWorkfile().process(), + index=(rm_item[0] + 7) ) log.debug("Adding menu item: {}".format(name)) # add item that applies all setting above - name = "Apply all settings" + name = "Apply All Settings" menu.addCommand( - name, lambda: workfile_settings().set_context_settings(), index=(rm_item[0]+3) + name, + lambda: workfile_settings().set_context_settings(), + index=(rm_item[0] + 3) ) log.debug("Adding menu item: {}".format(name)) - def uninstall(): menubar = nuke.menu("Nuke") diff --git a/pype/hosts/nukestudio/tags.py b/pype/hosts/nukestudio/tags.py index c2b1d0d728..c8af0cabc1 100644 --- a/pype/hosts/nukestudio/tags.py +++ b/pype/hosts/nukestudio/tags.py @@ -71,8 +71,8 @@ def add_tags_from_presets(): # Get project task types. tasks = io.find_one({"type": "project"})["config"]["tasks"] nks_pres_tags["[Tasks]"] = {} - for task in tasks: - nks_pres_tags["[Tasks]"][task["name"]] = { + for task_type in tasks.keys(): + nks_pres_tags["[Tasks]"][task_type] = { "editable": "1", "note": "", "icon": { diff --git a/pype/hosts/resolve/__init__.py b/pype/hosts/resolve/__init__.py index 72d6314b5e..c8f45259ff 100644 --- a/pype/hosts/resolve/__init__.py +++ b/pype/hosts/resolve/__init__.py @@ -1,17 +1,34 @@ +from .utils import ( + setup, + get_resolve_module +) + from .pipeline import ( install, uninstall, ls, containerise, publish, - launch_workfiles_app + launch_workfiles_app, + maintained_selection ) -from .utils import ( - setup, - get_resolve_module +from .lib import ( + get_project_manager, + get_current_project, + get_current_sequence, + get_current_track_items, + create_current_sequence_media_bin, + create_compound_clip, + swap_clips, + get_pype_clip_metadata, + set_project_manager_to_folder_name ) +from .menu import launch_pype_menu + +from .plugin import Creator + from .workio import ( open_file, save_file, @@ -21,12 +38,8 @@ from .workio import ( work_root ) -from .lib import ( - get_project_manager, - set_project_manager_to_folder_name -) - -from .menu import launch_pype_menu +bmdvr = None +bmdvf = None __all__ = [ # pipeline @@ -37,6 +50,7 @@ __all__ = [ "reload_pipeline", "publish", "launch_workfiles_app", + "maintained_selection", # utils "setup", @@ -44,16 +58,30 @@ __all__ = [ # lib "get_project_manager", + "get_current_project", + "get_current_sequence", + "get_current_track_items", + "create_current_sequence_media_bin", + "create_compound_clip", + "swap_clips", + "get_pype_clip_metadata", "set_project_manager_to_folder_name", # menu "launch_pype_menu", + # plugin + "Creator", + # workio "open_file", "save_file", "current_file", "has_unsaved_changes", "file_extensions", - "work_root" + "work_root", + + # singleton with black magic resolve module + "bmdvr", + "bmdvf" ] diff --git a/pype/hosts/resolve/action.py b/pype/hosts/resolve/action.py index 31830937c1..a9803cef4e 100644 --- a/pype/hosts/resolve/action.py +++ b/pype/hosts/resolve/action.py @@ -21,9 +21,9 @@ class SelectInvalidAction(pyblish.api.Action): def process(self, context, plugin): try: - from pype.hosts.resolve.utils import get_resolve_module - resolve = get_resolve_module() - self.log.debug(resolve) + from . import get_project_manager + pm = get_project_manager() + self.log.debug(pm) except ImportError: raise ImportError("Current host is not Resolve") diff --git a/pype/hosts/resolve/lib.py b/pype/hosts/resolve/lib.py index 2576136df5..deb4fa6339 100644 --- a/pype/hosts/resolve/lib.py +++ b/pype/hosts/resolve/lib.py @@ -1,20 +1,406 @@ import sys -from .utils import get_resolve_module -from pypeapp import Logger +import json +from opentimelineio import opentime +from pprint import pformat + +from pype.api import Logger log = Logger().get_logger(__name__, "resolve") self = sys.modules[__name__] self.pm = None +self.rename_index = 0 +self.rename_add = 0 +self.pype_metadata_key = "VFX Notes" def get_project_manager(): + from . import bmdvr if not self.pm: - resolve = get_resolve_module() - self.pm = resolve.GetProjectManager() + self.pm = bmdvr.GetProjectManager() return self.pm +def get_current_project(): + # initialize project manager + get_project_manager() + + return self.pm.GetCurrentProject() + + +def get_current_sequence(): + # get current project + project = get_current_project() + + return project.GetCurrentTimeline() + + +def get_current_track_items( + filter=False, + track_type=None, + selecting_color=None): + """ Gets all available current timeline track items + """ + track_type = track_type or "video" + selecting_color = selecting_color or "Chocolate" + project = get_current_project() + sequence = get_current_sequence() + selected_clips = list() + + # get all tracks count filtered by track type + selected_track_count = sequence.GetTrackCount(track_type) + + # loop all tracks and get items + _clips = dict() + for track_index in range(1, (int(selected_track_count) + 1)): + track_name = sequence.GetTrackName(track_type, track_index) + track_track_items = sequence.GetItemListInTrack( + track_type, track_index) + _clips[track_index] = track_track_items + + _data = { + "project": project, + "sequence": sequence, + "track": { + "name": track_name, + "index": track_index, + "type": track_type} + } + # get track item object and its color + for clip_index, ti in enumerate(_clips[track_index]): + data = _data.copy() + data["clip"] = { + "item": ti, + "index": clip_index + } + ti_color = ti.GetClipColor() + if filter is True: + if selecting_color in ti_color: + selected_clips.append(data) + # ti.ClearClipColor() + else: + selected_clips.append(data) + + return selected_clips + + +def create_current_sequence_media_bin(sequence): + seq_name = sequence.GetName() + media_pool = get_current_project().GetMediaPool() + root_folder = media_pool.GetRootFolder() + sub_folders = root_folder.GetSubFolderList() + testing_names = list() + + print(f"_ sub_folders: {sub_folders}") + for subfolder in sub_folders: + subf_name = subfolder.GetName() + if seq_name in subf_name: + testing_names.append(subfolder) + else: + testing_names.append(False) + + matching = next((f for f in testing_names if f is not False), None) + + if not matching: + new_folder = media_pool.AddSubFolder(root_folder, seq_name) + media_pool.SetCurrentFolder(new_folder) + else: + media_pool.SetCurrentFolder(matching) + + return media_pool.GetCurrentFolder() + + +def get_name_with_data(clip_data, presets): + """ + Take hierarchy data from presets and build name with parents data + + Args: + clip_data (dict): clip data from `get_current_track_items()` + presets (dict): data from create plugin + + Returns: + list: name, data + + """ + def _replace_hash_to_expression(name, text): + _spl = text.split("#") + _len = (len(_spl) - 1) + _repl = f"{{{name}:0>{_len}}}" + new_text = text.replace(("#" * _len), _repl) + return new_text + + # presets data + clip_name = presets["clipName"] + hierarchy = presets["hierarchy"] + hierarchy_data = presets["hierarchyData"].copy() + count_from = presets["countFrom"] + steps = presets["steps"] + + # reset rename_add + if self.rename_add < count_from: + self.rename_add = count_from + + # shot num calculate + if self.rename_index == 0: + shot_num = self.rename_add + else: + shot_num = self.rename_add + steps + + print(f"shot_num: {shot_num}") + + # clip data + _data = { + "sequence": clip_data["sequence"].GetName(), + "track": clip_data["track"]["name"].replace(" ", "_"), + "shot": shot_num + } + + # solve # in test to pythonic explression + for k, v in hierarchy_data.items(): + if "#" not in v: + continue + hierarchy_data[k] = _replace_hash_to_expression(k, v) + + # fill up pythonic expresisons + for k, v in hierarchy_data.items(): + hierarchy_data[k] = v.format(**_data) + + # fill up clip name and hierarchy keys + hierarchy = hierarchy.format(**hierarchy_data) + clip_name = clip_name.format(**hierarchy_data) + + self.rename_add = shot_num + print(f"shot_num: {shot_num}") + + return (clip_name, { + "hierarchy": hierarchy, + "hierarchyData": hierarchy_data + }) + + +def create_compound_clip(clip_data, folder, rename=False, **kwargs): + """ + Convert timeline object into nested timeline object + + Args: + clip_data (dict): timeline item object packed into dict + with project, timeline (sequence) + folder (resolve.MediaPool.Folder): media pool folder object, + rename (bool)[optional]: renaming in sequence or not + kwargs (optional): additional data needed for rename=True (presets) + + Returns: + resolve.MediaPoolItem: media pool item with compound clip timeline(cct) + """ + # get basic objects form data + project = clip_data["project"] + sequence = clip_data["sequence"] + clip = clip_data["clip"] + + # get details of objects + clip_item = clip["item"] + track = clip_data["track"] + + mp = project.GetMediaPool() + + # get clip attributes + clip_attributes = get_clip_attributes(clip_item) + print(f"_ clip_attributes: {pformat(clip_attributes)}") + + if rename: + presets = kwargs.get("presets") + if presets: + name, data = get_name_with_data(clip_data, presets) + # add hirarchy data to clip attributes + clip_attributes.update(data) + else: + name = "{:0>3}_{:0>4}".format( + int(track["index"]), int(clip["index"])) + else: + # build name + clip_name_split = clip_item.GetName().split(".") + name = "_".join([ + track["name"], + str(track["index"]), + clip_name_split[0], + str(clip["index"])] + ) + + # get metadata + mp_item = clip_item.GetMediaPoolItem() + mp_props = mp_item.GetClipProperty() + + mp_first_frame = int(mp_props["Start"]) + mp_last_frame = int(mp_props["End"]) + + # initialize basic source timing for otio + ci_l_offset = clip_item.GetLeftOffset() + ci_duration = clip_item.GetDuration() + rate = float(mp_props["FPS"]) + + # source rational times + mp_in_rc = opentime.RationalTime((ci_l_offset), rate) + mp_out_rc = opentime.RationalTime((ci_l_offset + ci_duration - 1), rate) + + # get frame in and out for clip swaping + in_frame = opentime.to_frames(mp_in_rc) + out_frame = opentime.to_frames(mp_out_rc) + + # keep original sequence + sq_origin = sequence + + # Set current folder to input media_pool_folder: + mp.SetCurrentFolder(folder) + + # check if clip doesnt exist already: + clips = folder.GetClipList() + cct = next((c for c in clips + if c.GetName() in name), None) + + if cct: + print(f"_ cct exists: {cct}") + else: + # Create empty timeline in current folder and give name: + cct = mp.CreateEmptyTimeline(name) + + # check if clip doesnt exist already: + clips = folder.GetClipList() + cct = next((c for c in clips + if c.GetName() in name), None) + print(f"_ cct created: {cct}") + + # Set current timeline to created timeline: + project.SetCurrentTimeline(cct) + + # Add input clip to the current timeline: + mp.AppendToTimeline([{ + "mediaPoolItem": mp_item, + "startFrame": mp_first_frame, + "endFrame": mp_last_frame + }]) + + # Set current timeline to the working timeline: + project.SetCurrentTimeline(sq_origin) + + # Add collected metadata and attributes to the comound clip: + if mp_item.GetMetadata(self.pype_metadata_key): + clip_attributes[self.pype_metadata_key] = mp_item.GetMetadata( + self.pype_metadata_key)[self.pype_metadata_key] + + # stringify + clip_attributes = json.dumps(clip_attributes) + + # add attributes to metadata + for k, v in mp_item.GetMetadata().items(): + cct.SetMetadata(k, v) + + # add metadata to cct + cct.SetMetadata(self.pype_metadata_key, clip_attributes) + + # reset start timecode of the compound clip + cct.SetClipProperty("Start TC", mp_props["Start TC"]) + + # swap clips on timeline + swap_clips(clip_item, cct, name, in_frame, out_frame) + + cct.SetClipColor("Pink") + return cct + + +def swap_clips(from_clip, to_clip, to_clip_name, to_in_frame, to_out_frame): + """ + Swaping clips on timeline in timelineItem + + It will add take and activate it to the frame range which is inputted + + Args: + from_clip (resolve.mediaPoolItem) + to_clip (resolve.mediaPoolItem) + to_clip_name (str): name of to_clip + to_in_frame (float): cut in frame, usually `GetLeftOffset()` + to_out_frame (float): cut out frame, usually left offset plus duration + + Returns: + bool: True if successfully replaced + + """ + # add clip item as take to timeline + take = from_clip.AddTake( + to_clip, + float(to_in_frame), + float(to_out_frame) + ) + + if not take: + return False + + for take_index in range(1, (int(from_clip.GetTakesCount()) + 1)): + take_item = from_clip.GetTakeByIndex(take_index) + take_mp_item = take_item["mediaPoolItem"] + if to_clip_name in take_mp_item.GetName(): + from_clip.SelectTakeByIndex(take_index) + from_clip.FinalizeTake() + return True + return False + + +def validate_tc(x): + # Validate and reformat timecode string + + if len(x) != 11: + print('Invalid timecode. Try again.') + + c = ':' + colonized = x[:2] + c + x[3:5] + c + x[6:8] + c + x[9:] + + if colonized.replace(':', '').isdigit(): + print(f"_ colonized: {colonized}") + return colonized + else: + print('Invalid timecode. Try again.') + + +def get_pype_clip_metadata(clip): + """ + Get pype metadata created by creator plugin + + Attributes: + clip (resolve.TimelineItem): resolve's object + + Returns: + dict: hierarchy, orig clip attributes + """ + mp_item = clip.GetMediaPoolItem() + metadata = mp_item.GetMetadata() + + return metadata.get(self.pype_metadata_key) + + +def get_clip_attributes(clip): + """ + Collect basic atrributes from resolve timeline item + + Args: + clip (resolve.TimelineItem): timeline item object + + Returns: + dict: all collected attributres as key: values + """ + mp_item = clip.GetMediaPoolItem() + + data = { + "clipIn": clip.GetStart(), + "clipOut": clip.GetEnd(), + "clipLeftOffset": clip.GetLeftOffset(), + "clipRightOffset": clip.GetRightOffset(), + "clipMarkers": clip.GetMarkers(), + "clipFlags": clip.GetFlagList(), + "sourceId": mp_item.GetMediaId(), + "sourceProperties": mp_item.GetClipProperty() + } + return data + + def set_project_manager_to_folder_name(folder_name): """ Sets context of Project manager to given folder by name. diff --git a/pype/hosts/resolve/menu_style.qss b/pype/hosts/resolve/menu_style.qss index df4fd7e949..ea11c4ca2e 100644 --- a/pype/hosts/resolve/menu_style.qss +++ b/pype/hosts/resolve/menu_style.qss @@ -1,6 +1,7 @@ QWidget { background-color: #282828; border-radius: 3; + font-size: 13px; } QPushButton { @@ -20,10 +21,38 @@ QPushButton:hover { color: #e64b3d; } +QSpinBox { + border: 1px solid #090909; + background-color: #201f1f; + color: #ffffff; + padding: 2; + max-width: 8em; + qproperty-alignment: AlignCenter; +} + +QLineEdit { + border: 1px solid #090909; + border-radius: 3px; + background-color: #201f1f; + color: #ffffff; + padding: 2; + min-width: 10em; + qproperty-alignment: AlignCenter; +} + #PypeMenu { border: 1px solid #fef9ef; } -#Spacer { +QVBoxLayout { background-color: #282828; } + +#Devider { + border: 1px solid #090909; + background-color: #585858; +} + +QLabel { + color: #77776b; +} diff --git a/pype/hosts/resolve/pipeline.py b/pype/hosts/resolve/pipeline.py index 967aed1436..92bef2e13b 100644 --- a/pype/hosts/resolve/pipeline.py +++ b/pype/hosts/resolve/pipeline.py @@ -2,27 +2,23 @@ Basic avalon integration """ import os -# import sys +import contextlib from avalon.tools import workfiles from avalon import api as avalon from pyblish import api as pyblish -from pypeapp import Logger +import pype +from pype.api import Logger log = Logger().get_logger(__name__, "resolve") -# self = sys.modules[__name__] - AVALON_CONFIG = os.environ["AVALON_CONFIG"] -PARENT_DIR = os.path.dirname(__file__) -PACKAGE_DIR = os.path.dirname(PARENT_DIR) -PLUGINS_DIR = os.path.join(PACKAGE_DIR, "plugins") -LOAD_PATH = os.path.join(PLUGINS_DIR, "resolve", "load") -CREATE_PATH = os.path.join(PLUGINS_DIR, "resolve", "create") -INVENTORY_PATH = os.path.join(PLUGINS_DIR, "resolve", "inventory") +LOAD_PATH = os.path.join(pype.PLUGINS_DIR, "resolve", "load") +CREATE_PATH = os.path.join(pype.PLUGINS_DIR, "resolve", "create") +INVENTORY_PATH = os.path.join(pype.PLUGINS_DIR, "resolve", "inventory") PUBLISH_PATH = os.path.join( - PLUGINS_DIR, "resolve", "publish" + pype.PLUGINS_DIR, "resolve", "publish" ).replace("\\", "/") AVALON_CONTAINERS = ":AVALON_CONTAINERS" @@ -40,11 +36,13 @@ def install(): See the Maya equivalent for inspiration on how to implement this. """ + from . import get_resolve_module # Disable all families except for the ones we explicitly want to see family_states = [ "imagesequence", - "mov" + "mov", + "clip" ] avalon.data["familiesStateDefault"] = False avalon.data["familiesStateToggled"] = family_states @@ -59,6 +57,8 @@ def install(): avalon.register_plugin_path(avalon.Creator, CREATE_PATH) avalon.register_plugin_path(avalon.InventoryAction, INVENTORY_PATH) + get_resolve_module() + def uninstall(): """Uninstall all tha was installed @@ -140,3 +140,26 @@ def publish(parent): """Shorthand to publish from within host""" from avalon.tools import publish return publish.show(parent) + + +@contextlib.contextmanager +def maintained_selection(): + """Maintain selection during context + + Example: + >>> with maintained_selection(): + ... node['selected'].setValue(True) + >>> print(node['selected'].value()) + False + """ + try: + # do the operation + yield + finally: + pass + + +def reset_selection(): + """Deselect all selected nodes + """ + pass diff --git a/pype/hosts/resolve/plugin.py b/pype/hosts/resolve/plugin.py index 628d4bdb26..72eec04896 100644 --- a/pype/hosts/resolve/plugin.py +++ b/pype/hosts/resolve/plugin.py @@ -1,6 +1,182 @@ +import re from avalon import api -# from pype.hosts.resolve import lib as drlib +from pype.hosts import resolve from avalon.vendor import qargparse +from pype.api import config + +from Qt import QtWidgets, QtCore + + +class CreatorWidget(QtWidgets.QDialog): + + # output items + items = dict() + + def __init__(self, name, info, presets, parent=None): + super(CreatorWidget, self).__init__(parent) + + self.setObjectName(name) + + self.setWindowFlags( + QtCore.Qt.Window + | QtCore.Qt.CustomizeWindowHint + | QtCore.Qt.WindowTitleHint + | QtCore.Qt.WindowCloseButtonHint + | QtCore.Qt.WindowStaysOnTopHint + ) + self.setWindowTitle(name or "Pype Creator Input") + + # Where inputs and labels are set + self.content_widget = [QtWidgets.QWidget(self)] + top_layout = QtWidgets.QFormLayout(self.content_widget[0]) + top_layout.setObjectName("ContentLayout") + top_layout.addWidget(Spacer(5, self)) + + # first add widget tag line + top_layout.addWidget(QtWidgets.QLabel(info)) + + top_layout.addWidget(Spacer(5, self)) + + # main dynamic layout + self.content_widget.append(QtWidgets.QWidget(self)) + content_layout = QtWidgets.QFormLayout(self.content_widget[-1]) + + # add preset data into input widget layout + self.items = self.add_presets_to_layout(content_layout, presets) + + # Confirmation buttons + btns_widget = QtWidgets.QWidget(self) + btns_layout = QtWidgets.QHBoxLayout(btns_widget) + + cancel_btn = QtWidgets.QPushButton("Cancel") + btns_layout.addWidget(cancel_btn) + + ok_btn = QtWidgets.QPushButton("Ok") + btns_layout.addWidget(ok_btn) + + # Main layout of the dialog + main_layout = QtWidgets.QVBoxLayout(self) + main_layout.setContentsMargins(10, 10, 10, 10) + main_layout.setSpacing(0) + + # adding content widget + for w in self.content_widget: + main_layout.addWidget(w) + + main_layout.addWidget(btns_widget) + + ok_btn.clicked.connect(self._on_ok_clicked) + cancel_btn.clicked.connect(self._on_cancel_clicked) + + stylesheet = resolve.menu.load_stylesheet() + self.setStyleSheet(stylesheet) + + def _on_ok_clicked(self): + self.result = self.value(self.items) + self.close() + + def _on_cancel_clicked(self): + self.result = None + self.close() + + def value(self, data): + for k, v in data.items(): + if isinstance(v, dict): + print(f"nested: {k}") + data[k] = self.value(v) + elif getattr(v, "value", None): + print(f"normal int: {k}") + result = v.value() + data[k] = result() + else: + print(f"normal text: {k}") + result = v.text() + data[k] = result() + return data + + def camel_case_split(self, text): + matches = re.finditer( + '.+?(?:(?<=[a-z])(?=[A-Z])|(?<=[A-Z])(?=[A-Z][a-z])|$)', text) + return " ".join([str(m.group(0)).capitalize() for m in matches]) + + def create_row(self, layout, type, text, **kwargs): + # get type attribute from qwidgets + attr = getattr(QtWidgets, type) + + # convert label text to normal capitalized text with spaces + label_text = self.camel_case_split(text) + + # assign the new text to lable widget + label = QtWidgets.QLabel(label_text) + label.setObjectName("LineLabel") + + # create attribute name text strip of spaces + attr_name = text.replace(" ", "") + + # create attribute and assign default values + setattr( + self, + attr_name, + attr(parent=self)) + + # assign the created attribute to variable + item = getattr(self, attr_name) + for func, val in kwargs.items(): + if getattr(item, func): + func_attr = getattr(item, func) + func_attr(val) + + # add to layout + layout.addRow(label, item) + + return item + + def add_presets_to_layout(self, content_layout, data): + for k, v in data.items(): + if isinstance(v, dict): + # adding spacer between sections + self.content_widget.append(QtWidgets.QWidget(self)) + devider = QtWidgets.QVBoxLayout(self.content_widget[-1]) + devider.addWidget(Spacer(5, self)) + devider.setObjectName("Devider") + + # adding nested layout with label + self.content_widget.append(QtWidgets.QWidget(self)) + nested_content_layout = QtWidgets.QFormLayout( + self.content_widget[-1]) + nested_content_layout.setObjectName("NestedContentLayout") + + # add nested key as label + self.create_row(nested_content_layout, "QLabel", k) + data[k] = self.add_presets_to_layout(nested_content_layout, v) + elif isinstance(v, str): + print(f"layout.str: {k}") + print(f"content_layout: {content_layout}") + data[k] = self.create_row( + content_layout, "QLineEdit", k, setText=v) + elif isinstance(v, int): + print(f"layout.int: {k}") + print(f"content_layout: {content_layout}") + data[k] = self.create_row( + content_layout, "QSpinBox", k, setValue=v) + return data + + +class Spacer(QtWidgets.QWidget): + def __init__(self, height, *args, **kwargs): + super(self.__class__, self).__init__(*args, **kwargs) + + self.setFixedHeight(height) + + real_spacer = QtWidgets.QWidget(self) + real_spacer.setObjectName("Spacer") + real_spacer.setFixedHeight(height) + + layout = QtWidgets.QVBoxLayout(self) + layout.setContentsMargins(0, 0, 0, 0) + layout.addWidget(real_spacer) + + self.setLayout(layout) def get_reference_node_parents(ref): @@ -73,3 +249,25 @@ class SequenceLoader(api.Loader): """Remove an existing `container` """ pass + + +class Creator(api.Creator): + """Creator class wrapper + """ + marker_color = "Purple" + + def __init__(self, *args, **kwargs): + super(Creator, self).__init__(*args, **kwargs) + self.presets = config.get_presets()['plugins']["resolve"][ + "create"].get(self.__class__.__name__, {}) + + # adding basic current context resolve objects + self.project = resolve.get_current_project() + self.sequence = resolve.get_current_sequence() + + if (self.options or {}).get("useSelection"): + self.selected = resolve.get_current_track_items(filter=True) + else: + self.selected = resolve.get_current_track_items(filter=False) + + self.widget = CreatorWidget diff --git a/pype/hosts/resolve/preload_console.py b/pype/hosts/resolve/preload_console.py index ea1bd4f180..58975777b8 100644 --- a/pype/hosts/resolve/preload_console.py +++ b/pype/hosts/resolve/preload_console.py @@ -1,7 +1,7 @@ #!/usr/bin/env python import time from pype.hosts.resolve.utils import get_resolve_module -from pypeapp import Logger +from pype.api import Logger log = Logger().get_logger(__name__, "resolve") diff --git a/pype/hosts/resolve/utility_scripts/Pype_menu.py b/pype/hosts/resolve/utility_scripts/Pype_menu.py index 1f5cd36277..230a7a80f0 100644 --- a/pype/hosts/resolve/utility_scripts/Pype_menu.py +++ b/pype/hosts/resolve/utility_scripts/Pype_menu.py @@ -3,7 +3,7 @@ import sys import avalon.api as avalon import pype -from pypeapp import Logger +from pype.api import Logger log = Logger().get_logger(__name__) diff --git a/pype/hosts/resolve/utility_scripts/__dev_compound_clip.py b/pype/hosts/resolve/utility_scripts/__dev_compound_clip.py deleted file mode 100644 index fe47008c70..0000000000 --- a/pype/hosts/resolve/utility_scripts/__dev_compound_clip.py +++ /dev/null @@ -1,65 +0,0 @@ -#! python3 -# -*- coding: utf-8 -*- - - -# convert clip def -def convert_clip(timeline=None): - """Convert timeline item (clip) into compound clip pype container - - Args: - timeline (MediaPool.Timeline): Object of timeline - - Returns: - bool: `True` if success - - Raises: - Exception: description - - """ - pass - - -# decorator function create_current_timeline_media_bin() -def create_current_timeline_media_bin(timeline=None): - """Convert timeline item (clip) into compound clip pype container - - Args: - timeline (MediaPool.Timeline): Object of timeline - - Returns: - bool: `True` if success - - Raises: - Exception: description - - """ - pass - - -# decorator function get_selected_track_items() -def get_selected_track_items(): - """Convert timeline item (clip) into compound clip pype container - - Args: - timeline (MediaPool.Timeline): Object of timeline - - Returns: - bool: `True` if success - - Raises: - Exception: description - - """ - print("testText") - - -# PypeCompoundClip() class -class PypeCompoundClip(object): - """docstring for .""" - - def __init__(self, arg): - super(self).__init__() - self.arg = arg - - def create_compound_clip(self): - pass diff --git a/pype/hosts/resolve/utility_scripts/__test_pyblish.py b/pype/hosts/resolve/utility_scripts/__test_pyblish.py deleted file mode 100644 index a6fe991025..0000000000 --- a/pype/hosts/resolve/utility_scripts/__test_pyblish.py +++ /dev/null @@ -1,57 +0,0 @@ -import os -import sys -import pype -import importlib -import pyblish.api -import pyblish.util -import avalon.api -from avalon.tools import publish -from pypeapp import Logger - -log = Logger().get_logger(__name__) - - -def main(env): - # Registers pype's Global pyblish plugins - pype.install() - - # Register Host (and it's pyblish plugins) - host_name = env["AVALON_APP"] - # TODO not sure if use "pype." or "avalon." for host import - host_import_str = f"pype.{host_name}" - - try: - host_module = importlib.import_module(host_import_str) - except ModuleNotFoundError: - log.error(( - f"Host \"{host_name}\" can't be imported." - f" Import string \"{host_import_str}\" failed." - )) - return False - - avalon.api.install(host_module) - - # Register additional paths - addition_paths_str = env.get("PUBLISH_PATHS") or "" - addition_paths = addition_paths_str.split(os.pathsep) - for path in addition_paths: - path = os.path.normpath(path) - if not os.path.exists(path): - continue - - pyblish.api.register_plugin_path(path) - - # Register project specific plugins - project_name = os.environ["AVALON_PROJECT"] - project_plugins_paths = env.get("PYPE_PROJECT_PLUGINS") or "" - for path in project_plugins_paths.split(os.pathsep): - plugin_path = os.path.join(path, project_name, "plugins") - if os.path.exists(plugin_path): - pyblish.api.register_plugin_path(plugin_path) - - return publish.show() - - -if __name__ == "__main__": - result = main(os.environ) - sys.exit(not bool(result)) diff --git a/pype/hosts/resolve/utility_scripts/__test_subprocess.py b/pype/hosts/resolve/utility_scripts/__test_subprocess.py deleted file mode 100644 index bdc57bbf00..0000000000 --- a/pype/hosts/resolve/utility_scripts/__test_subprocess.py +++ /dev/null @@ -1,35 +0,0 @@ -#! python3 -# -*- coding: utf-8 -*- -import os -from pypeapp import execute, Logger -from pype.hosts.resolve.utils import get_resolve_module - -log = Logger().get_logger("Resolve") - -CURRENT_DIR = os.getenv("RESOLVE_UTILITY_SCRIPTS_DIR", "") -python_dir = os.getenv("PYTHON36_RESOLVE") -python_exe = os.path.normpath( - os.path.join(python_dir, "python.exe") -) - -resolve = get_resolve_module() -PM = resolve.GetProjectManager() -P = PM.GetCurrentProject() - -log.info(P.GetName()) - - -# ______________________________________________________ -# testing subprocessing Scripts -testing_py = os.path.join(CURRENT_DIR, "ResolvePageSwitcher.py") -testing_py = os.path.normpath(testing_py) -log.info(f"Testing path to script: `{testing_py}`") - -returncode = execute( - [python_exe, os.path.normpath(testing_py)], - env=dict(os.environ) -) - -# Check if output file exists -if returncode != 0: - log.error("Executing failed!") diff --git a/pype/hosts/resolve/utility_scripts/test.py b/pype/hosts/resolve/utility_scripts/test.py new file mode 100644 index 0000000000..69dc4768bd --- /dev/null +++ b/pype/hosts/resolve/utility_scripts/test.py @@ -0,0 +1,21 @@ +#! python3 +import sys +from pype.api import Logger +import DaVinciResolveScript as bmdvr + + +log = Logger().get_logger(__name__) + + +def main(): + import pype.hosts.resolve as bmdvr + bm = bmdvr.utils.get_resolve_module() + log.info(f"blackmagicmodule: {bm}") + + +print(f"_>> bmdvr.scriptapp(Resolve): {bmdvr.scriptapp('Resolve')}") + + +if __name__ == "__main__": + result = main() + sys.exit(not bool(result)) diff --git a/pype/hosts/resolve/utils.py b/pype/hosts/resolve/utils.py index f5add53a6b..e11cc64b3b 100644 --- a/pype/hosts/resolve/utils.py +++ b/pype/hosts/resolve/utils.py @@ -9,18 +9,16 @@ import os import shutil from pypeapp import Logger - log = Logger().get_logger(__name__, "resolve") -self = sys.modules[__name__] -self.bmd = None - def get_resolve_module(): + from pype.hosts import resolve # dont run if already loaded - if self.bmd: - return self.bmd - + if resolve.bmdvr: + log.info(("resolve module is assigned to " + f"`pype.hosts.resolve.bmdvr`: {resolve.bmdvr}")) + return resolve.bmdvr try: """ The PYTHONPATH needs to be set correctly for this import @@ -71,8 +69,14 @@ def get_resolve_module(): ) sys.exit() # assign global var and return - self.bmd = bmd.scriptapp("Resolve") - return self.bmd + bmdvr = bmd.scriptapp("Resolve") + # bmdvf = bmd.scriptapp("Fusion") + resolve.bmdvr = bmdvr + resolve.bmdvf = bmdvr.Fusion() + log.info(("Assigning resolve module to " + f"`pype.hosts.resolve.bmdvr`: {resolve.bmdvr}")) + log.info(("Assigning resolve module to " + f"`pype.hosts.resolve.bmdvf`: {resolve.bmdvf}")) def _sync_utility_scripts(env=None): diff --git a/pype/hosts/resolve/workio.py b/pype/hosts/resolve/workio.py index e1e30a8734..9d8d320a3c 100644 --- a/pype/hosts/resolve/workio.py +++ b/pype/hosts/resolve/workio.py @@ -2,8 +2,9 @@ import os from pypeapp import Logger -from .lib import ( +from . import ( get_project_manager, + get_current_project, set_project_manager_to_folder_name ) @@ -26,7 +27,7 @@ def save_file(filepath): pm = get_project_manager() file = os.path.basename(filepath) fname, _ = os.path.splitext(file) - project = pm.GetCurrentProject() + project = get_current_project() name = project.GetName() if "Untitled Project" not in name: diff --git a/pype/lib.py b/pype/lib.py index 7cf4e2f1a5..6fa204b379 100644 --- a/pype/lib.py +++ b/pype/lib.py @@ -19,7 +19,7 @@ from abc import ABCMeta, abstractmethod from avalon import io, pipeline import six import avalon.api -from .api import config, Anatomy +from .api import config, Anatomy, Logger log = logging.getLogger(__name__) @@ -746,8 +746,9 @@ class PypeHook: def get_linked_assets(asset_entity): """Return linked assets for `asset_entity`.""" - # TODO implement - return [] + inputs = asset_entity["data"].get("inputs", []) + inputs = [io.find_one({"_id": x}) for x in inputs] + return inputs def map_subsets_by_family(subsets): @@ -1621,7 +1622,7 @@ class ApplicationAction(avalon.api.Action): parsed application `.toml` this can launch the application. """ - + _log = None config = None group = None variant = None @@ -1631,6 +1632,12 @@ class ApplicationAction(avalon.api.Action): "AVALON_TASK" ) + @property + def log(self): + if self._log is None: + self._log = Logger().get_logger(self.__class__.__name__) + return self._log + def is_compatible(self, session): for key in self.required_session_keys: if key not in session: @@ -1643,6 +1650,165 @@ class ApplicationAction(avalon.api.Action): project_name = session["AVALON_PROJECT"] asset_name = session["AVALON_ASSET"] task_name = session["AVALON_TASK"] - return launch_application( + launch_application( project_name, asset_name, task_name, self.name ) + + self._ftrack_after_launch_procedure( + project_name, asset_name, task_name + ) + + def _ftrack_after_launch_procedure( + self, project_name, asset_name, task_name + ): + # TODO move to launch hook + required_keys = ("FTRACK_SERVER", "FTRACK_API_USER", "FTRACK_API_KEY") + for key in required_keys: + if not os.environ.get(key): + self.log.debug(( + "Missing required environment \"{}\"" + " for Ftrack after launch procedure." + ).format(key)) + return + + try: + import ftrack_api + session = ftrack_api.Session(auto_connect_event_hub=True) + self.log.debug("Ftrack session created") + except Exception: + self.log.warning("Couldn't create Ftrack session") + return + + try: + entity = self._find_ftrack_task_entity( + session, project_name, asset_name, task_name + ) + self._ftrack_status_change(session, entity, project_name) + self._start_timer(session, entity, ftrack_api) + except Exception: + self.log.warning( + "Couldn't finish Ftrack procedure.", exc_info=True + ) + return + + finally: + session.close() + + def _find_ftrack_task_entity( + self, session, project_name, asset_name, task_name + ): + project_entity = session.query( + "Project where full_name is \"{}\"".format(project_name) + ).first() + if not project_entity: + self.log.warning( + "Couldn't find project \"{}\" in Ftrack.".format(project_name) + ) + return + + potential_task_entities = session.query(( + "TypedContext where parent.name is \"{}\" and project_id is \"{}\"" + ).format(asset_name, project_entity["id"])).all() + filtered_entities = [] + for _entity in potential_task_entities: + if ( + _entity.entity_type.lower() == "task" + and _entity["name"] == task_name + ): + filtered_entities.append(_entity) + + if not filtered_entities: + self.log.warning(( + "Couldn't find task \"{}\" under parent \"{}\" in Ftrack." + ).format(task_name, asset_name)) + return + + if len(filtered_entities) > 1: + self.log.warning(( + "Found more than one task \"{}\"" + " under parent \"{}\" in Ftrack." + ).format(task_name, asset_name)) + return + + return filtered_entities[0] + + def _ftrack_status_change(self, session, entity, project_name): + presets = config.get_presets(project_name)["ftrack"]["ftrack_config"] + statuses = presets.get("status_update") + if not statuses: + return + + actual_status = entity["status"]["name"].lower() + already_tested = set() + ent_path = "/".join( + [ent["name"] for ent in entity["link"]] + ) + while True: + next_status_name = None + for key, value in statuses.items(): + if key in already_tested: + continue + if actual_status in value or "_any_" in value: + if key != "_ignore_": + next_status_name = key + already_tested.add(key) + break + already_tested.add(key) + + if next_status_name is None: + break + + try: + query = "Status where name is \"{}\"".format( + next_status_name + ) + status = session.query(query).one() + + entity["status"] = status + session.commit() + self.log.debug("Changing status to \"{}\" <{}>".format( + next_status_name, ent_path + )) + break + + except Exception: + session.rollback() + msg = ( + "Status \"{}\" in presets wasn't found" + " on Ftrack entity type \"{}\"" + ).format(next_status_name, entity.entity_type) + self.log.warning(msg) + + def _start_timer(self, session, entity, _ftrack_api): + self.log.debug("Triggering timer start.") + + user_entity = session.query("User where username is \"{}\"".format( + os.environ["FTRACK_API_USER"] + )).first() + if not user_entity: + self.log.warning( + "Couldn't find user with username \"{}\" in Ftrack".format( + os.environ["FTRACK_API_USER"] + ) + ) + return + + source = { + "user": { + "id": user_entity["id"], + "username": user_entity["username"] + } + } + event_data = { + "actionIdentifier": "start.timer", + "selection": [{"entityId": entity["id"], "entityType": "task"}] + } + session.event_hub.publish( + _ftrack_api.event.base.Event( + topic="ftrack.action.launch", + data=event_data, + source=source + ), + on_error="ignore" + ) + self.log.debug("Timer start triggered successfully.") diff --git a/pype/modules/adobe_communicator/lib/__init__.py b/pype/modules/adobe_communicator/lib/__init__.py index 23aee81275..f918e49a60 100644 --- a/pype/modules/adobe_communicator/lib/__init__.py +++ b/pype/modules/adobe_communicator/lib/__init__.py @@ -1,8 +1,6 @@ -from .io_nonsingleton import DbConnector from .rest_api import AdobeRestApi, PUBLISH_PATHS __all__ = [ "PUBLISH_PATHS", - "DbConnector", "AdobeRestApi" ] diff --git a/pype/modules/adobe_communicator/lib/io_nonsingleton.py b/pype/modules/adobe_communicator/lib/io_nonsingleton.py deleted file mode 100644 index da37c657c6..0000000000 --- a/pype/modules/adobe_communicator/lib/io_nonsingleton.py +++ /dev/null @@ -1,460 +0,0 @@ -""" -Wrapper around interactions with the database - -Copy of io module in avalon-core. - - In this case not working as singleton with api.Session! -""" - -import os -import time -import errno -import shutil -import logging -import tempfile -import functools -import contextlib - -from avalon import schema -from avalon.vendor import requests -from avalon.io import extract_port_from_url - -# Third-party dependencies -import pymongo - - -def auto_reconnect(func): - """Handling auto reconnect in 3 retry times""" - @functools.wraps(func) - def decorated(*args, **kwargs): - object = args[0] - for retry in range(3): - try: - return func(*args, **kwargs) - except pymongo.errors.AutoReconnect: - object.log.error("Reconnecting..") - time.sleep(0.1) - else: - raise - - return decorated - - -class DbConnector(object): - - log = logging.getLogger(__name__) - - def __init__(self): - self.Session = {} - self._mongo_client = None - self._sentry_client = None - self._sentry_logging_handler = None - self._database = None - self._is_installed = False - - def __getitem__(self, key): - # gives direct access to collection withou setting `active_table` - return self._database[key] - - def __getattribute__(self, attr): - # not all methods of PyMongo database are implemented with this it is - # possible to use them too - try: - return super(DbConnector, self).__getattribute__(attr) - except AttributeError: - cur_proj = self.Session["AVALON_PROJECT"] - return self._database[cur_proj].__getattribute__(attr) - - def install(self): - """Establish a persistent connection to the database""" - if self._is_installed: - return - - logging.basicConfig() - self.Session.update(self._from_environment()) - - timeout = int(self.Session["AVALON_TIMEOUT"]) - mongo_url = self.Session["AVALON_MONGO"] - kwargs = { - "host": mongo_url, - "serverSelectionTimeoutMS": timeout - } - - port = extract_port_from_url(mongo_url) - if port is not None: - kwargs["port"] = int(port) - - self._mongo_client = pymongo.MongoClient(**kwargs) - - for retry in range(3): - try: - t1 = time.time() - self._mongo_client.server_info() - - except Exception: - self.log.error("Retrying..") - time.sleep(1) - timeout *= 1.5 - - else: - break - - else: - raise IOError( - "ERROR: Couldn't connect to %s in " - "less than %.3f ms" % (self.Session["AVALON_MONGO"], timeout)) - - self.log.info("Connected to %s, delay %.3f s" % ( - self.Session["AVALON_MONGO"], time.time() - t1)) - - self._install_sentry() - - self._database = self._mongo_client[self.Session["AVALON_DB"]] - self._is_installed = True - - def _install_sentry(self): - if "AVALON_SENTRY" not in self.Session: - return - - try: - from raven import Client - from raven.handlers.logging import SentryHandler - from raven.conf import setup_logging - except ImportError: - # Note: There was a Sentry address in this Session - return self.log.warning("Sentry disabled, raven not installed") - - client = Client(self.Session["AVALON_SENTRY"]) - - # Transmit log messages to Sentry - handler = SentryHandler(client) - handler.setLevel(logging.WARNING) - - setup_logging(handler) - - self._sentry_client = client - self._sentry_logging_handler = handler - self.log.info( - "Connected to Sentry @ %s" % self.Session["AVALON_SENTRY"] - ) - - def _from_environment(self): - Session = { - item[0]: os.getenv(item[0], item[1]) - for item in ( - # Root directory of projects on disk - ("AVALON_PROJECTS", None), - - # Name of current Project - ("AVALON_PROJECT", ""), - - # Name of current Asset - ("AVALON_ASSET", ""), - - # Name of current silo - ("AVALON_SILO", ""), - - # Name of current task - ("AVALON_TASK", None), - - # Name of current app - ("AVALON_APP", None), - - # Path to working directory - ("AVALON_WORKDIR", None), - - # Name of current Config - # TODO(marcus): Establish a suitable default config - ("AVALON_CONFIG", "no_config"), - - # Name of Avalon in graphical user interfaces - # Use this to customise the visual appearance of Avalon - # to better integrate with your surrounding pipeline - ("AVALON_LABEL", "Avalon"), - - # Used during any connections to the outside world - ("AVALON_TIMEOUT", "1000"), - - # Address to Asset Database - ("AVALON_MONGO", "mongodb://localhost:27017"), - - # Name of database used in MongoDB - ("AVALON_DB", "avalon"), - - # Address to Sentry - ("AVALON_SENTRY", None), - - # Address to Deadline Web Service - # E.g. http://192.167.0.1:8082 - ("AVALON_DEADLINE", None), - - # Enable features not necessarily stable. The user's own risk - ("AVALON_EARLY_ADOPTER", None), - - # Address of central asset repository, contains - # the following interface: - # /upload - # /download - # /manager (optional) - ("AVALON_LOCATION", "http://127.0.0.1"), - - # Boolean of whether to upload published material - # to central asset repository - ("AVALON_UPLOAD", None), - - # Generic username and password - ("AVALON_USERNAME", "avalon"), - ("AVALON_PASSWORD", "secret"), - - # Unique identifier for instances in working files - ("AVALON_INSTANCE_ID", "avalon.instance"), - ("AVALON_CONTAINER_ID", "avalon.container"), - - # Enable debugging - ("AVALON_DEBUG", None), - - ) if os.getenv(item[0], item[1]) is not None - } - - Session["schema"] = "avalon-core:session-2.0" - try: - schema.validate(Session) - except schema.ValidationError as e: - # TODO(marcus): Make this mandatory - self.log.warning(e) - - return Session - - def uninstall(self): - """Close any connection to the database""" - try: - self._mongo_client.close() - except AttributeError: - pass - - self._mongo_client = None - self._database = None - self._is_installed = False - - def active_project(self): - """Return the name of the active project""" - return self.Session["AVALON_PROJECT"] - - def activate_project(self, project_name): - self.Session["AVALON_PROJECT"] = project_name - - def projects(self): - """List available projects - - Returns: - list of project documents - - """ - - collection_names = self.collections() - for project in collection_names: - if project in ("system.indexes",): - continue - - # Each collection will have exactly one project document - document = self.find_project(project) - - if document is not None: - yield document - - def locate(self, path): - """Traverse a hierarchy from top-to-bottom - - Example: - representation = locate(["hulk", "Bruce", "modelDefault", 1, "ma"]) - - Returns: - representation (ObjectId) - - """ - - components = zip( - ("project", "asset", "subset", "version", "representation"), - path - ) - - parent = None - for type_, name in components: - latest = (type_ == "version") and name in (None, -1) - - try: - if latest: - parent = self.find_one( - filter={ - "type": type_, - "parent": parent - }, - projection={"_id": 1}, - sort=[("name", -1)] - )["_id"] - else: - parent = self.find_one( - filter={ - "type": type_, - "name": name, - "parent": parent - }, - projection={"_id": 1}, - )["_id"] - - except TypeError: - return None - - return parent - - @auto_reconnect - def collections(self): - return self._database.collection_names() - - @auto_reconnect - def find_project(self, project): - return self._database[project].find_one({"type": "project"}) - - @auto_reconnect - def insert_one(self, item): - assert isinstance(item, dict), "item must be of type " - schema.validate(item) - return self._database[self.Session["AVALON_PROJECT"]].insert_one(item) - - @auto_reconnect - def insert_many(self, items, ordered=True): - # check if all items are valid - assert isinstance(items, list), "`items` must be of type " - for item in items: - assert isinstance(item, dict), "`item` must be of type " - schema.validate(item) - - return self._database[self.Session["AVALON_PROJECT"]].insert_many( - items, - ordered=ordered) - - @auto_reconnect - def find(self, filter, projection=None, sort=None): - return self._database[self.Session["AVALON_PROJECT"]].find( - filter=filter, - projection=projection, - sort=sort - ) - - @auto_reconnect - def find_one(self, filter, projection=None, sort=None): - assert isinstance(filter, dict), "filter must be " - - return self._database[self.Session["AVALON_PROJECT"]].find_one( - filter=filter, - projection=projection, - sort=sort - ) - - @auto_reconnect - def save(self, *args, **kwargs): - return self._database[self.Session["AVALON_PROJECT"]].save( - *args, **kwargs) - - @auto_reconnect - def replace_one(self, filter, replacement): - return self._database[self.Session["AVALON_PROJECT"]].replace_one( - filter, replacement) - - @auto_reconnect - def update_many(self, filter, update): - return self._database[self.Session["AVALON_PROJECT"]].update_many( - filter, update) - - @auto_reconnect - def distinct(self, *args, **kwargs): - return self._database[self.Session["AVALON_PROJECT"]].distinct( - *args, **kwargs) - - @auto_reconnect - def drop(self, *args, **kwargs): - return self._database[self.Session["AVALON_PROJECT"]].drop( - *args, **kwargs) - - @auto_reconnect - def delete_many(self, *args, **kwargs): - return self._database[self.Session["AVALON_PROJECT"]].delete_many( - *args, **kwargs) - - def parenthood(self, document): - assert document is not None, "This is a bug" - - parents = list() - - while document.get("parent") is not None: - document = self.find_one({"_id": document["parent"]}) - - if document is None: - break - - if document.get("type") == "master_version": - _document = self.find_one({"_id": document["version_id"]}) - document["data"] = _document["data"] - - parents.append(document) - - return parents - - @contextlib.contextmanager - def tempdir(self): - tempdir = tempfile.mkdtemp() - try: - yield tempdir - finally: - shutil.rmtree(tempdir) - - def download(self, src, dst): - """Download `src` to `dst` - - Arguments: - src (str): URL to source file - dst (str): Absolute path to destination file - - Yields tuple (progress, error): - progress (int): Between 0-100 - error (Exception): Any exception raised when first making connection - - """ - - try: - response = requests.get( - src, - stream=True, - auth=requests.auth.HTTPBasicAuth( - self.Session["AVALON_USERNAME"], - self.Session["AVALON_PASSWORD"] - ) - ) - except requests.ConnectionError as e: - yield None, e - return - - with self.tempdir() as dirname: - tmp = os.path.join(dirname, os.path.basename(src)) - - with open(tmp, "wb") as f: - total_length = response.headers.get("content-length") - - if total_length is None: # no content length header - f.write(response.content) - else: - downloaded = 0 - total_length = int(total_length) - for data in response.iter_content(chunk_size=4096): - downloaded += len(data) - f.write(data) - - yield int(100.0 * downloaded / total_length), None - - try: - os.makedirs(os.path.dirname(dst)) - except OSError as e: - # An already existing destination directory is fine. - if e.errno != errno.EEXIST: - raise - - shutil.copy(tmp, dst) diff --git a/pype/modules/adobe_communicator/lib/rest_api.py b/pype/modules/adobe_communicator/lib/rest_api.py index 86739e4d80..35094d10dc 100644 --- a/pype/modules/adobe_communicator/lib/rest_api.py +++ b/pype/modules/adobe_communicator/lib/rest_api.py @@ -2,7 +2,7 @@ import os import sys import copy from pype.modules.rest_api import RestApi, route, abort, CallbackResult -from .io_nonsingleton import DbConnector +from avalon.api import AvalonMongoDB from pype.api import config, execute, Logger log = Logger().get_logger("AdobeCommunicator") @@ -14,7 +14,7 @@ PUBLISH_PATHS = [] class AdobeRestApi(RestApi): - dbcon = DbConnector() + dbcon = AvalonMongoDB() def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) diff --git a/pype/modules/avalon_apps/avalon_app.py b/pype/modules/avalon_apps/avalon_app.py index 34fbc5c5ae..de10268304 100644 --- a/pype/modules/avalon_apps/avalon_app.py +++ b/pype/modules/avalon_apps/avalon_app.py @@ -1,16 +1,27 @@ -from Qt import QtWidgets -from avalon.tools import libraryloader from pype.api import Logger -from pype.tools.launcher import LauncherWindow, actions class AvalonApps: def __init__(self, main_parent=None, parent=None): self.log = Logger().get_logger(__name__) - self.main_parent = main_parent + + self.tray_init(main_parent, parent) + + def tray_init(self, main_parent, parent): + from avalon.tools.libraryloader import app + from avalon import style + from pype.tools.launcher import LauncherWindow, actions + self.parent = parent + self.main_parent = main_parent self.app_launcher = LauncherWindow() + self.libraryloader = app.Window( + icon=self.parent.icon, + show_projects=True, + show_libraries=True + ) + self.libraryloader.setStyleSheet(style.load_stylesheet()) # actions.register_default_actions() actions.register_config_actions() @@ -23,6 +34,7 @@ class AvalonApps: # Definition of Tray menu def tray_menu(self, parent_menu=None): + from Qt import QtWidgets # Actions if parent_menu is None: if self.parent is None: @@ -48,11 +60,15 @@ class AvalonApps: def show_launcher(self): # if app_launcher don't exist create it/otherwise only show main window self.app_launcher.show() + self.app_launcher.raise_() + self.app_launcher.activateWindow() def show_library_loader(self): - libraryloader.show( - parent=self.main_parent, - icon=self.parent.icon, - show_projects=True, - show_libraries=True - ) + self.libraryloader.show() + + # Raise and activate the window + # for MacOS + self.libraryloader.raise_() + # for Windows + self.libraryloader.activateWindow() + self.libraryloader.refresh() diff --git a/pype/modules/avalon_apps/rest_api.py b/pype/modules/avalon_apps/rest_api.py index 1cb9e544a7..2408e56bbc 100644 --- a/pype/modules/avalon_apps/rest_api.py +++ b/pype/modules/avalon_apps/rest_api.py @@ -4,14 +4,14 @@ import json import bson import bson.json_util from pype.modules.rest_api import RestApi, abort, CallbackResult -from pype.modules.ftrack.lib.io_nonsingleton import DbConnector +from avalon.api import AvalonMongoDB class AvalonRestApi(RestApi): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) - self.dbcon = DbConnector() + self.dbcon = AvalonMongoDB() self.dbcon.install() @RestApi.route("/projects/", url_prefix="/avalon", methods="GET") diff --git a/pype/modules/clockify/clockify.py b/pype/modules/clockify/clockify.py index fea15a1bea..4309bff9f2 100644 --- a/pype/modules/clockify/clockify.py +++ b/pype/modules/clockify/clockify.py @@ -1,9 +1,8 @@ import os import threading +import time + from pype.api import Logger -from avalon import style -from Qt import QtWidgets -from .widgets import ClockifySettings, MessageWidget from .clockify_api import ClockifyAPI from .constants import CLOCKIFY_FTRACK_USER_PATH @@ -17,11 +16,21 @@ class ClockifyModule: os.environ["CLOCKIFY_WORKSPACE"] = self.workspace_name + self.timer_manager = None + self.MessageWidgetClass = None + + self.clockapi = ClockifyAPI(master_parent=self) + self.log = Logger().get_logger(self.__class__.__name__, "PypeTray") + self.tray_init(main_parent, parent) + + def tray_init(self, main_parent, parent): + from .widgets import ClockifySettings, MessageWidget + + self.MessageWidgetClass = MessageWidget self.main_parent = main_parent self.parent = parent - self.clockapi = ClockifyAPI(master_parent=self) self.message_widget = None self.widget_settings = ClockifySettings(main_parent, self) self.widget_settings_required = None @@ -57,11 +66,10 @@ class ClockifyModule: ) if 'AvalonApps' in modules: - from launcher import lib - actions_path = os.path.sep.join([ + actions_path = os.path.join( os.path.dirname(__file__), 'launcher_actions' - ]) + ) current = os.environ.get('AVALON_ACTIONS', '') if current: current += os.pathsep @@ -78,12 +86,12 @@ class ClockifyModule: self.stop_timer() def timer_started(self, data): - if hasattr(self, 'timer_manager'): + if self.timer_manager: self.timer_manager.start_timers(data) def timer_stopped(self): self.bool_timer_run = False - if hasattr(self, 'timer_manager'): + if self.timer_manager: self.timer_manager.stop_timers() def start_timer_check(self): @@ -102,7 +110,7 @@ class ClockifyModule: self.thread_timer_check = None def check_running(self): - import time + while self.bool_thread_check_running is True: bool_timer_run = False if self.clockapi.get_in_progress() is not None: @@ -156,15 +164,14 @@ class ClockifyModule: self.timer_stopped() def signed_in(self): - if hasattr(self, 'timer_manager'): - if not self.timer_manager: - return + if not self.timer_manager: + return - if not self.timer_manager.last_task: - return + if not self.timer_manager.last_task: + return - if self.timer_manager.is_running: - self.start_timer_manager(self.timer_manager.last_task) + if self.timer_manager.is_running: + self.start_timer_manager(self.timer_manager.last_task) def start_timer(self, input_data): # If not api key is not entered then skip @@ -197,11 +204,14 @@ class ClockifyModule: "

Please inform your Project Manager." ).format(project_name, str(self.clockapi.workspace_name)) - self.message_widget = MessageWidget( - self.main_parent, msg, "Clockify - Info Message" - ) - self.message_widget.closed.connect(self.on_message_widget_close) - self.message_widget.show() + if self.MessageWidgetClass: + self.message_widget = self.MessageWidgetClass( + self.main_parent, msg, "Clockify - Info Message" + ) + self.message_widget.closed.connect( + self.on_message_widget_close + ) + self.message_widget.show() return @@ -227,31 +237,29 @@ class ClockifyModule: # Definition of Tray menu def tray_menu(self, parent_menu): # Menu for Tray App - self.menu = QtWidgets.QMenu('Clockify', parent_menu) - self.menu.setProperty('submenu', 'on') - self.menu.setStyleSheet(style.load_stylesheet()) + from Qt import QtWidgets + menu = QtWidgets.QMenu("Clockify", parent_menu) + menu.setProperty("submenu", "on") # Actions - self.aShowSettings = QtWidgets.QAction( - "Settings", self.menu - ) - self.aStopTimer = QtWidgets.QAction( - "Stop timer", self.menu - ) + action_show_settings = QtWidgets.QAction("Settings", menu) + action_stop_timer = QtWidgets.QAction("Stop timer", menu) - self.menu.addAction(self.aShowSettings) - self.menu.addAction(self.aStopTimer) + menu.addAction(action_show_settings) + menu.addAction(action_stop_timer) - self.aShowSettings.triggered.connect(self.show_settings) - self.aStopTimer.triggered.connect(self.stop_timer) + action_show_settings.triggered.connect(self.show_settings) + action_stop_timer.triggered.connect(self.stop_timer) + + self.action_stop_timer = action_stop_timer self.set_menu_visibility() - parent_menu.addMenu(self.menu) + parent_menu.addMenu(menu) def show_settings(self): self.widget_settings.input_api_key.setText(self.clockapi.get_api_key()) self.widget_settings.show() def set_menu_visibility(self): - self.aStopTimer.setVisible(self.bool_timer_run) + self.action_stop_timer.setVisible(self.bool_timer_run) diff --git a/pype/modules/clockify/launcher_actions/ClockifySync.py b/pype/modules/clockify/launcher_actions/ClockifySync.py index a77c038076..422a346023 100644 --- a/pype/modules/clockify/launcher_actions/ClockifySync.py +++ b/pype/modules/clockify/launcher_actions/ClockifySync.py @@ -30,7 +30,7 @@ class ClockifySync(api.Action): projects_info = {} for project in projects_to_sync: - task_types = [task['name'] for task in project['config']['tasks']] + task_types = project['config']['tasks'].keys() projects_info[project['name']] = task_types clockify_projects = self.clockapi.get_projects() diff --git a/pype/modules/ftrack/__init__.py b/pype/modules/ftrack/__init__.py index aa8f04bffb..fad771f084 100644 --- a/pype/modules/ftrack/__init__.py +++ b/pype/modules/ftrack/__init__.py @@ -1,2 +1,12 @@ -from .lib import * +from . import ftrack_server from .ftrack_server import FtrackServer, check_ftrack_url +from .lib import BaseHandler, BaseEvent, BaseAction + +__all__ = ( + "ftrack_server", + "FtrackServer", + "check_ftrack_url", + "BaseHandler", + "BaseEvent", + "BaseAction" +) diff --git a/pype/modules/ftrack/actions/action_delete_asset.py b/pype/modules/ftrack/actions/action_delete_asset.py index 27394770e1..7d2dac3320 100644 --- a/pype/modules/ftrack/actions/action_delete_asset.py +++ b/pype/modules/ftrack/actions/action_delete_asset.py @@ -5,7 +5,7 @@ from queue import Queue from bson.objectid import ObjectId from pype.modules.ftrack.lib import BaseAction, statics_icon -from pype.modules.ftrack.lib.io_nonsingleton import DbConnector +from avalon.api import AvalonMongoDB class DeleteAssetSubset(BaseAction): @@ -21,7 +21,7 @@ class DeleteAssetSubset(BaseAction): #: roles that are allowed to register this action role_list = ["Pypeclub", "Administrator", "Project Manager"] #: Db connection - dbcon = DbConnector() + dbcon = AvalonMongoDB() splitter = {"type": "label", "value": "---"} action_data_by_id = {} diff --git a/pype/modules/ftrack/actions/action_delete_old_versions.py b/pype/modules/ftrack/actions/action_delete_old_versions.py index 6a4c5a0cae..b55f091fdc 100644 --- a/pype/modules/ftrack/actions/action_delete_old_versions.py +++ b/pype/modules/ftrack/actions/action_delete_old_versions.py @@ -6,7 +6,7 @@ import clique from pymongo import UpdateOne from pype.modules.ftrack.lib import BaseAction, statics_icon -from pype.modules.ftrack.lib.io_nonsingleton import DbConnector +from avalon.api import AvalonMongoDB from pype.api import Anatomy import avalon.pipeline @@ -24,7 +24,7 @@ class DeleteOldVersions(BaseAction): role_list = ["Pypeclub", "Project Manager", "Administrator"] icon = statics_icon("ftrack", "action_icons", "PypeAdmin.svg") - dbcon = DbConnector() + dbcon = AvalonMongoDB() inteface_title = "Choose your preferences" splitter_item = {"type": "label", "value": "---"} diff --git a/pype/modules/ftrack/actions/action_delivery.py b/pype/modules/ftrack/actions/action_delivery.py index c0d9e80286..8812ce9bc7 100644 --- a/pype/modules/ftrack/actions/action_delivery.py +++ b/pype/modules/ftrack/actions/action_delivery.py @@ -1,5 +1,6 @@ import os import copy +import json import shutil import collections @@ -9,10 +10,10 @@ from bson.objectid import ObjectId from avalon import pipeline from avalon.vendor import filelink -from pype.api import Anatomy +from pype.api import Anatomy, config from pype.modules.ftrack.lib import BaseAction, statics_icon from pype.modules.ftrack.lib.avalon_sync import CUST_ATTR_ID_KEY -from pype.modules.ftrack.lib.io_nonsingleton import DbConnector +from avalon.api import AvalonMongoDB class Delivery(BaseAction): @@ -23,7 +24,7 @@ class Delivery(BaseAction): role_list = ["Pypeclub", "Administrator", "Project manager"] icon = statics_icon("ftrack", "action_icons", "Delivery.svg") - db_con = DbConnector() + db_con = AvalonMongoDB() def discover(self, session, entities, event): for entity in entities: @@ -41,36 +42,22 @@ class Delivery(BaseAction): items = [] item_splitter = {"type": "label", "value": "---"} - # Prepare component names for processing - components = None - project = None - for entity in entities: - if project is None: - project_id = None - for ent_info in entity["link"]: - if ent_info["type"].lower() == "project": - project_id = ent_info["id"] - break + project_entity = self.get_project_from_entity(entities[0]) + project_name = project_entity["full_name"] + self.db_con.install() + self.db_con.Session["AVALON_PROJECT"] = project_name + project_doc = self.db_con.find_one({"type": "project"}) + if not project_doc: + return { + "success": False, + "message": ( + "Didn't found project \"{}\" in avalon." + ).format(project_name) + } - if project_id is None: - project = entity["asset"]["parent"]["project"] - else: - project = session.query(( - "select id, full_name from Project where id is \"{}\"" - ).format(project_id)).one() + repre_names = self._get_repre_names(entities) + self.db_con.uninstall() - _components = set( - [component["name"] for component in entity["components"]] - ) - if components is None: - components = _components - continue - - components = components.intersection(_components) - if not components: - break - - project_name = project["full_name"] items.append({ "type": "hidden", "name": "__project_name__", @@ -93,7 +80,7 @@ class Delivery(BaseAction): skipped = False # Add message if there are any common components - if not components or not new_anatomies: + if not repre_names or not new_anatomies: skipped = True items.append({ "type": "label", @@ -106,7 +93,7 @@ class Delivery(BaseAction): "value": skipped }) - if not components: + if not repre_names: if len(entities) == 1: items.append({ "type": "label", @@ -143,12 +130,12 @@ class Delivery(BaseAction): "type": "label" }) - for component in components: + for repre_name in repre_names: items.append({ "type": "boolean", "value": False, - "label": component, - "name": component + "label": repre_name, + "name": repre_name }) items.append(item_splitter) @@ -198,27 +185,233 @@ class Delivery(BaseAction): "title": title } + def _get_repre_names(self, entities): + version_ids = self._get_interest_version_ids(entities) + repre_docs = self.db_con.find({ + "type": "representation", + "parent": {"$in": version_ids} + }) + return list(sorted(repre_docs.distinct("name"))) + + def _get_interest_version_ids(self, entities): + parent_ent_by_id = {} + subset_names = set() + version_nums = set() + for entity in entities: + asset = entity["asset"] + parent = asset["parent"] + parent_ent_by_id[parent["id"]] = parent + + subset_name = asset["name"] + subset_names.add(subset_name) + + version = entity["version"] + version_nums.add(version) + + asset_docs_by_ftrack_id = self._get_asset_docs(parent_ent_by_id) + subset_docs = self._get_subset_docs( + asset_docs_by_ftrack_id, subset_names, entities + ) + version_docs = self._get_version_docs( + asset_docs_by_ftrack_id, subset_docs, version_nums, entities + ) + + return [version_doc["_id"] for version_doc in version_docs] + + def _get_version_docs( + self, asset_docs_by_ftrack_id, subset_docs, version_nums, entities + ): + subset_docs_by_id = { + subset_doc["_id"]: subset_doc + for subset_doc in subset_docs + } + version_docs = list(self.db_con.find({ + "type": "version", + "parent": {"$in": list(subset_docs_by_id.keys())}, + "name": {"$in": list(version_nums)} + })) + version_docs_by_parent_id = collections.defaultdict(dict) + for version_doc in version_docs: + subset_doc = subset_docs_by_id[version_doc["parent"]] + + asset_id = subset_doc["parent"] + subset_name = subset_doc["name"] + version = version_doc["name"] + if version_docs_by_parent_id[asset_id].get(subset_name) is None: + version_docs_by_parent_id[asset_id][subset_name] = {} + + version_docs_by_parent_id[asset_id][subset_name][version] = ( + version_doc + ) + + filtered_versions = [] + for entity in entities: + asset = entity["asset"] + + parent = asset["parent"] + asset_doc = asset_docs_by_ftrack_id[parent["id"]] + + subsets_by_name = version_docs_by_parent_id.get(asset_doc["_id"]) + if not subsets_by_name: + continue + + subset_name = asset["name"] + version_docs_by_version = subsets_by_name.get(subset_name) + if not version_docs_by_version: + continue + + version = entity["version"] + version_doc = version_docs_by_version.get(version) + if version_doc: + filtered_versions.append(version_doc) + return filtered_versions + + def _get_subset_docs( + self, asset_docs_by_ftrack_id, subset_names, entities + ): + asset_doc_ids = list() + for asset_doc in asset_docs_by_ftrack_id.values(): + asset_doc_ids.append(asset_doc["_id"]) + + subset_docs = list(self.db_con.find({ + "type": "subset", + "parent": {"$in": asset_doc_ids}, + "name": {"$in": list(subset_names)} + })) + subset_docs_by_parent_id = collections.defaultdict(dict) + for subset_doc in subset_docs: + asset_id = subset_doc["parent"] + subset_name = subset_doc["name"] + subset_docs_by_parent_id[asset_id][subset_name] = subset_doc + + filtered_subsets = [] + for entity in entities: + asset = entity["asset"] + + parent = asset["parent"] + asset_doc = asset_docs_by_ftrack_id[parent["id"]] + + subsets_by_name = subset_docs_by_parent_id.get(asset_doc["_id"]) + if not subsets_by_name: + continue + + subset_name = asset["name"] + subset_doc = subsets_by_name.get(subset_name) + if subset_doc: + filtered_subsets.append(subset_doc) + return filtered_subsets + + def _get_asset_docs(self, parent_ent_by_id): + asset_docs = list(self.db_con.find({ + "type": "asset", + "data.ftrackId": {"$in": list(parent_ent_by_id.keys())} + })) + asset_docs_by_ftrack_id = { + asset_doc["data"]["ftrackId"]: asset_doc + for asset_doc in asset_docs + } + + entities_by_mongo_id = {} + entities_by_names = {} + for ftrack_id, entity in parent_ent_by_id.items(): + if ftrack_id not in asset_docs_by_ftrack_id: + parent_mongo_id = entity["custom_attributes"].get( + CUST_ATTR_ID_KEY + ) + if parent_mongo_id: + entities_by_mongo_id[ObjectId(parent_mongo_id)] = entity + else: + entities_by_names[entity["name"]] = entity + + expressions = [] + if entities_by_mongo_id: + expression = { + "type": "asset", + "_id": {"$in": list(entities_by_mongo_id.keys())} + } + expressions.append(expression) + + if entities_by_names: + expression = { + "type": "asset", + "name": {"$in": list(entities_by_names.keys())} + } + expressions.append(expression) + + if expressions: + if len(expressions) == 1: + filter = expressions[0] + else: + filter = {"$or": expressions} + + asset_docs = self.db_con.find(filter) + for asset_doc in asset_docs: + if asset_doc["_id"] in entities_by_mongo_id: + entity = entities_by_mongo_id[asset_doc["_id"]] + asset_docs_by_ftrack_id[entity["id"]] = asset_doc + + elif asset_doc["name"] in entities_by_names: + entity = entities_by_names[asset_doc["name"]] + asset_docs_by_ftrack_id[entity["id"]] = asset_doc + + return asset_docs_by_ftrack_id + def launch(self, session, entities, event): if "values" not in event["data"]: return - self.report_items = collections.defaultdict(list) - values = event["data"]["values"] skipped = values.pop("__skipped__") if skipped: return None - component_names = [] + user_id = event["source"]["user"]["id"] + user_entity = session.query( + "User where id is {}".format(user_id) + ).one() + + job = session.create("Job", { + "user": user_entity, + "status": "running", + "data": json.dumps({ + "description": "Delivery processing." + }) + }) + session.commit() + + try: + self.db_con.install() + self.real_launch(session, entities, event) + job["status"] = "done" + + except Exception: + self.log.warning( + "Failed during processing delivery action.", + exc_info=True + ) + + finally: + if job["status"] != "done": + job["status"] = "failed" + session.commit() + self.db_con.uninstall() + + def real_launch(self, session, entities, event): + self.log.info("Delivery action just started.") + report_items = collections.defaultdict(list) + + values = event["data"]["values"] + location_path = values.pop("__location_path__") anatomy_name = values.pop("__new_anatomies__") project_name = values.pop("__project_name__") + repre_names = [] for key, value in values.items(): if value is True: - component_names.append(key) + repre_names.append(key) - if not component_names: + if not repre_names: return { "success": True, "message": "Not selected components to deliver." @@ -230,64 +423,15 @@ class Delivery(BaseAction): if not os.path.exists(location_path): os.makedirs(location_path) - self.db_con.install() self.db_con.Session["AVALON_PROJECT"] = project_name - repres_to_deliver = [] - for entity in entities: - asset = entity["asset"] - subset_name = asset["name"] - version = entity["version"] - - parent = asset["parent"] - parent_mongo_id = parent["custom_attributes"].get(CUST_ATTR_ID_KEY) - if parent_mongo_id: - parent_mongo_id = ObjectId(parent_mongo_id) - else: - asset_ent = self.db_con.find_one({ - "type": "asset", - "data.ftrackId": parent["id"] - }) - if not asset_ent: - ent_path = "/".join( - [ent["name"] for ent in parent["link"]] - ) - msg = "Not synchronized entities to avalon" - self.report_items[msg].append(ent_path) - self.log.warning("{} <{}>".format(msg, ent_path)) - continue - - parent_mongo_id = asset_ent["_id"] - - subset_ent = self.db_con.find_one({ - "type": "subset", - "parent": parent_mongo_id, - "name": subset_name - }) - - version_ent = self.db_con.find_one({ - "type": "version", - "name": version, - "parent": subset_ent["_id"] - }) - - repre_ents = self.db_con.find({ - "type": "representation", - "parent": version_ent["_id"] - }) - - repres_by_name = {} - for repre in repre_ents: - repre_name = repre["name"] - repres_by_name[repre_name] = repre - - for component in entity["components"]: - comp_name = component["name"] - if comp_name not in component_names: - continue - - repre = repres_by_name.get(comp_name) - repres_to_deliver.append(repre) + self.log.debug("Collecting representations to process.") + version_ids = self._get_interest_version_ids(entities) + repres_to_deliver = list(self.db_con.find({ + "type": "representation", + "parent": {"$in": version_ids}, + "name": {"$in": repre_names} + })) anatomy = Anatomy(project_name) @@ -304,9 +448,17 @@ class Delivery(BaseAction): for name in root_names: format_dict["root"][name] = location_path + datetime_data = config.get_datetime_data() for repre in repres_to_deliver: + source_path = repre.get("data", {}).get("path") + debug_msg = "Processing representation {}".format(repre["_id"]) + if source_path: + debug_msg += " with published path {}.".format(source_path) + self.log.debug(debug_msg) + # Get destination repre path anatomy_data = copy.deepcopy(repre["context"]) + anatomy_data.update(datetime_data) anatomy_filled = anatomy.format_all(anatomy_data) test_path = anatomy_filled["delivery"][anatomy_name] @@ -333,7 +485,7 @@ class Delivery(BaseAction): "- Invalid value DataType: \"{}\"
" ).format(str(repre["_id"]), keys) - self.report_items[msg].append(sub_msg) + report_items[msg].append(sub_msg) self.log.warning( "{} Representation: \"{}\" Filled: <{}>".format( msg, str(repre["_id"]), str(test_path) @@ -355,20 +507,19 @@ class Delivery(BaseAction): anatomy, anatomy_name, anatomy_data, - format_dict + format_dict, + report_items ) - if not frame: self.process_single_file(*args) else: self.process_sequence(*args) - self.db_con.uninstall() - - return self.report() + return self.report(report_items) def process_single_file( - self, repre_path, anatomy, anatomy_name, anatomy_data, format_dict + self, repre_path, anatomy, anatomy_name, anatomy_data, format_dict, + report_items ): anatomy_filled = anatomy.format(anatomy_data) if format_dict: @@ -384,7 +535,8 @@ class Delivery(BaseAction): self.copy_file(repre_path, delivery_path) def process_sequence( - self, repre_path, anatomy, anatomy_name, anatomy_data, format_dict + self, repre_path, anatomy, anatomy_name, anatomy_data, format_dict, + report_items ): dir_path, file_name = os.path.split(str(repre_path)) @@ -398,7 +550,7 @@ class Delivery(BaseAction): if not file_name_items: msg = "Source file was not found" - self.report_items[msg].append(repre_path) + report_items[msg].append(repre_path) self.log.warning("{} <{}>".format(msg, repre_path)) return @@ -418,7 +570,7 @@ class Delivery(BaseAction): if src_collection is None: # TODO log error! msg = "Source collection of files was not found" - self.report_items[msg].append(repre_path) + report_items[msg].append(repre_path) self.log.warning("{} <{}>".format(msg, repre_path)) return @@ -491,10 +643,10 @@ class Delivery(BaseAction): except OSError: shutil.copyfile(src_path, dst_path) - def report(self): + def report(self, report_items): items = [] title = "Delivery report" - for msg, _items in self.report_items.items(): + for msg, _items in report_items.items(): if not _items: continue diff --git a/pype/modules/ftrack/actions/action_store_thumbnails_to_avalon.py b/pype/modules/ftrack/actions/action_store_thumbnails_to_avalon.py index 94ca503233..36f7175768 100644 --- a/pype/modules/ftrack/actions/action_store_thumbnails_to_avalon.py +++ b/pype/modules/ftrack/actions/action_store_thumbnails_to_avalon.py @@ -6,7 +6,7 @@ import json from bson.objectid import ObjectId from pype.modules.ftrack.lib import BaseAction, statics_icon from pype.api import Anatomy -from pype.modules.ftrack.lib.io_nonsingleton import DbConnector +from avalon.api import AvalonMongoDB from pype.modules.ftrack.lib.avalon_sync import CUST_ATTR_ID_KEY @@ -25,7 +25,7 @@ class StoreThumbnailsToAvalon(BaseAction): icon = statics_icon("ftrack", "action_icons", "PypeAdmin.svg") thumbnail_key = "AVALON_THUMBNAIL_ROOT" - db_con = DbConnector() + db_con = AvalonMongoDB() def discover(self, session, entities, event): for entity in entities: diff --git a/pype/modules/ftrack/actions/action_thumbnail_to_parent.py b/pype/modules/ftrack/actions/action_thumbnail_to_parent.py index 8710fa9dcf..fb473f9aa5 100644 --- a/pype/modules/ftrack/actions/action_thumbnail_to_parent.py +++ b/pype/modules/ftrack/actions/action_thumbnail_to_parent.py @@ -41,9 +41,9 @@ class ThumbToParent(BaseAction): parent = None thumbid = None if entity.entity_type.lower() == 'assetversion': - try: - parent = entity['task'] - except Exception: + parent = entity['task'] + + if parent is None: par_ent = entity['link'][-2] parent = session.get(par_ent['type'], par_ent['id']) else: @@ -51,7 +51,7 @@ class ThumbToParent(BaseAction): parent = entity['parent'] except Exception as e: msg = ( - "Durin Action 'Thumb to Parent'" + "During Action 'Thumb to Parent'" " went something wrong" ) self.log.error(msg) @@ -62,7 +62,10 @@ class ThumbToParent(BaseAction): parent['thumbnail_id'] = thumbid status = 'done' else: - status = 'failed' + raise Exception( + "Parent or thumbnail id not found. Parent: {}. " + "Thumbnail id: {}".format(parent, thumbid) + ) # inform the user that the job is done job['status'] = status or 'done' diff --git a/pype/modules/ftrack/events/action_push_frame_values_to_task.py b/pype/modules/ftrack/events/action_push_frame_values_to_task.py new file mode 100644 index 0000000000..a55c1e46a6 --- /dev/null +++ b/pype/modules/ftrack/events/action_push_frame_values_to_task.py @@ -0,0 +1,437 @@ +import json +import collections +import ftrack_api +from pype.modules.ftrack.lib import BaseAction + + +class PushFrameValuesToTaskAction(BaseAction): + """Action for testing purpose or as base for new actions.""" + + # Ignore event handler by default + ignore_me = True + + identifier = "admin.push_frame_values_to_task" + label = "Pype Admin" + variant = "- Push Frame values to Task" + + entities_query = ( + "select id, name, parent_id, link from TypedContext" + " where project_id is \"{}\" and object_type_id in ({})" + ) + cust_attrs_query = ( + "select id, key, object_type_id, is_hierarchical, default" + " from CustomAttributeConfiguration" + " where key in ({})" + ) + cust_attr_value_query = ( + "select value, entity_id from CustomAttributeValue" + " where entity_id in ({}) and configuration_id in ({})" + ) + + pushing_entity_types = {"Shot"} + hierarchical_custom_attribute_keys = {"frameStart", "frameEnd"} + custom_attribute_mapping = { + "frameStart": "fstart", + "frameEnd": "fend" + } + discover_role_list = {"Pypeclub", "Administrator", "Project Manager"} + + def register(self): + modified_role_names = set() + for role_name in self.discover_role_list: + modified_role_names.add(role_name.lower()) + self.discover_role_list = modified_role_names + + self.session.event_hub.subscribe( + "topic=ftrack.action.discover", + self._discover, + priority=self.priority + ) + + launch_subscription = ( + "topic=ftrack.action.launch and data.actionIdentifier={0}" + ).format(self.identifier) + self.session.event_hub.subscribe(launch_subscription, self._launch) + + def discover(self, session, entities, event): + """ Validation """ + # Check if selection is valid + valid_selection = False + for ent in event["data"]["selection"]: + # Ignore entities that are not tasks or projects + if ent["entityType"].lower() == "show": + valid_selection = True + break + + if not valid_selection: + return False + + # Get user and check his roles + user_id = event.get("source", {}).get("user", {}).get("id") + if not user_id: + return False + + user = session.query("User where id is \"{}\"".format(user_id)).first() + if not user: + return False + + for role in user["user_security_roles"]: + lowered_role = role["security_role"]["name"].lower() + if lowered_role in self.discover_role_list: + return True + return False + + def launch(self, session, entities, event): + self.log.debug("{}: Creating job".format(self.label)) + + user_entity = session.query( + "User where id is {}".format(event["source"]["user"]["id"]) + ).one() + job = session.create("Job", { + "user": user_entity, + "status": "running", + "data": json.dumps({ + "description": "Propagation of Frame attribute values to task." + }) + }) + session.commit() + + try: + project_entity = self.get_project_from_entity(entities[0]) + result = self.propagate_values(session, project_entity, event) + job["status"] = "done" + session.commit() + + return result + + except Exception: + session.rollback() + job["status"] = "failed" + session.commit() + + msg = "Pushing Custom attribute values to task Failed" + self.log.warning(msg, exc_info=True) + return { + "success": False, + "message": msg + } + + finally: + if job["status"] == "running": + job["status"] = "failed" + session.commit() + + def task_attributes(self, session): + task_object_type = session.query( + "ObjectType where name is \"Task\"" + ).one() + + hier_attr_names = list( + self.custom_attribute_mapping.keys() + ) + entity_type_specific_names = list( + self.custom_attribute_mapping.values() + ) + joined_keys = self.join_keys( + hier_attr_names + entity_type_specific_names + ) + attribute_entities = session.query( + self.cust_attrs_query.format(joined_keys) + ).all() + + hier_attrs = [] + task_attrs = {} + for attr in attribute_entities: + attr_key = attr["key"] + if attr["is_hierarchical"]: + if attr_key in hier_attr_names: + hier_attrs.append(attr) + elif attr["object_type_id"] == task_object_type["id"]: + if attr_key in entity_type_specific_names: + task_attrs[attr_key] = attr["id"] + return task_attrs, hier_attrs + + def join_keys(self, items): + return ",".join(["\"{}\"".format(item) for item in items]) + + def propagate_values(self, session, project_entity, event): + self.log.debug("Querying project's entities \"{}\".".format( + project_entity["full_name"] + )) + pushing_entity_types = tuple( + ent_type.lower() + for ent_type in self.pushing_entity_types + ) + destination_object_types = [] + all_object_types = session.query("ObjectType").all() + for object_type in all_object_types: + lowered_name = object_type["name"].lower() + if ( + lowered_name == "task" + or lowered_name in pushing_entity_types + ): + destination_object_types.append(object_type) + + destination_object_type_ids = tuple( + obj_type["id"] + for obj_type in destination_object_types + ) + entities = session.query(self.entities_query.format( + project_entity["id"], + self.join_keys(destination_object_type_ids) + )).all() + + entities_by_id = { + entity["id"]: entity + for entity in entities + } + + self.log.debug("Filtering Task entities.") + task_entities_by_parent_id = collections.defaultdict(list) + non_task_entities = [] + non_task_entity_ids = [] + for entity in entities: + if entity.entity_type.lower() != "task": + non_task_entities.append(entity) + non_task_entity_ids.append(entity["id"]) + continue + + parent_id = entity["parent_id"] + if parent_id in entities_by_id: + task_entities_by_parent_id[parent_id].append(entity) + + task_attr_id_by_keys, hier_attrs = self.task_attributes(session) + + self.log.debug("Getting Custom attribute values from tasks' parents.") + hier_values_by_entity_id = self.get_hier_values( + session, + hier_attrs, + non_task_entity_ids + ) + + self.log.debug("Setting parents' values to task.") + task_missing_keys = self.set_task_attr_values( + session, + task_entities_by_parent_id, + hier_values_by_entity_id, + task_attr_id_by_keys + ) + + self.log.debug("Setting values to entities themselves.") + missing_keys_by_object_name = self.push_values_to_entities( + session, + non_task_entities, + hier_values_by_entity_id + ) + if task_missing_keys: + missing_keys_by_object_name["Task"] = task_missing_keys + if missing_keys_by_object_name: + self.report(missing_keys_by_object_name, event) + return True + + def report(self, missing_keys_by_object_name, event): + splitter = {"type": "label", "value": "---"} + + title = "Push Custom Attribute values report:" + + items = [] + items.append({ + "type": "label", + "value": "# Pushing values was not complete" + }) + items.append({ + "type": "label", + "value": ( + "

It was due to missing custom" + " attribute configurations for specific entity type/s." + " These configurations are not created automatically.

" + ) + }) + + log_message_items = [] + log_message_item_template = ( + "Entity type \"{}\" does not have created Custom Attribute/s: {}" + ) + for object_name, missing_attr_names in ( + missing_keys_by_object_name.items() + ): + log_message_items.append(log_message_item_template.format( + object_name, self.join_keys(missing_attr_names) + )) + + items.append(splitter) + items.append({ + "type": "label", + "value": "## Entity type: {}".format(object_name) + }) + + items.append({ + "type": "label", + "value": "

{}

".format("
".join(missing_attr_names)) + }) + + self.log.warning(( + "Couldn't finish pushing attribute values because" + " few entity types miss Custom attribute configurations:\n{}" + ).format("\n".join(log_message_items))) + + self.show_interface(items, title, event) + + def get_hier_values(self, session, hier_attrs, focus_entity_ids): + joined_entity_ids = self.join_keys(focus_entity_ids) + hier_attr_ids = self.join_keys( + tuple(hier_attr["id"] for hier_attr in hier_attrs) + ) + hier_attrs_key_by_id = { + hier_attr["id"]: hier_attr["key"] + for hier_attr in hier_attrs + } + call_expr = [{ + "action": "query", + "expression": self.cust_attr_value_query.format( + joined_entity_ids, hier_attr_ids + ) + }] + if hasattr(session, "call"): + [values] = session.call(call_expr) + else: + [values] = session._call(call_expr) + + values_per_entity_id = {} + for item in values["data"]: + entity_id = item["entity_id"] + key = hier_attrs_key_by_id[item["configuration_id"]] + + if entity_id not in values_per_entity_id: + values_per_entity_id[entity_id] = {} + value = item["value"] + if value is not None: + values_per_entity_id[entity_id][key] = value + + output = {} + for entity_id in focus_entity_ids: + value = values_per_entity_id.get(entity_id) + if value: + output[entity_id] = value + + return output + + def set_task_attr_values( + self, + session, + task_entities_by_parent_id, + hier_values_by_entity_id, + task_attr_id_by_keys + ): + missing_keys = set() + for parent_id, values in hier_values_by_entity_id.items(): + task_entities = task_entities_by_parent_id[parent_id] + for hier_key, value in values.items(): + key = self.custom_attribute_mapping[hier_key] + if key not in task_attr_id_by_keys: + missing_keys.add(key) + continue + + for task_entity in task_entities: + _entity_key = collections.OrderedDict({ + "configuration_id": task_attr_id_by_keys[key], + "entity_id": task_entity["id"] + }) + + session.recorded_operations.push( + ftrack_api.operation.UpdateEntityOperation( + "ContextCustomAttributeValue", + _entity_key, + "value", + ftrack_api.symbol.NOT_SET, + value + ) + ) + session.commit() + + return missing_keys + + def push_values_to_entities( + self, + session, + non_task_entities, + hier_values_by_entity_id + ): + object_types = session.query( + "ObjectType where name in ({})".format( + self.join_keys(self.pushing_entity_types) + ) + ).all() + object_type_names_by_id = { + object_type["id"]: object_type["name"] + for object_type in object_types + } + joined_keys = self.join_keys( + self.custom_attribute_mapping.values() + ) + attribute_entities = session.query( + self.cust_attrs_query.format(joined_keys) + ).all() + + attrs_by_obj_id = {} + for attr in attribute_entities: + if attr["is_hierarchical"]: + continue + + obj_id = attr["object_type_id"] + if obj_id not in object_type_names_by_id: + continue + + if obj_id not in attrs_by_obj_id: + attrs_by_obj_id[obj_id] = {} + + attr_key = attr["key"] + attrs_by_obj_id[obj_id][attr_key] = attr["id"] + + entities_by_obj_id = collections.defaultdict(list) + for entity in non_task_entities: + entities_by_obj_id[entity["object_type_id"]].append(entity) + + missing_keys_by_object_id = collections.defaultdict(set) + for obj_type_id, attr_keys in attrs_by_obj_id.items(): + entities = entities_by_obj_id.get(obj_type_id) + if not entities: + continue + + for entity in entities: + values = hier_values_by_entity_id.get(entity["id"]) + if not values: + continue + + for hier_key, value in values.items(): + key = self.custom_attribute_mapping[hier_key] + if key not in attr_keys: + missing_keys_by_object_id[obj_type_id].add(key) + continue + + _entity_key = collections.OrderedDict({ + "configuration_id": attr_keys[key], + "entity_id": entity["id"] + }) + + session.recorded_operations.push( + ftrack_api.operation.UpdateEntityOperation( + "ContextCustomAttributeValue", + _entity_key, + "value", + ftrack_api.symbol.NOT_SET, + value + ) + ) + session.commit() + + missing_keys_by_object_name = {} + for obj_id, missing_keys in missing_keys_by_object_id.items(): + obj_name = object_type_names_by_id[obj_id] + missing_keys_by_object_name[obj_name] = missing_keys + + return missing_keys_by_object_name + + +def register(session, plugins_presets={}): + PushFrameValuesToTaskAction(session, plugins_presets).register() diff --git a/pype/modules/ftrack/events/event_push_frame_values_to_task.py b/pype/modules/ftrack/events/event_push_frame_values_to_task.py new file mode 100644 index 0000000000..32993ef938 --- /dev/null +++ b/pype/modules/ftrack/events/event_push_frame_values_to_task.py @@ -0,0 +1,230 @@ +import collections +import ftrack_api +from pype.modules.ftrack import BaseEvent + + +class PushFrameValuesToTaskEvent(BaseEvent): + # Ignore event handler by default + ignore_me = True + + cust_attrs_query = ( + "select id, key, object_type_id, is_hierarchical, default" + " from CustomAttributeConfiguration" + " where key in ({}) and object_type_id in ({})" + ) + + interest_entity_types = {"Shot"} + interest_attributes = {"frameStart", "frameEnd"} + interest_attr_mapping = { + "frameStart": "fstart", + "frameEnd": "fend" + } + _cached_task_object_id = None + _cached_interest_object_ids = None + + @staticmethod + def join_keys(keys): + return ",".join(["\"{}\"".format(key) for key in keys]) + + @classmethod + def task_object_id(cls, session): + if cls._cached_task_object_id is None: + task_object_type = session.query( + "ObjectType where name is \"Task\"" + ).one() + cls._cached_task_object_id = task_object_type["id"] + return cls._cached_task_object_id + + @classmethod + def interest_object_ids(cls, session): + if cls._cached_interest_object_ids is None: + object_types = session.query( + "ObjectType where name in ({})".format( + cls.join_keys(cls.interest_entity_types) + ) + ).all() + cls._cached_interest_object_ids = tuple( + object_type["id"] + for object_type in object_types + ) + return cls._cached_interest_object_ids + + def launch(self, session, event): + interesting_data = self.extract_interesting_data(session, event) + if not interesting_data: + return + + entities = self.get_entities(session, interesting_data) + if not entities: + return + + entities_by_id = { + entity["id"]: entity + for entity in entities + } + for entity_id in tuple(interesting_data.keys()): + if entity_id not in entities_by_id: + interesting_data.pop(entity_id) + + task_entities = self.get_task_entities(session, interesting_data) + + attrs_by_obj_id = self.attrs_configurations(session) + if not attrs_by_obj_id: + self.log.warning(( + "There is not created Custom Attributes {}" + " for \"Task\" entity type." + ).format(self.join_keys(self.interest_attributes))) + return + + task_entities_by_parent_id = collections.defaultdict(list) + for task_entity in task_entities: + task_entities_by_parent_id[task_entity["parent_id"]].append( + task_entity + ) + + missing_keys_by_object_name = collections.defaultdict(set) + for parent_id, values in interesting_data.items(): + entities = task_entities_by_parent_id.get(parent_id) or [] + entities.append(entities_by_id[parent_id]) + + for hier_key, value in values.items(): + changed_ids = [] + for entity in entities: + key = self.interest_attr_mapping[hier_key] + entity_attrs_mapping = ( + attrs_by_obj_id.get(entity["object_type_id"]) + ) + if not entity_attrs_mapping: + missing_keys_by_object_name[entity.entity_type].add( + key + ) + continue + + configuration_id = entity_attrs_mapping.get(key) + if not configuration_id: + missing_keys_by_object_name[entity.entity_type].add( + key + ) + continue + + changed_ids.append(entity["id"]) + entity_key = collections.OrderedDict({ + "configuration_id": configuration_id, + "entity_id": entity["id"] + }) + if value is None: + op = ftrack_api.operation.DeleteEntityOperation( + "CustomAttributeValue", + entity_key + ) + else: + op = ftrack_api.operation.UpdateEntityOperation( + "ContextCustomAttributeValue", + entity_key, + "value", + ftrack_api.symbol.NOT_SET, + value + ) + + session.recorded_operations.push(op) + self.log.info(( + "Changing Custom Attribute \"{}\" to value" + " \"{}\" on entities: {}" + ).format(key, value, self.join_keys(changed_ids))) + try: + session.commit() + except Exception: + session.rollback() + self.log.warning( + "Changing of values failed.", + exc_info=True + ) + if not missing_keys_by_object_name: + return + + msg_items = [] + for object_name, missing_keys in missing_keys_by_object_name.items(): + msg_items.append( + "{}: ({})".format(object_name, self.join_keys(missing_keys)) + ) + + self.log.warning(( + "Missing Custom Attribute configuration" + " per specific object types: {}" + ).format(", ".join(msg_items))) + + def extract_interesting_data(self, session, event): + # Filter if event contain relevant data + entities_info = event["data"].get("entities") + if not entities_info: + return + + interesting_data = {} + for entity_info in entities_info: + # Care only about tasks + if entity_info.get("entityType") != "task": + continue + + # Care only about changes of status + changes = entity_info.get("changes") or {} + if not changes: + continue + + # Care only about changes if specific keys + entity_changes = {} + for key in self.interest_attributes: + if key in changes: + entity_changes[key] = changes[key]["new"] + + if not entity_changes: + continue + + # Do not care about "Task" entity_type + task_object_id = self.task_object_id(session) + if entity_info.get("objectTypeId") == task_object_id: + continue + + interesting_data[entity_info["entityId"]] = entity_changes + return interesting_data + + def get_entities(self, session, interesting_data): + entities = session.query( + "TypedContext where id in ({})".format( + self.join_keys(interesting_data.keys()) + ) + ).all() + + output = [] + interest_object_ids = self.interest_object_ids(session) + for entity in entities: + if entity["object_type_id"] in interest_object_ids: + output.append(entity) + return output + + def get_task_entities(self, session, interesting_data): + return session.query( + "Task where parent_id in ({})".format( + self.join_keys(interesting_data.keys()) + ) + ).all() + + def attrs_configurations(self, session): + object_ids = list(self.interest_object_ids(session)) + object_ids.append(self.task_object_id(session)) + + attrs = session.query(self.cust_attrs_query.format( + self.join_keys(self.interest_attr_mapping.values()), + self.join_keys(object_ids) + )).all() + + output = {} + for attr in attrs: + obj_id = attr["object_type_id"] + if obj_id not in output: + output[obj_id] = {} + output[obj_id][attr["key"]] = attr["id"] + return output + + +def register(session, plugins_presets): + PushFrameValuesToTaskEvent(session, plugins_presets).register() diff --git a/pype/modules/ftrack/events/event_sync_to_avalon.py b/pype/modules/ftrack/events/event_sync_to_avalon.py index efcb74a608..d0c9ea2e96 100644 --- a/pype/modules/ftrack/events/event_sync_to_avalon.py +++ b/pype/modules/ftrack/events/event_sync_to_avalon.py @@ -19,12 +19,12 @@ from pype.modules.ftrack.lib.avalon_sync import ( import ftrack_api from pype.modules.ftrack import BaseEvent -from pype.modules.ftrack.lib.io_nonsingleton import DbConnector +from avalon.api import AvalonMongoDB class SyncToAvalonEvent(BaseEvent): - dbcon = DbConnector() + dbcon = AvalonMongoDB() interest_entTypes = ["show", "task"] ignore_ent_types = ["Milestone"] @@ -717,6 +717,9 @@ class SyncToAvalonEvent(BaseEvent): if not self.ftrack_removed: return ent_infos = self.ftrack_removed + self.log.debug( + "Processing removed entities: {}".format(str(ent_infos)) + ) removable_ids = [] recreate_ents = [] removed_names = [] @@ -878,8 +881,9 @@ class SyncToAvalonEvent(BaseEvent): self.process_session.commit() found_idx = None - for idx, _entity in enumerate(self._avalon_ents): - if _entity["_id"] == avalon_entity["_id"]: + proj_doc, asset_docs = self._avalon_ents + for idx, asset_doc in enumerate(asset_docs): + if asset_doc["_id"] == avalon_entity["_id"]: found_idx = idx break @@ -894,7 +898,8 @@ class SyncToAvalonEvent(BaseEvent): new_entity_id ) # Update cached entities - self._avalon_ents[found_idx] = avalon_entity + asset_docs[found_idx] = avalon_entity + self._avalon_ents = proj_doc, asset_docs if self._avalon_ents_by_id is not None: mongo_id = avalon_entity["_id"] @@ -1258,6 +1263,10 @@ class SyncToAvalonEvent(BaseEvent): if not ent_infos: return + self.log.debug( + "Processing renamed entities: {}".format(str(ent_infos)) + ) + renamed_tasks = {} not_found = {} changeable_queue = queue.Queue() @@ -1453,6 +1462,10 @@ class SyncToAvalonEvent(BaseEvent): if not ent_infos: return + self.log.debug( + "Processing added entities: {}".format(str(ent_infos)) + ) + cust_attrs, hier_attrs = self.avalon_cust_attrs entity_type_conf_ids = {} # Skip if already exit in avalon db or tasks entities @@ -1729,6 +1742,10 @@ class SyncToAvalonEvent(BaseEvent): if not self.ftrack_moved: return + self.log.debug( + "Processing moved entities: {}".format(str(self.ftrack_moved)) + ) + ftrack_moved = {k: v for k, v in sorted( self.ftrack_moved.items(), key=(lambda line: len( @@ -1859,6 +1876,10 @@ class SyncToAvalonEvent(BaseEvent): if not self.ftrack_updated: return + self.log.debug( + "Processing updated entities: {}".format(str(self.ftrack_updated)) + ) + ent_infos = self.ftrack_updated ftrack_mongo_mapping = {} not_found_ids = [] diff --git a/pype/modules/ftrack/events/event_user_assigment.py b/pype/modules/ftrack/events/event_user_assigment.py index d1b3439c8f..19a67b745f 100644 --- a/pype/modules/ftrack/events/event_user_assigment.py +++ b/pype/modules/ftrack/events/event_user_assigment.py @@ -4,7 +4,7 @@ import subprocess from pype.modules.ftrack import BaseEvent from pype.modules.ftrack.lib.avalon_sync import CUST_ATTR_ID_KEY -from pype.modules.ftrack.lib.io_nonsingleton import DbConnector +from avalon.api import AvalonMongoDB from bson.objectid import ObjectId @@ -37,7 +37,7 @@ class UserAssigmentEvent(BaseEvent): 3) path to publish files of task user was (de)assigned to """ - db_con = DbConnector() + db_con = AvalonMongoDB() def error(self, *err): for e in err: diff --git a/pype/modules/ftrack/ftrack_server/__init__.py b/pype/modules/ftrack/ftrack_server/__init__.py index fcae4e0690..9e3920b500 100644 --- a/pype/modules/ftrack/ftrack_server/__init__.py +++ b/pype/modules/ftrack/ftrack_server/__init__.py @@ -1,2 +1,8 @@ from .ftrack_server import FtrackServer from .lib import check_ftrack_url + + +__all__ = ( + "FtrackServer", + "check_ftrack_url" +) diff --git a/pype/modules/ftrack/lib/custom_db_connector.py b/pype/modules/ftrack/ftrack_server/custom_db_connector.py similarity index 71% rename from pype/modules/ftrack/lib/custom_db_connector.py rename to pype/modules/ftrack/ftrack_server/custom_db_connector.py index d498d041dc..8a8ba4ccbb 100644 --- a/pype/modules/ftrack/lib/custom_db_connector.py +++ b/pype/modules/ftrack/ftrack_server/custom_db_connector.py @@ -16,9 +16,9 @@ import pymongo from pype.api import decompose_url -class NotActiveTable(Exception): +class NotActiveCollection(Exception): def __init__(self, *args, **kwargs): - msg = "Active table is not set. (This is bug)" + msg = "Active collection is not set. (This is bug)" if not (args or kwargs): args = [msg] super().__init__(*args, **kwargs) @@ -40,12 +40,12 @@ def auto_reconnect(func): return decorated -def check_active_table(func): +def check_active_collection(func): """Check if CustomDbConnector has active collection.""" @functools.wraps(func) def decorated(obj, *args, **kwargs): - if not obj.active_table: - raise NotActiveTable() + if not obj.active_collection: + raise NotActiveCollection() return func(obj, *args, **kwargs) return decorated @@ -55,7 +55,7 @@ class CustomDbConnector: timeout = int(os.environ["AVALON_TIMEOUT"]) def __init__( - self, uri, database_name, port=None, table_name=None + self, uri, database_name, port=None, collection_name=None ): self._mongo_client = None self._sentry_client = None @@ -76,10 +76,10 @@ class CustomDbConnector: self._port = port self._database_name = database_name - self.active_table = table_name + self.active_collection = collection_name def __getitem__(self, key): - # gives direct access to collection withou setting `active_table` + # gives direct access to collection withou setting `active_collection` return self._database[key] def __getattribute__(self, attr): @@ -88,9 +88,11 @@ class CustomDbConnector: try: return super(CustomDbConnector, self).__getattribute__(attr) except AttributeError: - if self.active_table is None: - raise NotActiveTable() - return self._database[self.active_table].__getattribute__(attr) + if self.active_collection is None: + raise NotActiveCollection() + return self._database[self.active_collection].__getattribute__( + attr + ) def install(self): """Establish a persistent connection to the database""" @@ -146,46 +148,30 @@ class CustomDbConnector: self._is_installed = False atexit.unregister(self.uninstall) - def create_table(self, name, **options): - if self.exist_table(name): + def collection_exists(self, collection_name): + return collection_name in self.collections() + + def create_collection(self, name, **options): + if self.collection_exists(name): return return self._database.create_collection(name, **options) - def exist_table(self, table_name): - return table_name in self.tables() - - def create_table(self, name, **options): - if self.exist_table(name): - return - - return self._database.create_collection(name, **options) - - def exist_table(self, table_name): - return table_name in self.tables() - - def tables(self): - """List available tables - Returns: - list of table names - """ - collection_names = self.collections() - for table_name in collection_names: - if table_name in ("system.indexes",): - continue - yield table_name - @auto_reconnect def collections(self): - return self._database.collection_names() + for col_name in self._database.collection_names(): + if col_name not in ("system.indexes",): + yield col_name - @check_active_table + @check_active_collection @auto_reconnect def insert_one(self, item, **options): assert isinstance(item, dict), "item must be of type " - return self._database[self.active_table].insert_one(item, **options) + return self._database[self.active_collection].insert_one( + item, **options + ) - @check_active_table + @check_active_collection @auto_reconnect def insert_many(self, items, ordered=True, **options): # check if all items are valid @@ -194,72 +180,74 @@ class CustomDbConnector: assert isinstance(item, dict), "`item` must be of type " options["ordered"] = ordered - return self._database[self.active_table].insert_many(items, **options) + return self._database[self.active_collection].insert_many( + items, **options + ) - @check_active_table + @check_active_collection @auto_reconnect def find(self, filter, projection=None, sort=None, **options): options["sort"] = sort - return self._database[self.active_table].find( + return self._database[self.active_collection].find( filter, projection, **options ) - @check_active_table + @check_active_collection @auto_reconnect def find_one(self, filter, projection=None, sort=None, **options): assert isinstance(filter, dict), "filter must be " options["sort"] = sort - return self._database[self.active_table].find_one( + return self._database[self.active_collection].find_one( filter, projection, **options ) - @check_active_table + @check_active_collection @auto_reconnect def replace_one(self, filter, replacement, **options): - return self._database[self.active_table].replace_one( + return self._database[self.active_collection].replace_one( filter, replacement, **options ) - @check_active_table + @check_active_collection @auto_reconnect def update_one(self, filter, update, **options): - return self._database[self.active_table].update_one( + return self._database[self.active_collection].update_one( filter, update, **options ) - @check_active_table + @check_active_collection @auto_reconnect def update_many(self, filter, update, **options): - return self._database[self.active_table].update_many( + return self._database[self.active_collection].update_many( filter, update, **options ) - @check_active_table + @check_active_collection @auto_reconnect def distinct(self, **options): - return self._database[self.active_table].distinct(**options) + return self._database[self.active_collection].distinct(**options) - @check_active_table + @check_active_collection @auto_reconnect def drop_collection(self, name_or_collection, **options): - return self._database[self.active_table].drop( + return self._database[self.active_collection].drop( name_or_collection, **options ) - @check_active_table + @check_active_collection @auto_reconnect def delete_one(self, filter, collation=None, **options): options["collation"] = collation - return self._database[self.active_table].delete_one( + return self._database[self.active_collection].delete_one( filter, **options ) - @check_active_table + @check_active_collection @auto_reconnect def delete_many(self, filter, collation=None, **options): options["collation"] = collation - return self._database[self.active_table].delete_many( + return self._database[self.active_collection].delete_many( filter, **options ) diff --git a/pype/modules/ftrack/ftrack_server/lib.py b/pype/modules/ftrack/ftrack_server/lib.py index acf31ab437..79b708b17a 100644 --- a/pype/modules/ftrack/ftrack_server/lib.py +++ b/pype/modules/ftrack/ftrack_server/lib.py @@ -26,7 +26,7 @@ from pype.api import ( compose_url ) -from pype.modules.ftrack.lib.custom_db_connector import CustomDbConnector +from .custom_db_connector import CustomDbConnector TOPIC_STATUS_SERVER = "pype.event.server.status" @@ -153,9 +153,9 @@ class StorerEventHub(SocketBaseEventHub): class ProcessEventHub(SocketBaseEventHub): hearbeat_msg = b"processor" - uri, port, database, table_name = get_ftrack_event_mongo_info() + uri, port, database, collection_name = get_ftrack_event_mongo_info() - is_table_created = False + is_collection_created = False pypelog = Logger().get_logger("Session Processor") def __init__(self, *args, **kwargs): @@ -163,7 +163,7 @@ class ProcessEventHub(SocketBaseEventHub): self.uri, self.database, self.port, - self.table_name + self.collection_name ) super(ProcessEventHub, self).__init__(*args, **kwargs) @@ -184,7 +184,7 @@ class ProcessEventHub(SocketBaseEventHub): "Error with Mongo access, probably permissions." "Check if exist database with name \"{}\"" " and collection \"{}\" inside." - ).format(self.database, self.table_name)) + ).format(self.database, self.collection_name)) self.sock.sendall(b"MongoError") sys.exit(0) @@ -205,10 +205,16 @@ class ProcessEventHub(SocketBaseEventHub): else: try: self._handle(event) + + mongo_id = event["data"].get("_event_mongo_id") + if mongo_id is None: + continue + self.dbcon.update_one( - {"id": event["id"]}, + {"_id": mongo_id}, {"$set": {"pype_data.is_processed": True}} ) + except pymongo.errors.AutoReconnect: self.pypelog.error(( "Mongo server \"{}\" is not responding, exiting." @@ -244,6 +250,7 @@ class ProcessEventHub(SocketBaseEventHub): } try: event = ftrack_api.event.base.Event(**new_event_data) + event["data"]["_event_mongo_id"] = event_data["_id"] except Exception: self.logger.exception(L( 'Failed to convert payload into event: {0}', diff --git a/pype/modules/ftrack/ftrack_server/sub_event_status.py b/pype/modules/ftrack/ftrack_server/sub_event_status.py index c2e7b7477f..00a6687de3 100644 --- a/pype/modules/ftrack/ftrack_server/sub_event_status.py +++ b/pype/modules/ftrack/ftrack_server/sub_event_status.py @@ -12,7 +12,7 @@ from pype.modules.ftrack.ftrack_server.lib import ( SocketSession, StatusEventHub, TOPIC_STATUS_SERVER, TOPIC_STATUS_SERVER_RESULT ) -from pype.api import Logger, config +from pype.api import Logger log = Logger().get_logger("Event storer") action_identifier = ( @@ -23,17 +23,7 @@ action_data = { "label": "Pype Admin", "variant": "- Event server Status ({})".format(host_ip), "description": "Get Infromation about event server", - "actionIdentifier": action_identifier, - "icon": "{}/ftrack/action_icons/PypeAdmin.svg".format( - os.environ.get( - "PYPE_STATICS_SERVER", - "http://localhost:{}".format( - config.get_presets().get("services", {}).get( - "rest_api", {} - ).get("default_port", 8021) - ) - ) - ) + "actionIdentifier": action_identifier } diff --git a/pype/modules/ftrack/ftrack_server/sub_event_storer.py b/pype/modules/ftrack/ftrack_server/sub_event_storer.py index 1635f6cea3..2f4395c8db 100644 --- a/pype/modules/ftrack/ftrack_server/sub_event_storer.py +++ b/pype/modules/ftrack/ftrack_server/sub_event_storer.py @@ -12,7 +12,9 @@ from pype.modules.ftrack.ftrack_server.lib import ( get_ftrack_event_mongo_info, TOPIC_STATUS_SERVER, TOPIC_STATUS_SERVER_RESULT ) -from pype.modules.ftrack.lib.custom_db_connector import CustomDbConnector +from pype.modules.ftrack.ftrack_server.custom_db_connector import ( + CustomDbConnector +) from pype.api import Logger log = Logger().get_logger("Event storer") @@ -23,8 +25,8 @@ class SessionFactory: session = None -uri, port, database, table_name = get_ftrack_event_mongo_info() -dbcon = CustomDbConnector(uri, database, port, table_name) +uri, port, database, collection_name = get_ftrack_event_mongo_info() +dbcon = CustomDbConnector(uri, database, port, collection_name) # ignore_topics = ["ftrack.meta.connected"] ignore_topics = [] @@ -200,7 +202,7 @@ def main(args): "Error with Mongo access, probably permissions." "Check if exist database with name \"{}\"" " and collection \"{}\" inside." - ).format(database, table_name)) + ).format(database, collection_name)) sock.sendall(b"MongoError") finally: diff --git a/pype/modules/ftrack/lib/avalon_sync.py b/pype/modules/ftrack/lib/avalon_sync.py index 4bab1676d4..28114c7fdc 100644 --- a/pype/modules/ftrack/lib/avalon_sync.py +++ b/pype/modules/ftrack/lib/avalon_sync.py @@ -5,7 +5,7 @@ import json import collections import copy -from pype.modules.ftrack.lib.io_nonsingleton import DbConnector +from avalon.api import AvalonMongoDB import avalon import avalon.api @@ -16,6 +16,7 @@ from bson.objectid import ObjectId from bson.errors import InvalidId from pymongo import UpdateOne import ftrack_api +from pype.api import config log = Logger().get_logger(__name__) @@ -23,9 +24,9 @@ log = Logger().get_logger(__name__) # Current schemas for avalon types EntitySchemas = { - "project": "avalon-core:project-2.0", + "project": "avalon-core:project-2.1", "asset": "avalon-core:asset-3.0", - "config": "avalon-core:config-1.0" + "config": "avalon-core:config-1.1" } # Group name of custom attributes @@ -50,7 +51,7 @@ def check_regex(name, entity_type, in_schema=None, schema_patterns=None): if in_schema: schema_name = in_schema elif entity_type == "project": - schema_name = "project-2.0" + schema_name = "project-2.1" elif entity_type == "task": schema_name = "task" @@ -103,6 +104,14 @@ def get_pype_attr(session, split_hierarchical=True): def from_dict_to_set(data): + """ + Converts 'data' into $set part of MongoDB update command. + Args: + data: (dictionary) - up-to-date data from Ftrack + + Returns: + (dictionary) - { "$set" : "{..}"} + """ result = {"$set": {}} dict_queue = queue.Queue() dict_queue.put((None, data)) @@ -114,7 +123,8 @@ def from_dict_to_set(data): if _key is not None: new_key = "{}.{}".format(_key, key) - if not isinstance(value, dict): + if not isinstance(value, dict) or \ + (isinstance(value, dict) and not bool(value)): # empty dic result["$set"][new_key] = value continue dict_queue.put((new_key, value)) @@ -123,6 +133,8 @@ def from_dict_to_set(data): def get_avalon_project_template(project_name): """Get avalon template + Args: + project_name: (string) Returns: dictionary with templates """ @@ -135,6 +147,16 @@ def get_avalon_project_template(project_name): def get_project_apps(in_app_list): + """ + Returns metadata information about apps in 'in_app_list' enhanced + from toml files. + Args: + in_app_list: (list) - names of applications + + Returns: + tuple (list, dictionary) - list of dictionaries about apps + dictionary of warnings + """ apps = [] # TODO report missing_toml_msg = "Missing config file for application" @@ -239,8 +261,30 @@ def get_hierarchical_attributes(session, entity, attr_names, attr_defaults={}): return hier_values +def get_task_short_name(task_type): + """ + Returns short name (code) for 'task_type'. Short name stored in + metadata dictionary in project.config per each 'task_type'. + Could be used in anatomy, paths etc. + If no appropriate short name is found in mapping, 'task_type' is + returned back unchanged. + + Currently stores data in: + 'pype-config/presets/ftrack/project_defaults.json' + Args: + task_type: (string) - Animation | Modeling ... + + Returns: + (string) - anim | model ... + """ + presets = config.get_presets()['ftrack']['project_defaults']\ + .get("task_short_names") + + return presets.get(task_type, task_type) + + class SyncEntitiesFactory: - dbcon = DbConnector() + dbcon = AvalonMongoDB() project_query = ( "select full_name, name, custom_attributes" @@ -378,7 +422,7 @@ class SyncEntitiesFactory: "custom_attributes": {}, "hier_attrs": {}, "avalon_attrs": {}, - "tasks": [] + "tasks": {} }) for entity in all_project_entities: @@ -389,7 +433,9 @@ class SyncEntitiesFactory: continue elif entity_type_low == "task": - entities_dict[parent_id]["tasks"].append(entity["name"]) + # enrich task info with additional metadata + task = {"type": entity["type"]["name"]} + entities_dict[parent_id]["tasks"][entity["name"]] = task continue entity_id = entity["id"] @@ -416,6 +462,13 @@ class SyncEntitiesFactory: @property def avalon_ents_by_id(self): + """ + Returns dictionary of avalon tracked entities (assets stored in + MongoDB) accessible by its '_id' + (mongo intenal ID - example ObjectId("5f48de5830a9467b34b69798")) + Returns: + (dictionary) - {"(_id)": whole entity asset} + """ if self._avalon_ents_by_id is None: self._avalon_ents_by_id = {} for entity in self.avalon_entities: @@ -425,6 +478,14 @@ class SyncEntitiesFactory: @property def avalon_ents_by_ftrack_id(self): + """ + Returns dictionary of Mongo ids of avalon tracked entities + (assets stored in MongoDB) accessible by its 'ftrackId' + (id from ftrack) + (example '431ee3f2-e91a-11ea-bfa4-92591a5b5e3e') + Returns: + (dictionary) - {"(ftrackId)": "_id"} + """ if self._avalon_ents_by_ftrack_id is None: self._avalon_ents_by_ftrack_id = {} for entity in self.avalon_entities: @@ -437,6 +498,13 @@ class SyncEntitiesFactory: @property def avalon_ents_by_name(self): + """ + Returns dictionary of Mongo ids of avalon tracked entities + (assets stored in MongoDB) accessible by its 'name' + (example 'Hero') + Returns: + (dictionary) - {"(name)": "_id"} + """ if self._avalon_ents_by_name is None: self._avalon_ents_by_name = {} for entity in self.avalon_entities: @@ -446,6 +514,15 @@ class SyncEntitiesFactory: @property def avalon_ents_by_parent_id(self): + """ + Returns dictionary of avalon tracked entities + (assets stored in MongoDB) accessible by its 'visualParent' + (example ObjectId("5f48de5830a9467b34b69798")) + + Fills 'self._avalon_archived_ents' for performance + Returns: + (dictionary) - {"(_id)": whole entity} + """ if self._avalon_ents_by_parent_id is None: self._avalon_ents_by_parent_id = collections.defaultdict(list) for entity in self.avalon_entities: @@ -458,6 +535,14 @@ class SyncEntitiesFactory: @property def avalon_archived_ents(self): + """ + Returns list of archived assets from DB + (their "type" == 'archived_asset') + + Fills 'self._avalon_archived_ents' for performance + Returns: + (list) of assets + """ if self._avalon_archived_ents is None: self._avalon_archived_ents = [ ent for ent in self.dbcon.find({"type": "archived_asset"}) @@ -466,6 +551,14 @@ class SyncEntitiesFactory: @property def avalon_archived_by_name(self): + """ + Returns list of archived assets from DB + (their "type" == 'archived_asset') + + Fills 'self._avalon_archived_by_name' for performance + Returns: + (dictionary of lists) of assets accessible by asset name + """ if self._avalon_archived_by_name is None: self._avalon_archived_by_name = collections.defaultdict(list) for ent in self.avalon_archived_ents: @@ -474,6 +567,14 @@ class SyncEntitiesFactory: @property def avalon_archived_by_id(self): + """ + Returns dictionary of archived assets from DB + (their "type" == 'archived_asset') + + Fills 'self._avalon_archived_by_id' for performance + Returns: + (dictionary) of assets accessible by asset mongo _id + """ if self._avalon_archived_by_id is None: self._avalon_archived_by_id = { str(ent["_id"]): ent for ent in self.avalon_archived_ents @@ -482,6 +583,15 @@ class SyncEntitiesFactory: @property def avalon_archived_by_parent_id(self): + """ + Returns dictionary of archived assets from DB per their's parent + (their "type" == 'archived_asset') + + Fills 'self._avalon_archived_by_parent_id' for performance + Returns: + (dictionary of lists) of assets accessible by asset parent + mongo _id + """ if self._avalon_archived_by_parent_id is None: self._avalon_archived_by_parent_id = collections.defaultdict(list) for entity in self.avalon_archived_ents: @@ -494,6 +604,14 @@ class SyncEntitiesFactory: @property def subsets_by_parent_id(self): + """ + Returns dictionary of subsets from Mongo ("type": "subset") + grouped by their parent. + + Fills 'self._subsets_by_parent_id' for performance + Returns: + (dictionary of lists) + """ if self._subsets_by_parent_id is None: self._subsets_by_parent_id = collections.defaultdict(list) for subset in self.dbcon.find({"type": "subset"}): @@ -515,6 +633,11 @@ class SyncEntitiesFactory: @property def all_ftrack_names(self): + """ + Returns lists of names of all entities in Ftrack + Returns: + (list) + """ return [ ent_dict["name"] for ent_dict in self.entities_dict.values() if ( ent_dict.get("name") @@ -534,8 +657,9 @@ class SyncEntitiesFactory: name = entity_dict["name"] entity_type = entity_dict["entity_type"] # Tasks must be checked too - for task_name in entity_dict["tasks"]: - passed = task_names.get(task_name) + for task in entity_dict["tasks"].items(): + task_name, task = task + passed = task_name if passed is None: passed = check_regex( task_name, "task", schema_patterns=_schema_patterns @@ -1014,22 +1138,26 @@ class SyncEntitiesFactory: if not msg or not items: continue self.report_items["warning"][msg] = items - + tasks = {} + for tt in task_types: + tasks[tt["name"]] = { + "short_name": get_task_short_name(tt["name"]) + } self.entities_dict[id]["final_entity"]["config"] = { - "tasks": [{"name": tt["name"]} for tt in task_types], + "tasks": tasks, "apps": proj_apps } continue ent_path_items = [ent["name"] for ent in entity["link"]] - parents = ent_path_items[1:len(ent_path_items)-1:] + parents = ent_path_items[1:len(ent_path_items) - 1:] hierarchy = "" if len(parents) > 0: hierarchy = os.path.sep.join(parents) data["parents"] = parents data["hierarchy"] = hierarchy - data["tasks"] = self.entities_dict[id].pop("tasks", []) + data["tasks"] = self.entities_dict[id].pop("tasks", {}) self.entities_dict[id]["final_entity"]["data"] = data self.entities_dict[id]["final_entity"]["type"] = "asset" @@ -1141,7 +1269,7 @@ class SyncEntitiesFactory: if not is_right and not else_match_better: entity = entity_dict["entity"] ent_path_items = [ent["name"] for ent in entity["link"]] - parents = ent_path_items[1:len(ent_path_items)-1:] + parents = ent_path_items[1:len(ent_path_items) - 1:] av_parents = av_ent_by_mongo_id["data"]["parents"] if av_parents == parents: is_right = True @@ -1904,10 +2032,10 @@ class SyncEntitiesFactory: filter = {"_id": ObjectId(mongo_id)} change_data = from_dict_to_set(changes) mongo_changes_bulk.append(UpdateOne(filter, change_data)) - if not mongo_changes_bulk: # TODO LOG return + log.debug("mongo_changes_bulk:: {}".format(mongo_changes_bulk)) self.dbcon.bulk_write(mongo_changes_bulk) def reload_parents(self, hierarchy_changing_ids): @@ -2144,6 +2272,7 @@ class SyncEntitiesFactory: "name": _name, "parent": parent_entity }) + self.session.commit() final_entity = {} for k, v in av_entity.items(): diff --git a/pype/modules/ftrack/lib/ftrack_base_handler.py b/pype/modules/ftrack/lib/ftrack_base_handler.py index ce6607d6bf..d322fbaf23 100644 --- a/pype/modules/ftrack/lib/ftrack_base_handler.py +++ b/pype/modules/ftrack/lib/ftrack_base_handler.py @@ -2,7 +2,7 @@ import functools import time from pype.api import Logger import ftrack_api -from pype.modules.ftrack.ftrack_server.lib import SocketSession +from pype.modules.ftrack import ftrack_server class MissingPermision(Exception): @@ -41,7 +41,7 @@ class BaseHandler(object): self.log = Logger().get_logger(self.__class__.__name__) if not( isinstance(session, ftrack_api.session.Session) or - isinstance(session, SocketSession) + isinstance(session, ftrack_server.lib.SocketSession) ): raise Exception(( "Session object entered with args is instance of \"{}\"" @@ -49,7 +49,7 @@ class BaseHandler(object): ).format( str(type(session)), str(ftrack_api.session.Session), - str(SocketSession) + str(ftrack_server.lib.SocketSession) )) self._session = session diff --git a/pype/modules/ftrack/lib/io_nonsingleton.py b/pype/modules/ftrack/lib/io_nonsingleton.py deleted file mode 100644 index da37c657c6..0000000000 --- a/pype/modules/ftrack/lib/io_nonsingleton.py +++ /dev/null @@ -1,460 +0,0 @@ -""" -Wrapper around interactions with the database - -Copy of io module in avalon-core. - - In this case not working as singleton with api.Session! -""" - -import os -import time -import errno -import shutil -import logging -import tempfile -import functools -import contextlib - -from avalon import schema -from avalon.vendor import requests -from avalon.io import extract_port_from_url - -# Third-party dependencies -import pymongo - - -def auto_reconnect(func): - """Handling auto reconnect in 3 retry times""" - @functools.wraps(func) - def decorated(*args, **kwargs): - object = args[0] - for retry in range(3): - try: - return func(*args, **kwargs) - except pymongo.errors.AutoReconnect: - object.log.error("Reconnecting..") - time.sleep(0.1) - else: - raise - - return decorated - - -class DbConnector(object): - - log = logging.getLogger(__name__) - - def __init__(self): - self.Session = {} - self._mongo_client = None - self._sentry_client = None - self._sentry_logging_handler = None - self._database = None - self._is_installed = False - - def __getitem__(self, key): - # gives direct access to collection withou setting `active_table` - return self._database[key] - - def __getattribute__(self, attr): - # not all methods of PyMongo database are implemented with this it is - # possible to use them too - try: - return super(DbConnector, self).__getattribute__(attr) - except AttributeError: - cur_proj = self.Session["AVALON_PROJECT"] - return self._database[cur_proj].__getattribute__(attr) - - def install(self): - """Establish a persistent connection to the database""" - if self._is_installed: - return - - logging.basicConfig() - self.Session.update(self._from_environment()) - - timeout = int(self.Session["AVALON_TIMEOUT"]) - mongo_url = self.Session["AVALON_MONGO"] - kwargs = { - "host": mongo_url, - "serverSelectionTimeoutMS": timeout - } - - port = extract_port_from_url(mongo_url) - if port is not None: - kwargs["port"] = int(port) - - self._mongo_client = pymongo.MongoClient(**kwargs) - - for retry in range(3): - try: - t1 = time.time() - self._mongo_client.server_info() - - except Exception: - self.log.error("Retrying..") - time.sleep(1) - timeout *= 1.5 - - else: - break - - else: - raise IOError( - "ERROR: Couldn't connect to %s in " - "less than %.3f ms" % (self.Session["AVALON_MONGO"], timeout)) - - self.log.info("Connected to %s, delay %.3f s" % ( - self.Session["AVALON_MONGO"], time.time() - t1)) - - self._install_sentry() - - self._database = self._mongo_client[self.Session["AVALON_DB"]] - self._is_installed = True - - def _install_sentry(self): - if "AVALON_SENTRY" not in self.Session: - return - - try: - from raven import Client - from raven.handlers.logging import SentryHandler - from raven.conf import setup_logging - except ImportError: - # Note: There was a Sentry address in this Session - return self.log.warning("Sentry disabled, raven not installed") - - client = Client(self.Session["AVALON_SENTRY"]) - - # Transmit log messages to Sentry - handler = SentryHandler(client) - handler.setLevel(logging.WARNING) - - setup_logging(handler) - - self._sentry_client = client - self._sentry_logging_handler = handler - self.log.info( - "Connected to Sentry @ %s" % self.Session["AVALON_SENTRY"] - ) - - def _from_environment(self): - Session = { - item[0]: os.getenv(item[0], item[1]) - for item in ( - # Root directory of projects on disk - ("AVALON_PROJECTS", None), - - # Name of current Project - ("AVALON_PROJECT", ""), - - # Name of current Asset - ("AVALON_ASSET", ""), - - # Name of current silo - ("AVALON_SILO", ""), - - # Name of current task - ("AVALON_TASK", None), - - # Name of current app - ("AVALON_APP", None), - - # Path to working directory - ("AVALON_WORKDIR", None), - - # Name of current Config - # TODO(marcus): Establish a suitable default config - ("AVALON_CONFIG", "no_config"), - - # Name of Avalon in graphical user interfaces - # Use this to customise the visual appearance of Avalon - # to better integrate with your surrounding pipeline - ("AVALON_LABEL", "Avalon"), - - # Used during any connections to the outside world - ("AVALON_TIMEOUT", "1000"), - - # Address to Asset Database - ("AVALON_MONGO", "mongodb://localhost:27017"), - - # Name of database used in MongoDB - ("AVALON_DB", "avalon"), - - # Address to Sentry - ("AVALON_SENTRY", None), - - # Address to Deadline Web Service - # E.g. http://192.167.0.1:8082 - ("AVALON_DEADLINE", None), - - # Enable features not necessarily stable. The user's own risk - ("AVALON_EARLY_ADOPTER", None), - - # Address of central asset repository, contains - # the following interface: - # /upload - # /download - # /manager (optional) - ("AVALON_LOCATION", "http://127.0.0.1"), - - # Boolean of whether to upload published material - # to central asset repository - ("AVALON_UPLOAD", None), - - # Generic username and password - ("AVALON_USERNAME", "avalon"), - ("AVALON_PASSWORD", "secret"), - - # Unique identifier for instances in working files - ("AVALON_INSTANCE_ID", "avalon.instance"), - ("AVALON_CONTAINER_ID", "avalon.container"), - - # Enable debugging - ("AVALON_DEBUG", None), - - ) if os.getenv(item[0], item[1]) is not None - } - - Session["schema"] = "avalon-core:session-2.0" - try: - schema.validate(Session) - except schema.ValidationError as e: - # TODO(marcus): Make this mandatory - self.log.warning(e) - - return Session - - def uninstall(self): - """Close any connection to the database""" - try: - self._mongo_client.close() - except AttributeError: - pass - - self._mongo_client = None - self._database = None - self._is_installed = False - - def active_project(self): - """Return the name of the active project""" - return self.Session["AVALON_PROJECT"] - - def activate_project(self, project_name): - self.Session["AVALON_PROJECT"] = project_name - - def projects(self): - """List available projects - - Returns: - list of project documents - - """ - - collection_names = self.collections() - for project in collection_names: - if project in ("system.indexes",): - continue - - # Each collection will have exactly one project document - document = self.find_project(project) - - if document is not None: - yield document - - def locate(self, path): - """Traverse a hierarchy from top-to-bottom - - Example: - representation = locate(["hulk", "Bruce", "modelDefault", 1, "ma"]) - - Returns: - representation (ObjectId) - - """ - - components = zip( - ("project", "asset", "subset", "version", "representation"), - path - ) - - parent = None - for type_, name in components: - latest = (type_ == "version") and name in (None, -1) - - try: - if latest: - parent = self.find_one( - filter={ - "type": type_, - "parent": parent - }, - projection={"_id": 1}, - sort=[("name", -1)] - )["_id"] - else: - parent = self.find_one( - filter={ - "type": type_, - "name": name, - "parent": parent - }, - projection={"_id": 1}, - )["_id"] - - except TypeError: - return None - - return parent - - @auto_reconnect - def collections(self): - return self._database.collection_names() - - @auto_reconnect - def find_project(self, project): - return self._database[project].find_one({"type": "project"}) - - @auto_reconnect - def insert_one(self, item): - assert isinstance(item, dict), "item must be of type " - schema.validate(item) - return self._database[self.Session["AVALON_PROJECT"]].insert_one(item) - - @auto_reconnect - def insert_many(self, items, ordered=True): - # check if all items are valid - assert isinstance(items, list), "`items` must be of type " - for item in items: - assert isinstance(item, dict), "`item` must be of type " - schema.validate(item) - - return self._database[self.Session["AVALON_PROJECT"]].insert_many( - items, - ordered=ordered) - - @auto_reconnect - def find(self, filter, projection=None, sort=None): - return self._database[self.Session["AVALON_PROJECT"]].find( - filter=filter, - projection=projection, - sort=sort - ) - - @auto_reconnect - def find_one(self, filter, projection=None, sort=None): - assert isinstance(filter, dict), "filter must be " - - return self._database[self.Session["AVALON_PROJECT"]].find_one( - filter=filter, - projection=projection, - sort=sort - ) - - @auto_reconnect - def save(self, *args, **kwargs): - return self._database[self.Session["AVALON_PROJECT"]].save( - *args, **kwargs) - - @auto_reconnect - def replace_one(self, filter, replacement): - return self._database[self.Session["AVALON_PROJECT"]].replace_one( - filter, replacement) - - @auto_reconnect - def update_many(self, filter, update): - return self._database[self.Session["AVALON_PROJECT"]].update_many( - filter, update) - - @auto_reconnect - def distinct(self, *args, **kwargs): - return self._database[self.Session["AVALON_PROJECT"]].distinct( - *args, **kwargs) - - @auto_reconnect - def drop(self, *args, **kwargs): - return self._database[self.Session["AVALON_PROJECT"]].drop( - *args, **kwargs) - - @auto_reconnect - def delete_many(self, *args, **kwargs): - return self._database[self.Session["AVALON_PROJECT"]].delete_many( - *args, **kwargs) - - def parenthood(self, document): - assert document is not None, "This is a bug" - - parents = list() - - while document.get("parent") is not None: - document = self.find_one({"_id": document["parent"]}) - - if document is None: - break - - if document.get("type") == "master_version": - _document = self.find_one({"_id": document["version_id"]}) - document["data"] = _document["data"] - - parents.append(document) - - return parents - - @contextlib.contextmanager - def tempdir(self): - tempdir = tempfile.mkdtemp() - try: - yield tempdir - finally: - shutil.rmtree(tempdir) - - def download(self, src, dst): - """Download `src` to `dst` - - Arguments: - src (str): URL to source file - dst (str): Absolute path to destination file - - Yields tuple (progress, error): - progress (int): Between 0-100 - error (Exception): Any exception raised when first making connection - - """ - - try: - response = requests.get( - src, - stream=True, - auth=requests.auth.HTTPBasicAuth( - self.Session["AVALON_USERNAME"], - self.Session["AVALON_PASSWORD"] - ) - ) - except requests.ConnectionError as e: - yield None, e - return - - with self.tempdir() as dirname: - tmp = os.path.join(dirname, os.path.basename(src)) - - with open(tmp, "wb") as f: - total_length = response.headers.get("content-length") - - if total_length is None: # no content length header - f.write(response.content) - else: - downloaded = 0 - total_length = int(total_length) - for data in response.iter_content(chunk_size=4096): - downloaded += len(data) - f.write(data) - - yield int(100.0 * downloaded / total_length), None - - try: - os.makedirs(os.path.dirname(dst)) - except OSError as e: - # An already existing destination directory is fine. - if e.errno != errno.EEXIST: - raise - - shutil.copy(tmp, dst) diff --git a/pype/modules/ftrack/tray/ftrack_module.py b/pype/modules/ftrack/tray/ftrack_module.py index 674e8cbd4f..0b011c5b33 100644 --- a/pype/modules/ftrack/tray/ftrack_module.py +++ b/pype/modules/ftrack/tray/ftrack_module.py @@ -2,7 +2,7 @@ import os import time import datetime import threading -from Qt import QtCore, QtWidgets +from Qt import QtCore, QtWidgets, QtGui import ftrack_api from ..ftrack_server.lib import check_ftrack_url @@ -10,7 +10,7 @@ from ..ftrack_server import socket_thread from ..lib import credentials from . import login_dialog -from pype.api import Logger +from pype.api import Logger, resources log = Logger().get_logger("FtrackModule", "ftrack") @@ -19,7 +19,7 @@ log = Logger().get_logger("FtrackModule", "ftrack") class FtrackModule: def __init__(self, main_parent=None, parent=None): self.parent = parent - self.widget_login = login_dialog.Login_Dialog_ui(self) + self.thread_action_server = None self.thread_socket_server = None self.thread_timer = None @@ -29,8 +29,22 @@ class FtrackModule: self.bool_action_thread_running = False self.bool_timer_event = False + self.widget_login = login_dialog.CredentialsDialog() + self.widget_login.login_changed.connect(self.on_login_change) + self.widget_login.logout_signal.connect(self.on_logout) + + self.action_credentials = None + self.icon_logged = QtGui.QIcon( + resources.get_resource("icons", "circle_green.png") + ) + self.icon_not_logged = QtGui.QIcon( + resources.get_resource("icons", "circle_orange.png") + ) + def show_login_widget(self): self.widget_login.show() + self.widget_login.activateWindow() + self.widget_login.raise_() def validate(self): validation = False @@ -39,9 +53,10 @@ class FtrackModule: ft_api_key = cred.get("api_key") validation = credentials.check_credentials(ft_user, ft_api_key) if validation: + self.widget_login.set_credentials(ft_user, ft_api_key) credentials.set_env(ft_user, ft_api_key) log.info("Connected to Ftrack successfully") - self.loginChange() + self.on_login_change() return validation @@ -60,15 +75,28 @@ class FtrackModule: return validation # Necessary - login_dialog works with this method after logging in - def loginChange(self): + def on_login_change(self): self.bool_logged = True + + if self.action_credentials: + self.action_credentials.setIcon(self.icon_logged) + self.action_credentials.setToolTip( + "Logged as user \"{}\"".format( + self.widget_login.user_input.text() + ) + ) + self.set_menu_visibility() self.start_action_server() - def logout(self): + def on_logout(self): credentials.clear_credentials() self.stop_action_server() + if self.action_credentials: + self.action_credentials.setIcon(self.icon_not_logged) + self.action_credentials.setToolTip("Logged out") + log.info("Logged out of Ftrack") self.bool_logged = False self.set_menu_visibility() @@ -218,43 +246,45 @@ class FtrackModule: # Definition of Tray menu def tray_menu(self, parent_menu): # Menu for Tray App - self.menu = QtWidgets.QMenu('Ftrack', parent_menu) - self.menu.setProperty('submenu', 'on') - - # Actions - server - self.smActionS = self.menu.addMenu("Action server") - - self.aRunActionS = QtWidgets.QAction( - "Run action server", self.smActionS - ) - self.aResetActionS = QtWidgets.QAction( - "Reset action server", self.smActionS - ) - self.aStopActionS = QtWidgets.QAction( - "Stop action server", self.smActionS - ) - - self.aRunActionS.triggered.connect(self.start_action_server) - self.aResetActionS.triggered.connect(self.reset_action_server) - self.aStopActionS.triggered.connect(self.stop_action_server) - - self.smActionS.addAction(self.aRunActionS) - self.smActionS.addAction(self.aResetActionS) - self.smActionS.addAction(self.aStopActionS) + tray_menu = QtWidgets.QMenu("Ftrack", parent_menu) # Actions - basic - self.aLogin = QtWidgets.QAction("Login", self.menu) - self.aLogin.triggered.connect(self.validate) - self.aLogout = QtWidgets.QAction("Logout", self.menu) - self.aLogout.triggered.connect(self.logout) + action_credentials = QtWidgets.QAction("Credentials", tray_menu) + action_credentials.triggered.connect(self.show_login_widget) + if self.bool_logged: + icon = self.icon_logged + else: + icon = self.icon_not_logged + action_credentials.setIcon(icon) + tray_menu.addAction(action_credentials) + self.action_credentials = action_credentials - self.menu.addAction(self.aLogin) - self.menu.addAction(self.aLogout) + # Actions - server + tray_server_menu = tray_menu.addMenu("Action server") + self.action_server_run = QtWidgets.QAction( + "Run action server", tray_server_menu + ) + self.action_server_reset = QtWidgets.QAction( + "Reset action server", tray_server_menu + ) + self.action_server_stop = QtWidgets.QAction( + "Stop action server", tray_server_menu + ) + + self.action_server_run.triggered.connect(self.start_action_server) + self.action_server_reset.triggered.connect(self.reset_action_server) + self.action_server_stop.triggered.connect(self.stop_action_server) + + tray_server_menu.addAction(self.action_server_run) + tray_server_menu.addAction(self.action_server_reset) + tray_server_menu.addAction(self.action_server_stop) + + self.tray_server_menu = tray_server_menu self.bool_logged = False self.set_menu_visibility() - parent_menu.addMenu(self.menu) + parent_menu.addMenu(tray_menu) def tray_start(self): self.validate() @@ -264,19 +294,15 @@ class FtrackModule: # Definition of visibility of each menu actions def set_menu_visibility(self): - - self.smActionS.menuAction().setVisible(self.bool_logged) - self.aLogin.setVisible(not self.bool_logged) - self.aLogout.setVisible(self.bool_logged) - + self.tray_server_menu.menuAction().setVisible(self.bool_logged) if self.bool_logged is False: if self.bool_timer_event is True: self.stop_timer_thread() return - self.aRunActionS.setVisible(not self.bool_action_server_running) - self.aResetActionS.setVisible(self.bool_action_thread_running) - self.aStopActionS.setVisible(self.bool_action_server_running) + self.action_server_run.setVisible(not self.bool_action_server_running) + self.action_server_reset.setVisible(self.bool_action_thread_running) + self.action_server_stop.setVisible(self.bool_action_server_running) if self.bool_timer_event is False: self.start_timer_thread() diff --git a/pype/modules/ftrack/tray/login_dialog.py b/pype/modules/ftrack/tray/login_dialog.py index e0614513a3..94ad29e478 100644 --- a/pype/modules/ftrack/tray/login_dialog.py +++ b/pype/modules/ftrack/tray/login_dialog.py @@ -1,315 +1,322 @@ import os import requests from avalon import style -from pype.modules.ftrack import credentials +from pype.modules.ftrack.lib import credentials from . import login_tools from pype.api import resources from Qt import QtCore, QtGui, QtWidgets -class Login_Dialog_ui(QtWidgets.QWidget): - +class CredentialsDialog(QtWidgets.QDialog): SIZE_W = 300 SIZE_H = 230 - loginSignal = QtCore.Signal(object, object, object) - _login_server_thread = None - inputs = [] - buttons = [] - labels = [] + login_changed = QtCore.Signal() + logout_signal = QtCore.Signal() - def __init__(self, parent=None, is_event=False): + def __init__(self, parent=None): + super(CredentialsDialog, self).__init__(parent) - super(Login_Dialog_ui, self).__init__() + self.setWindowTitle("Pype - Ftrack Login") - self.parent = parent - self.is_event = is_event + self._login_server_thread = None + self._is_logged = False + self._in_advance_mode = False - if hasattr(parent, 'icon'): - self.setWindowIcon(self.parent.icon) - elif hasattr(parent, 'parent') and hasattr(parent.parent, 'icon'): - self.setWindowIcon(self.parent.parent.icon) - else: - icon = QtGui.QIcon(resources.pype_icon_filepath()) - self.setWindowIcon(icon) + icon = QtGui.QIcon(resources.pype_icon_filepath()) + self.setWindowIcon(icon) self.setWindowFlags( QtCore.Qt.WindowCloseButtonHint | QtCore.Qt.WindowMinimizeButtonHint ) - self.loginSignal.connect(self.loginWithCredentials) - self._translate = QtCore.QCoreApplication.translate - - self.font = QtGui.QFont() - self.font.setFamily("DejaVu Sans Condensed") - self.font.setPointSize(9) - self.font.setBold(True) - self.font.setWeight(50) - self.font.setKerning(True) - - self.resize(self.SIZE_W, self.SIZE_H) self.setMinimumSize(QtCore.QSize(self.SIZE_W, self.SIZE_H)) - self.setMaximumSize(QtCore.QSize(self.SIZE_W+100, self.SIZE_H+100)) + self.setMaximumSize(QtCore.QSize(self.SIZE_W + 100, self.SIZE_H + 100)) self.setStyleSheet(style.load_stylesheet()) - self.setLayout(self._main()) - self.setWindowTitle('Pype - Ftrack Login') + self.login_changed.connect(self._on_login) - def _main(self): - self.main = QtWidgets.QVBoxLayout() - self.main.setObjectName("main") + self.ui_init() - self.form = QtWidgets.QFormLayout() - self.form.setContentsMargins(10, 15, 10, 5) - self.form.setObjectName("form") - - self.ftsite_label = QtWidgets.QLabel("FTrack URL:") - self.ftsite_label.setFont(self.font) - self.ftsite_label.setCursor(QtGui.QCursor(QtCore.Qt.ArrowCursor)) - self.ftsite_label.setTextFormat(QtCore.Qt.RichText) - self.ftsite_label.setObjectName("user_label") + def ui_init(self): + self.ftsite_label = QtWidgets.QLabel("Ftrack URL:") + self.user_label = QtWidgets.QLabel("Username:") + self.api_label = QtWidgets.QLabel("API Key:") self.ftsite_input = QtWidgets.QLineEdit() - self.ftsite_input.setEnabled(True) - self.ftsite_input.setFrame(True) - self.ftsite_input.setEnabled(False) self.ftsite_input.setReadOnly(True) - self.ftsite_input.setObjectName("ftsite_input") - - self.user_label = QtWidgets.QLabel("Username:") - self.user_label.setFont(self.font) - self.user_label.setCursor(QtGui.QCursor(QtCore.Qt.ArrowCursor)) - self.user_label.setTextFormat(QtCore.Qt.RichText) - self.user_label.setObjectName("user_label") + self.ftsite_input.setCursor(QtGui.QCursor(QtCore.Qt.IBeamCursor)) self.user_input = QtWidgets.QLineEdit() - self.user_input.setEnabled(True) - self.user_input.setFrame(True) - self.user_input.setObjectName("user_input") - self.user_input.setPlaceholderText( - self._translate("main", "user.name") - ) + self.user_input.setPlaceholderText("user.name") self.user_input.textChanged.connect(self._user_changed) - self.api_label = QtWidgets.QLabel("API Key:") - self.api_label.setFont(self.font) - self.api_label.setCursor(QtGui.QCursor(QtCore.Qt.ArrowCursor)) - self.api_label.setTextFormat(QtCore.Qt.RichText) - self.api_label.setObjectName("api_label") - self.api_input = QtWidgets.QLineEdit() - self.api_input.setEnabled(True) - self.api_input.setFrame(True) - self.api_input.setObjectName("api_input") - self.api_input.setPlaceholderText(self._translate( - "main", "e.g. xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" - )) + self.api_input.setPlaceholderText( + "e.g. xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" + ) self.api_input.textChanged.connect(self._api_changed) + input_layout = QtWidgets.QFormLayout() + input_layout.setContentsMargins(10, 15, 10, 5) + + input_layout.addRow(self.ftsite_label, self.ftsite_input) + input_layout.addRow(self.user_label, self.user_input) + input_layout.addRow(self.api_label, self.api_input) + + self.btn_advanced = QtWidgets.QPushButton("Advanced") + self.btn_advanced.clicked.connect(self._on_advanced_clicked) + + self.btn_simple = QtWidgets.QPushButton("Simple") + self.btn_simple.clicked.connect(self._on_simple_clicked) + + self.btn_login = QtWidgets.QPushButton("Login") + self.btn_login.setToolTip( + "Set Username and API Key with entered values" + ) + self.btn_login.clicked.connect(self._on_login_clicked) + + self.btn_ftrack_login = QtWidgets.QPushButton("Ftrack login") + self.btn_ftrack_login.setToolTip("Open browser for Login to Ftrack") + self.btn_ftrack_login.clicked.connect(self._on_ftrack_login_clicked) + + self.btn_logout = QtWidgets.QPushButton("Logout") + self.btn_logout.clicked.connect(self._on_logout_clicked) + + self.btn_close = QtWidgets.QPushButton("Close") + self.btn_close.setToolTip("Close this window") + self.btn_close.clicked.connect(self._close_widget) + + btns_layout = QtWidgets.QHBoxLayout() + btns_layout.addWidget(self.btn_advanced) + btns_layout.addWidget(self.btn_simple) + btns_layout.addStretch(1) + btns_layout.addWidget(self.btn_ftrack_login) + btns_layout.addWidget(self.btn_login) + btns_layout.addWidget(self.btn_logout) + btns_layout.addWidget(self.btn_close) + + self.note_label = QtWidgets.QLabel(( + "NOTE: Click on \"{}\" button to log with your default browser" + " or click on \"{}\" button to enter API key manually." + ).format(self.btn_ftrack_login.text(), self.btn_advanced.text())) + + self.note_label.setWordWrap(True) + self.note_label.hide() + self.error_label = QtWidgets.QLabel("") - self.error_label.setFont(self.font) - self.error_label.setTextFormat(QtCore.Qt.RichText) - self.error_label.setObjectName("error_label") self.error_label.setWordWrap(True) self.error_label.hide() - self.form.addRow(self.ftsite_label, self.ftsite_input) - self.form.addRow(self.user_label, self.user_input) - self.form.addRow(self.api_label, self.api_input) - self.form.addRow(self.error_label) + label_layout = QtWidgets.QVBoxLayout() + label_layout.setContentsMargins(10, 5, 10, 5) + label_layout.addWidget(self.note_label) + label_layout.addWidget(self.error_label) - self.btnGroup = QtWidgets.QHBoxLayout() - self.btnGroup.addStretch(1) - self.btnGroup.setObjectName("btnGroup") + main = QtWidgets.QVBoxLayout(self) + main.addLayout(input_layout) + main.addLayout(label_layout) + main.addStretch(1) + main.addLayout(btns_layout) - self.btnEnter = QtWidgets.QPushButton("Login") - self.btnEnter.setToolTip( - 'Set Username and API Key with entered values' - ) - self.btnEnter.clicked.connect(self.enter_credentials) + self.fill_ftrack_url() - self.btnClose = QtWidgets.QPushButton("Close") - self.btnClose.setToolTip('Close this window') - self.btnClose.clicked.connect(self._close_widget) + self.set_is_logged(self._is_logged) - self.btnFtrack = QtWidgets.QPushButton("Ftrack") - self.btnFtrack.setToolTip('Open browser for Login to Ftrack') - self.btnFtrack.clicked.connect(self.open_ftrack) + self.setLayout(main) - self.btnGroup.addWidget(self.btnFtrack) - self.btnGroup.addWidget(self.btnEnter) - self.btnGroup.addWidget(self.btnClose) + def fill_ftrack_url(self): + url = os.getenv("FTRACK_SERVER") + checked_url = self.check_url(url) - self.main.addLayout(self.form) - self.main.addLayout(self.btnGroup) + if checked_url is None: + checked_url = "" + self.btn_login.setEnabled(False) + self.btn_ftrack_login.setEnabled(False) - self.inputs.append(self.api_input) - self.inputs.append(self.user_input) - self.inputs.append(self.ftsite_input) + self.api_input.setEnabled(False) + self.user_input.setEnabled(False) + self.ftsite_input.setEnabled(False) - self.enter_site() - return self.main + self.ftsite_input.setText(checked_url) - def enter_site(self): - try: - url = os.getenv('FTRACK_SERVER') - newurl = self.checkUrl(url) + def set_advanced_mode(self, is_advanced): + self._in_advance_mode = is_advanced - if newurl is None: - self.btnEnter.setEnabled(False) - self.btnFtrack.setEnabled(False) - for input in self.inputs: - input.setEnabled(False) - newurl = url + self.error_label.setVisible(False) - self.ftsite_input.setText(newurl) + is_logged = self._is_logged - except Exception: - self.setError("FTRACK_SERVER is not set in templates") - self.btnEnter.setEnabled(False) - self.btnFtrack.setEnabled(False) - for input in self.inputs: - input.setEnabled(False) + self.note_label.setVisible(not is_logged and not is_advanced) + self.btn_ftrack_login.setVisible(not is_logged and not is_advanced) + self.btn_advanced.setVisible(not is_logged and not is_advanced) - def setError(self, msg): + self.btn_login.setVisible(not is_logged and is_advanced) + self.btn_simple.setVisible(not is_logged and is_advanced) + + self.user_label.setVisible(is_logged or is_advanced) + self.user_input.setVisible(is_logged or is_advanced) + self.api_label.setVisible(is_logged or is_advanced) + self.api_input.setVisible(is_logged or is_advanced) + if is_advanced: + self.user_input.setFocus() + else: + self.btn_ftrack_login.setFocus() + + def set_is_logged(self, is_logged): + self._is_logged = is_logged + + self.user_input.setReadOnly(is_logged) + self.api_input.setReadOnly(is_logged) + self.user_input.setCursor(QtGui.QCursor(QtCore.Qt.IBeamCursor)) + self.api_input.setCursor(QtGui.QCursor(QtCore.Qt.IBeamCursor)) + + self.btn_logout.setVisible(is_logged) + + self.set_advanced_mode(self._in_advance_mode) + + def set_error(self, msg): self.error_label.setText(msg) self.error_label.show() + def _on_logout_clicked(self): + self.user_input.setText("") + self.api_input.setText("") + self.set_is_logged(False) + self.logout_signal.emit() + + def _on_simple_clicked(self): + self.set_advanced_mode(False) + + def _on_advanced_clicked(self): + self.set_advanced_mode(True) + def _user_changed(self): - self.user_input.setStyleSheet("") + self._not_invalid_input(self.user_input) def _api_changed(self): - self.api_input.setStyleSheet("") + self._not_invalid_input(self.api_input) - def _invalid_input(self, entity): - entity.setStyleSheet("border: 1px solid red;") + def _not_invalid_input(self, input_widget): + input_widget.setStyleSheet("") - def enter_credentials(self): + def _invalid_input(self, input_widget): + input_widget.setStyleSheet("border: 1px solid red;") + + def _on_login(self): + self.set_is_logged(True) + self._close_widget() + + def _on_login_clicked(self): username = self.user_input.text().strip() - apiKey = self.api_input.text().strip() - msg = "You didn't enter " + api_key = self.api_input.text().strip() missing = [] if username == "": missing.append("Username") self._invalid_input(self.user_input) - if apiKey == "": + if api_key == "": missing.append("API Key") self._invalid_input(self.api_input) if len(missing) > 0: - self.setError("{0} {1}".format(msg, " and ".join(missing))) + self.set_error("You didn't enter {}".format(" and ".join(missing))) return - verification = credentials.check_credentials(username, apiKey) - - if verification: - credentials.save_credentials(username, apiKey, self.is_event) - credentials.set_env(username, apiKey) - if self.parent is not None: - self.parent.loginChange() - self._close_widget() - else: + if not self.login_with_credentials(username, api_key): self._invalid_input(self.user_input) self._invalid_input(self.api_input) - self.setError( + self.set_error( "We're unable to sign in to Ftrack with these credentials" ) - def open_ftrack(self): - url = self.ftsite_input.text() - self.loginWithCredentials(url, None, None) - - def checkUrl(self, url): - url = url.strip('/ ') - + def _on_ftrack_login_clicked(self): + url = self.check_url(self.ftsite_input.text()) if not url: - self.setError("There is no URL set in Templates") - return - - if 'http' not in url: - if url.endswith('ftrackapp.com'): - url = 'https://' + url - else: - url = 'https://{0}.ftrackapp.com'.format(url) - try: - result = requests.get( - url, - # Old python API will not work with redirect. - allow_redirects=False - ) - except requests.exceptions.RequestException: - self.setError( - 'The server URL set in Templates could not be reached.' - ) - return - - if ( - result.status_code != 200 or 'FTRACK_VERSION' not in result.headers - ): - self.setError( - 'The server URL set in Templates is not a valid ftrack server.' - ) - return - return url - - def loginWithCredentials(self, url, username, apiKey): - url = url.strip('/ ') - - if not url: - self.setError( - 'You need to specify a valid server URL, ' - 'for example https://server-name.ftrackapp.com' - ) - return - - if 'http' not in url: - if url.endswith('ftrackapp.com'): - url = 'https://' + url - else: - url = 'https://{0}.ftrackapp.com'.format(url) - try: - result = requests.get( - url, - # Old python API will not work with redirect. - allow_redirects=False - ) - except requests.exceptions.RequestException: - self.setError( - 'The server URL you provided could not be reached.' - ) - return - - if ( - result.status_code != 200 or 'FTRACK_VERSION' not in result.headers - ): - self.setError( - 'The server URL you provided is not a valid ftrack server.' - ) return # If there is an existing server thread running we need to stop it. if self._login_server_thread: - self._login_server_thread.quit() + if self._login_server_thread.isAlive(): + self._login_server_thread.stop() + self._login_server_thread.join() self._login_server_thread = None # If credentials are not properly set, try to get them using a http # server. - if not username or not apiKey: - self._login_server_thread = login_tools.LoginServerThread() - self._login_server_thread.loginSignal.connect(self.loginSignal) - self._login_server_thread.start(url) + self._login_server_thread = login_tools.LoginServerThread( + url, self._result_of_ftrack_thread + ) + self._login_server_thread.start() + + def _result_of_ftrack_thread(self, username, api_key): + if not self.login_with_credentials(username, api_key): + self._invalid_input(self.api_input) + self.set_error(( + "Somthing happened with Ftrack login." + " Try enter Username and API key manually." + )) + + def login_with_credentials(self, username, api_key): + verification = credentials.check_credentials(username, api_key) + if verification: + credentials.save_credentials(username, api_key, False) + credentials.set_env(username, api_key) + self.set_credentials(username, api_key) + self.login_changed.emit() + return verification + + def set_credentials(self, username, api_key, is_logged=True): + self.user_input.setText(username) + self.api_input.setText(api_key) + + self.error_label.hide() + + self._not_invalid_input(self.ftsite_input) + self._not_invalid_input(self.user_input) + self._not_invalid_input(self.api_input) + + if is_logged is not None: + self.set_is_logged(is_logged) + + def check_url(self, url): + if url is not None: + url = url.strip("/ ") + + if not url: + self.set_error(( + "You need to specify a valid server URL, " + "for example https://server-name.ftrackapp.com" + )) return - verification = credentials.check_credentials(username, apiKey) + if "http" not in url: + if url.endswith("ftrackapp.com"): + url = "https://" + url + else: + url = "https://{}.ftrackapp.com".format(url) + try: + result = requests.get( + url, + # Old python API will not work with redirect. + allow_redirects=False + ) + except requests.exceptions.RequestException: + self.set_error( + "Specified URL could not be reached." + ) + return - if verification is True: - credentials.save_credentials(username, apiKey, self.is_event) - credentials.set_env(username, apiKey) - if self.parent is not None: - self.parent.loginChange() - self._close_widget() + if ( + result.status_code != 200 + or "FTRACK_VERSION" not in result.headers + ): + self.set_error( + "Specified URL does not lead to a valid Ftrack server." + ) + return + return url def closeEvent(self, event): event.ignore() diff --git a/pype/modules/ftrack/tray/login_tools.py b/pype/modules/ftrack/tray/login_tools.py index 02982294f2..d3297eaa76 100644 --- a/pype/modules/ftrack/tray/login_tools.py +++ b/pype/modules/ftrack/tray/login_tools.py @@ -2,7 +2,7 @@ from http.server import BaseHTTPRequestHandler, HTTPServer from urllib import parse import webbrowser import functools -from Qt import QtCore +import threading from pype.api import resources @@ -55,20 +55,22 @@ class LoginServerHandler(BaseHTTPRequestHandler): ) -class LoginServerThread(QtCore.QThread): +class LoginServerThread(threading.Thread): '''Login server thread.''' - # Login signal. - loginSignal = QtCore.Signal(object, object, object) - - def start(self, url): - '''Start thread.''' + def __init__(self, url, callback): self.url = url - super(LoginServerThread, self).start() + self.callback = callback + self._server = None + super(LoginServerThread, self).__init__() def _handle_login(self, api_user, api_key): '''Login to server with *api_user* and *api_key*.''' - self.loginSignal.emit(self.url, api_user, api_key) + self.callback(api_user, api_key) + + def stop(self): + if self._server: + self._server.server_close() def run(self): '''Listen for events.''' diff --git a/pype/modules/logging/gui/__init__.py b/pype/modules/logging/tray/gui/__init__.py similarity index 100% rename from pype/modules/logging/gui/__init__.py rename to pype/modules/logging/tray/gui/__init__.py diff --git a/pype/modules/logging/gui/app.py b/pype/modules/logging/tray/gui/app.py similarity index 100% rename from pype/modules/logging/gui/app.py rename to pype/modules/logging/tray/gui/app.py diff --git a/pype/modules/logging/gui/models.py b/pype/modules/logging/tray/gui/models.py similarity index 100% rename from pype/modules/logging/gui/models.py rename to pype/modules/logging/tray/gui/models.py diff --git a/pype/modules/logging/gui/widgets.py b/pype/modules/logging/tray/gui/widgets.py similarity index 100% rename from pype/modules/logging/gui/widgets.py rename to pype/modules/logging/tray/gui/widgets.py diff --git a/pype/modules/logging/tray/logging_module.py b/pype/modules/logging/tray/logging_module.py index 9b26d5d9bf..84b40f68e1 100644 --- a/pype/modules/logging/tray/logging_module.py +++ b/pype/modules/logging/tray/logging_module.py @@ -1,6 +1,4 @@ -from Qt import QtWidgets from pype.api import Logger -from ..gui.app import LogsWindow class LoggingModule: @@ -8,7 +6,13 @@ class LoggingModule: self.parent = parent self.log = Logger().get_logger(self.__class__.__name__, "logging") + self.window = None + + self.tray_init(main_parent, parent) + + def tray_init(self, main_parent, parent): try: + from .gui.app import LogsWindow self.window = LogsWindow() self.tray_menu = self._tray_menu except Exception: @@ -18,12 +22,12 @@ class LoggingModule: # Definition of Tray menu def _tray_menu(self, parent_menu): + from Qt import QtWidgets # Menu for Tray App menu = QtWidgets.QMenu('Logging', parent_menu) - # menu.setProperty('submenu', 'on') show_action = QtWidgets.QAction("Show Logs", menu) - show_action.triggered.connect(self.on_show_logs) + show_action.triggered.connect(self._show_logs_gui) menu.addAction(show_action) parent_menu.addMenu(menu) @@ -34,5 +38,6 @@ class LoggingModule: def process_modules(self, modules): return - def on_show_logs(self): - self.window.show() + def _show_logs_gui(self): + if self.window: + self.window.show() diff --git a/pype/modules/muster/muster.py b/pype/modules/muster/muster.py index 629fb12635..beb30690ac 100644 --- a/pype/modules/muster/muster.py +++ b/pype/modules/muster/muster.py @@ -1,10 +1,7 @@ -import appdirs -from avalon import style -from Qt import QtWidgets import os import json -from .widget_login import MusterLogin -from avalon.vendor import requests +import appdirs +import requests class MusterModule: @@ -21,6 +18,11 @@ class MusterModule: self.cred_path = os.path.join( self.cred_folder_path, self.cred_filename ) + self.tray_init(main_parent, parent) + + def tray_init(self, main_parent, parent): + from .widget_login import MusterLogin + self.main_parent = main_parent self.parent = parent self.widget_login = MusterLogin(main_parent, self) @@ -38,10 +40,6 @@ class MusterModule: pass def process_modules(self, modules): - - def api_callback(): - self.aShowLogin.trigger() - if "RestApiServer" in modules: def api_show_login(): self.aShowLogin.trigger() @@ -51,13 +49,12 @@ class MusterModule: # Definition of Tray menu def tray_menu(self, parent): - """ - Add **change credentials** option to tray menu. - """ + """Add **change credentials** option to tray menu.""" + from Qt import QtWidgets + # Menu for Tray App self.menu = QtWidgets.QMenu('Muster', parent) self.menu.setProperty('submenu', 'on') - self.menu.setStyleSheet(style.load_stylesheet()) # Actions self.aShowLogin = QtWidgets.QAction( @@ -91,9 +88,9 @@ class MusterModule: if not MUSTER_REST_URL: raise AttributeError("Muster REST API url not set") params = { - 'username': username, - 'password': password - } + 'username': username, + 'password': password + } api_entry = '/api/login' response = self._requests_post( MUSTER_REST_URL + api_entry, params=params) diff --git a/pype/modules/rest_api/rest_api.py b/pype/modules/rest_api/rest_api.py index cc98b56a3b..3e0c646560 100644 --- a/pype/modules/rest_api/rest_api.py +++ b/pype/modules/rest_api/rest_api.py @@ -1,6 +1,6 @@ import os import socket -from Qt import QtCore +import threading from socketserver import ThreadingMixIn from http.server import HTTPServer @@ -155,14 +155,15 @@ class RestApiServer: def is_running(self): return self.rest_api_thread.is_running + def tray_exit(self): + self.stop() + def stop(self): - self.rest_api_thread.is_running = False - - def thread_stopped(self): - self._is_running = False + self.rest_api_thread.stop() + self.rest_api_thread.join() -class RestApiThread(QtCore.QThread): +class RestApiThread(threading.Thread): """ Listener for REST requests. It is possible to register callbacks for url paths. @@ -174,6 +175,12 @@ class RestApiThread(QtCore.QThread): self.is_running = False self.module = module self.port = port + self.httpd = None + + def stop(self): + self.is_running = False + if self.httpd: + self.httpd.server_close() def run(self): self.is_running = True @@ -185,12 +192,14 @@ class RestApiThread(QtCore.QThread): ) with ThreadingSimpleServer(("", self.port), Handler) as httpd: + self.httpd = httpd while self.is_running: httpd.handle_request() + except Exception: log.warning( "Rest Api Server service has failed", exc_info=True ) + self.httpd = None self.is_running = False - self.module.thread_stopped() diff --git a/pype/modules/standalonepublish/standalonepublish_module.py b/pype/modules/standalonepublish/standalonepublish_module.py index ed997bfd9f..f8bc0c6f24 100644 --- a/pype/modules/standalonepublish/standalonepublish_module.py +++ b/pype/modules/standalonepublish/standalonepublish_module.py @@ -2,7 +2,6 @@ import os import sys import subprocess import pype -from pype import tools class StandAlonePublishModule: @@ -30,6 +29,7 @@ class StandAlonePublishModule: )) def show(self): + from pype import tools standalone_publisher_tool_path = os.path.join( os.path.dirname(tools.__file__), "standalonepublish" diff --git a/pype/modules/timers_manager/__init__.py b/pype/modules/timers_manager/__init__.py index a8a478d7ae..9de205f088 100644 --- a/pype/modules/timers_manager/__init__.py +++ b/pype/modules/timers_manager/__init__.py @@ -1,5 +1,4 @@ from .timers_manager import TimersManager -from .widget_user_idle import WidgetUserIdle CLASS_DEFINIION = TimersManager diff --git a/pype/modules/timers_manager/timers_manager.py b/pype/modules/timers_manager/timers_manager.py index 82ba1013f0..62767c24f1 100644 --- a/pype/modules/timers_manager/timers_manager.py +++ b/pype/modules/timers_manager/timers_manager.py @@ -1,21 +1,7 @@ -from .widget_user_idle import WidgetUserIdle, SignalHandler -from pype.api import Logger, config +from pype.api import Logger -class Singleton(type): - """ Signleton implementation - """ - _instances = {} - - def __call__(cls, *args, **kwargs): - if cls not in cls._instances: - cls._instances[cls] = super( - Singleton, cls - ).__call__(*args, **kwargs) - return cls._instances[cls] - - -class TimersManager(metaclass=Singleton): +class TimersManager: """ Handles about Timers. Should be able to start/stop all timers at once. @@ -41,7 +27,13 @@ class TimersManager(metaclass=Singleton): self.idle_man = None self.signal_handler = None + + self.trat_init(tray_widget, main_widget) + + def trat_init(self, tray_widget, main_widget): + from .widget_user_idle import WidgetUserIdle, SignalHandler self.widget_user_idle = WidgetUserIdle(self, tray_widget) + self.signal_handler = SignalHandler(self) def set_signal_times(self): try: @@ -119,7 +111,6 @@ class TimersManager(metaclass=Singleton): """ if 'IdleManager' in modules: - self.signal_handler = SignalHandler(self) if self.set_signal_times() is True: self.register_to_idle_manager(modules['IdleManager']) diff --git a/pype/modules/user/user_module.py b/pype/modules/user/user_module.py index f2de9dc2fb..dc57fe4a63 100644 --- a/pype/modules/user/user_module.py +++ b/pype/modules/user/user_module.py @@ -3,8 +3,6 @@ import json import getpass import appdirs -from Qt import QtWidgets -from .widget_user import UserWidget from pype.api import Logger @@ -24,6 +22,12 @@ class UserModule: self.cred_path = os.path.normpath(os.path.join( self.cred_folder_path, self.cred_filename )) + self.widget_login = None + + self.tray_init(main_parent, parent) + + def tray_init(self, main_parent=None, parent=None): + from .widget_user import UserWidget self.widget_login = UserWidget(self) self.load_credentials() @@ -66,6 +70,7 @@ class UserModule: # Definition of Tray menu def tray_menu(self, parent_menu): + from Qt import QtWidgets """Add menu or action to Tray(or parent)'s menu""" action = QtWidgets.QAction("Username", parent_menu) action.triggered.connect(self.show_widget) @@ -121,7 +126,8 @@ class UserModule: self.cred = {"username": username} os.environ[self.env_name] = username - self.widget_login.set_user(username) + if self.widget_login: + self.widget_login.set_user(username) try: file = open(self.cred_path, "w") file.write(json.dumps(self.cred)) diff --git a/pype/modules/websocket_server/__init__.py b/pype/modules/websocket_server/__init__.py new file mode 100644 index 0000000000..eb5a0d9f27 --- /dev/null +++ b/pype/modules/websocket_server/__init__.py @@ -0,0 +1,5 @@ +from .websocket_server import WebSocketServer + + +def tray_init(tray_widget, main_widget): + return WebSocketServer() diff --git a/pype/modules/websocket_server/hosts/__init__.py b/pype/modules/websocket_server/hosts/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/pype/modules/websocket_server/hosts/external_app_1.py b/pype/modules/websocket_server/hosts/external_app_1.py new file mode 100644 index 0000000000..9352787175 --- /dev/null +++ b/pype/modules/websocket_server/hosts/external_app_1.py @@ -0,0 +1,47 @@ +import asyncio + +from pype.api import Logger +from wsrpc_aiohttp import WebSocketRoute + +log = Logger().get_logger("WebsocketServer") + + +class ExternalApp1(WebSocketRoute): + """ + One route, mimicking external application (like Harmony, etc). + All functions could be called from client. + 'do_notify' function calls function on the client - mimicking + notification after long running job on the server or similar + """ + + def init(self, **kwargs): + # Python __init__ must be return "self". + # This method might return anything. + log.debug("someone called ExternalApp1 route") + return kwargs + + async def server_function_one(self): + log.info('In function one') + + async def server_function_two(self): + log.info('In function two') + return 'function two' + + async def server_function_three(self): + log.info('In function three') + asyncio.ensure_future(self.do_notify()) + return '{"message":"function tree"}' + + async def server_function_four(self, *args, **kwargs): + log.info('In function four args {} kwargs {}'.format(args, kwargs)) + ret = dict(**kwargs) + ret["message"] = "function four received arguments" + return str(ret) + + # This method calls function on the client side + async def do_notify(self): + import time + time.sleep(5) + log.info('Calling function on server after delay') + awesome = 'Somebody server_function_three method!' + await self.socket.call('notify', result=awesome) diff --git a/pype/modules/websocket_server/hosts/photoshop.py b/pype/modules/websocket_server/hosts/photoshop.py new file mode 100644 index 0000000000..cdfb9413a0 --- /dev/null +++ b/pype/modules/websocket_server/hosts/photoshop.py @@ -0,0 +1,64 @@ +from pype.api import Logger +from wsrpc_aiohttp import WebSocketRoute +import functools + +import avalon.photoshop as photoshop + +log = Logger().get_logger("WebsocketServer") + + +class Photoshop(WebSocketRoute): + """ + One route, mimicking external application (like Harmony, etc). + All functions could be called from client. + 'do_notify' function calls function on the client - mimicking + notification after long running job on the server or similar + """ + instance = None + + def init(self, **kwargs): + # Python __init__ must be return "self". + # This method might return anything. + log.debug("someone called Photoshop route") + self.instance = self + return kwargs + + # server functions + async def ping(self): + log.debug("someone called Photoshop route ping") + + # This method calls function on the client side + # client functions + + async def read(self): + log.debug("photoshop.read client calls server server calls " + "Photo client") + return await self.socket.call('Photoshop.read') + + # panel routes for tools + async def creator_route(self): + self._tool_route("creator") + + async def workfiles_route(self): + self._tool_route("workfiles") + + async def loader_route(self): + self._tool_route("loader") + + async def publish_route(self): + self._tool_route("publish") + + async def sceneinventory_route(self): + self._tool_route("sceneinventory") + + async def projectmanager_route(self): + self._tool_route("projectmanager") + + def _tool_route(self, tool_name): + """The address accessed when clicking on the buttons.""" + partial_method = functools.partial(photoshop.show, tool_name) + + photoshop.execute_in_main_thread(partial_method) + + # Required return statement. + return "nothing" diff --git a/pype/modules/websocket_server/stubs/photoshop_server_stub.py b/pype/modules/websocket_server/stubs/photoshop_server_stub.py new file mode 100644 index 0000000000..da69127799 --- /dev/null +++ b/pype/modules/websocket_server/stubs/photoshop_server_stub.py @@ -0,0 +1,283 @@ +from pype.modules.websocket_server import WebSocketServer +""" + Stub handling connection from server to client. + Used anywhere solution is calling client methods. +""" +import json +from collections import namedtuple + + +class PhotoshopServerStub(): + """ + Stub for calling function on client (Photoshop js) side. + Expects that client is already connected (started when avalon menu + is opened). + 'self.websocketserver.call' is used as async wrapper + """ + + def __init__(self): + self.websocketserver = WebSocketServer.get_instance() + self.client = self.websocketserver.get_client() + + def open(self, path): + """ + Open file located at 'path' (local). + :param path: file path locally + :return: None + """ + self.websocketserver.call(self.client.call + ('Photoshop.open', path=path) + ) + + def read(self, layer, layers_meta=None): + """ + Parses layer metadata from Headline field of active document + :param layer: Layer("id": XXX, "name":'YYY') + :param data: json representation for single layer + :param all_layers: - for performance, could be + injected for usage in loop, if not, single call will be + triggered + :param layers_meta: json representation from Headline + (for performance - provide only if imprint is in + loop - value should be same) + :return: None + """ + if not layers_meta: + layers_meta = self.get_layers_metadata() + # json.dumps writes integer values in a dictionary to string, so + # anticipating it here. + if str(layer.id) in layers_meta and layers_meta[str(layer.id)]: + layers_meta[str(layer.id)].update(data) + else: + layers_meta[str(layer.id)] = data + + # Ensure only valid ids are stored. + if not all_layers: + all_layers = self.get_layers() + layer_ids = [layer.id for layer in all_layers] + cleaned_data = {} + + for id in layers_meta: + if int(id) in layer_ids: + cleaned_data[id] = layers_meta[id] + + payload = json.dumps(cleaned_data, indent=4) + + self.websocketserver.call(self.client.call + ('Photoshop.imprint', payload=payload) + ) + + def get_layers(self): + """ + Returns JSON document with all(?) layers in active document. + + :return: + Format of tuple: { 'id':'123', + 'name': 'My Layer 1', + 'type': 'GUIDE'|'FG'|'BG'|'OBJ' + 'visible': 'true'|'false' + """ + res = self.websocketserver.call(self.client.call + ('Photoshop.get_layers')) + + return self._to_records(res) + + def get_layers_in_layers(self, layers): + """ + Return all layers that belong to layers (might be groups). + :param layers: + :return: + """ + all_layers = self.get_layers() + ret = [] + parent_ids = set([lay.id for lay in layers]) + + for layer in all_layers: + parents = set(layer.parents) + if len(parent_ids & parents) > 0: + ret.append(layer) + if layer.id in parent_ids: + ret.append(layer) + + return ret + + def create_group(self, name): + """ + Create new group (eg. LayerSet) + :return: + """ + ret = self.websocketserver.call(self.client.call + ('Photoshop.create_group', + name=name)) + # create group on PS is asynchronous, returns only id + layer = {"id": ret, "name": name, "group": True} + return namedtuple('Layer', layer.keys())(*layer.values()) + + def group_selected_layers(self, name): + """ + Group selected layers into new LayerSet (eg. group) + :return: + """ + res = self.websocketserver.call(self.client.call + ('Photoshop.group_selected_layers', + name=name) + ) + return self._to_records(res) + + def get_selected_layers(self): + """ + Get a list of actually selected layers + :return: + """ + res = self.websocketserver.call(self.client.call + ('Photoshop.get_selected_layers')) + return self._to_records(res) + + def select_layers(self, layers): + """ + Selecte specified layers in Photoshop + :param layers: + :return: None + """ + layer_ids = [layer.id for layer in layers] + + self.websocketserver.call(self.client.call + ('Photoshop.get_layers', + layers=layer_ids) + ) + + def get_active_document_full_name(self): + """ + Returns full name with path of active document via ws call + :return: full path with name + """ + res = self.websocketserver.call( + self.client.call('Photoshop.get_active_document_full_name')) + + return res + + def get_active_document_name(self): + """ + Returns just a name of active document via ws call + :return: file name + """ + res = self.websocketserver.call(self.client.call + ('Photoshop.get_active_document_name')) + + return res + + def is_saved(self): + """ + Returns true if no changes in active document + :return: + """ + return self.websocketserver.call(self.client.call + ('Photoshop.is_saved')) + + def save(self): + """ + Saves active document + :return: None + """ + self.websocketserver.call(self.client.call + ('Photoshop.save')) + + def saveAs(self, image_path, ext, as_copy): + """ + Saves active document to psd (copy) or png or jpg + :param image_path: full local path + :param ext: + :param as_copy: + :return: None + """ + self.websocketserver.call(self.client.call + ('Photoshop.saveAs', + image_path=image_path, + ext=ext, + as_copy=as_copy)) + + def set_visible(self, layer_id, visibility): + """ + Set layer with 'layer_id' to 'visibility' + :param layer_id: + :param visibility: + :return: None + """ + self.websocketserver.call(self.client.call + ('Photoshop.set_visible', + layer_id=layer_id, + visibility=visibility)) + + def get_layers_metadata(self): + """ + Reads layers metadata from Headline from active document in PS. + (Headline accessible by File > File Info) + :return: - json documents + """ + layers_data = {} + res = self.websocketserver.call(self.client.call('Photoshop.read')) + try: + layers_data = json.loads(res) + except json.decoder.JSONDecodeError: + pass + return layers_data + + def import_smart_object(self, path): + """ + Import the file at `path` as a smart object to active document. + + Args: + path (str): File path to import. + """ + res = self.websocketserver.call(self.client.call + ('Photoshop.import_smart_object', + path=path)) + + return self._to_records(res).pop() + + def replace_smart_object(self, layer, path): + """ + Replace the smart object `layer` with file at `path` + + Args: + layer (namedTuple): Layer("id":XX, "name":"YY"..). + path (str): File to import. + """ + self.websocketserver.call(self.client.call + ('Photoshop.replace_smart_object', + layer=layer, + path=path)) + + def close(self): + self.client.close() + + def _to_records(self, res): + """ + Converts string json representation into list of named tuples for + dot notation access to work. + :return: + :param res: - json representation + """ + try: + layers_data = json.loads(res) + except json.decoder.JSONDecodeError: + raise ValueError("Received broken JSON {}".format(res)) + ret = [] + # convert to namedtuple to use dot donation + if isinstance(layers_data, dict): # TODO refactore + layers_data = [layers_data] + for d in layers_data: + ret.append(namedtuple('Layer', d.keys())(*d.values())) + return ret diff --git a/pype/modules/websocket_server/test_client/wsrpc_client.html b/pype/modules/websocket_server/test_client/wsrpc_client.html new file mode 100644 index 0000000000..9c3f469aca --- /dev/null +++ b/pype/modules/websocket_server/test_client/wsrpc_client.html @@ -0,0 +1,179 @@ + + + + + Title + + + + + + + + + + + + + +
+
Test of wsrpc javascript client
+ +
+ +
+
+
+
+

No return value

+
+
+
    +
  • Calls server_function_one
  • +
  • Function only logs on server
  • +
  • No return value
  • +
  •  
  • +
  •  
  • +
  •  
  • +
+ +
+
+
+
+

Return value

+
+
+
    +
  • Calls server_function_two
  • +
  • Function logs on server
  • +
  • Returns simple text value
  • +
  •  
  • +
  •  
  • +
  •  
  • +
+ +
+
+
+
+

Notify

+
+
+
    +
  • Calls server_function_three
  • +
  • Function logs on server
  • +
  • Returns json payload
  • +
  • Server then calls function ON the client after delay
  • +
  •  
  • +
+ +
+
+
+
+

Send value

+
+
+
    +
  • Calls server_function_four
  • +
  • Function logs on server
  • +
  • Returns modified sent values
  • +
  •  
  • +
  •  
  • +
  •  
  • +
+ +
+
+
+
+ + + \ No newline at end of file diff --git a/pype/modules/websocket_server/test_client/wsrpc_client.py b/pype/modules/websocket_server/test_client/wsrpc_client.py new file mode 100644 index 0000000000..ef861513ae --- /dev/null +++ b/pype/modules/websocket_server/test_client/wsrpc_client.py @@ -0,0 +1,34 @@ +import asyncio + +from wsrpc_aiohttp import WSRPCClient + +""" + Simple testing Python client for wsrpc_aiohttp + Calls sequentially multiple methods on server +""" + +loop = asyncio.get_event_loop() + + +async def main(): + print("main") + client = WSRPCClient("ws://127.0.0.1:8099/ws/", + loop=asyncio.get_event_loop()) + + client.add_route('notify', notify) + await client.connect() + print("connected") + print(await client.proxy.ExternalApp1.server_function_one()) + print(await client.proxy.ExternalApp1.server_function_two()) + print(await client.proxy.ExternalApp1.server_function_three()) + print(await client.proxy.ExternalApp1.server_function_four(foo="one")) + await client.close() + + +def notify(socket, *args, **kwargs): + print("called from server") + + +if __name__ == "__main__": + # loop.run_until_complete(main()) + asyncio.run(main()) diff --git a/pype/modules/websocket_server/websocket_server.py b/pype/modules/websocket_server/websocket_server.py new file mode 100644 index 0000000000..daf4b03103 --- /dev/null +++ b/pype/modules/websocket_server/websocket_server.py @@ -0,0 +1,222 @@ +from pype.api import Logger + +import threading +from aiohttp import web +import asyncio +from wsrpc_aiohttp import STATIC_DIR, WebSocketAsync + +import os +import sys +import pyclbr +import importlib +import urllib + +log = Logger().get_logger("WebsocketServer") + + +class WebSocketServer(): + """ + Basic POC implementation of asychronic websocket RPC server. + Uses class in external_app_1.py to mimic implementation for single + external application. + 'test_client' folder contains two test implementations of client + """ + _instance = None + + def __init__(self): + self.qaction = None + self.failed_icon = None + self._is_running = False + WebSocketServer._instance = self + self.client = None + self.handlers = {} + + port = None + websocket_url = os.getenv("WEBSOCKET_URL") + if websocket_url: + parsed = urllib.parse.urlparse(websocket_url) + port = parsed.port + if not port: + port = 8098 # fallback + + self.app = web.Application() + + self.app.router.add_route("*", "/ws/", WebSocketAsync) + self.app.router.add_static("/js", STATIC_DIR) + self.app.router.add_static("/", ".") + + # add route with multiple methods for single "external app" + directories_with_routes = ['hosts'] + self.add_routes_for_directories(directories_with_routes) + + self.websocket_thread = WebsocketServerThread(self, port) + + def add_routes_for_directories(self, directories_with_routes): + """ Loops through selected directories to find all modules and + in them all classes implementing 'WebSocketRoute' that could be + used as route. + All methods in these classes are registered automatically. + """ + for dir_name in directories_with_routes: + dir_name = os.path.join(os.path.dirname(__file__), dir_name) + for file_name in os.listdir(dir_name): + if '.py' in file_name and '__' not in file_name: + self.add_routes_for_module(file_name, dir_name) + + def add_routes_for_module(self, file_name, dir_name): + """ Auto routes for all classes implementing 'WebSocketRoute' + in 'file_name' in 'dir_name' + """ + module_name = file_name.replace('.py', '') + module_info = pyclbr.readmodule(module_name, [dir_name]) + + for class_name, cls_object in module_info.items(): + sys.path.append(dir_name) + if 'WebSocketRoute' in cls_object.super: + log.debug('Adding route for {}'.format(class_name)) + module = importlib.import_module(module_name) + cls = getattr(module, class_name) + WebSocketAsync.add_route(class_name, cls) + sys.path.pop() + + def call(self, func): + log.debug("websocket.call {}".format(func)) + future = asyncio.run_coroutine_threadsafe(func, + self.websocket_thread.loop) + result = future.result() + return result + + def get_client(self): + """ + Return first connected client to WebSocket + TODO implement selection by Route + :return: client + """ + clients = WebSocketAsync.get_clients() + client = None + if len(clients) > 0: + key = list(clients.keys())[0] + client = clients.get(key) + + return client + + @staticmethod + def get_instance(): + if WebSocketServer._instance is None: + WebSocketServer() + return WebSocketServer._instance + + def tray_start(self): + self.websocket_thread.start() + + def tray_exit(self): + self.stop() + + def stop_websocket_server(self): + + self.stop() + + @property + def is_running(self): + return self.websocket_thread.is_running + + def stop(self): + if not self.is_running: + return + try: + log.debug("Stopping websocket server") + self.websocket_thread.is_running = False + self.websocket_thread.stop() + except Exception: + log.warning( + "Error has happened during Killing websocket server", + exc_info=True + ) + + def thread_stopped(self): + self._is_running = False + + +class WebsocketServerThread(threading.Thread): + """ Listener for websocket rpc requests. + + It would be probably better to "attach" this to main thread (as for + example Harmony needs to run something on main thread), but currently + it creates separate thread and separate asyncio event loop + """ + def __init__(self, module, port): + super(WebsocketServerThread, self).__init__() + self.is_running = False + self.port = port + self.module = module + self.loop = None + self.runner = None + self.site = None + self.tasks = [] + + def run(self): + self.is_running = True + + try: + log.info("Starting websocket server") + self.loop = asyncio.new_event_loop() # create new loop for thread + asyncio.set_event_loop(self.loop) + + self.loop.run_until_complete(self.start_server()) + + log.debug( + "Running Websocket server on URL:" + " \"ws://localhost:{}\"".format(self.port) + ) + + asyncio.ensure_future(self.check_shutdown(), loop=self.loop) + self.loop.run_forever() + except Exception: + log.warning( + "Websocket Server service has failed", exc_info=True + ) + finally: + self.loop.close() # optional + + self.is_running = False + self.module.thread_stopped() + log.info("Websocket server stopped") + + async def start_server(self): + """ Starts runner and TCPsite """ + self.runner = web.AppRunner(self.module.app) + await self.runner.setup() + self.site = web.TCPSite(self.runner, 'localhost', self.port) + await self.site.start() + + def stop(self): + """Sets is_running flag to false, 'check_shutdown' shuts server down""" + self.is_running = False + + async def check_shutdown(self): + """ Future that is running and checks if server should be running + periodically. + """ + while self.is_running: + while self.tasks: + task = self.tasks.pop(0) + log.debug("waiting for task {}".format(task)) + await task + log.debug("returned value {}".format(task.result)) + + await asyncio.sleep(0.5) + + log.debug("Starting shutdown") + await self.site.stop() + log.debug("Site stopped") + await self.runner.cleanup() + log.debug("Runner stopped") + tasks = [task for task in asyncio.all_tasks() if + task is not asyncio.current_task()] + list(map(lambda task: task.cancel(), tasks)) # cancel all the tasks + results = await asyncio.gather(*tasks, return_exceptions=True) + log.debug(f'Finished awaiting cancelled tasks, results: {results}...') + await self.loop.shutdown_asyncgens() + # to really make sure everything else has time to stop + await asyncio.sleep(0.07) + self.loop.stop() diff --git a/pype/plugins/ftrack/publish/integrate_ftrack_api.py b/pype/plugins/ftrack/publish/integrate_ftrack_api.py index 0c4c6d49b5..2c8e06a099 100644 --- a/pype/plugins/ftrack/publish/integrate_ftrack_api.py +++ b/pype/plugins/ftrack/publish/integrate_ftrack_api.py @@ -97,6 +97,7 @@ class IntegrateFtrackApi(pyblish.api.InstancePlugin): except Exception: tp, value, tb = sys.exc_info() session.rollback() + session._configure_locations() six.reraise(tp, value, tb) def process(self, instance): @@ -178,6 +179,7 @@ class IntegrateFtrackApi(pyblish.api.InstancePlugin): except Exception: tp, value, tb = sys.exc_info() session.rollback() + session._configure_locations() six.reraise(tp, value, tb) # Adding metadata @@ -228,6 +230,7 @@ class IntegrateFtrackApi(pyblish.api.InstancePlugin): except Exception: tp, value, tb = sys.exc_info() session.rollback() + session._configure_locations() six.reraise(tp, value, tb) # Adding metadata @@ -242,6 +245,7 @@ class IntegrateFtrackApi(pyblish.api.InstancePlugin): session.commit() except Exception: session.rollback() + session._configure_locations() self.log.warning(( "Comment was not possible to set for AssetVersion" "\"{0}\". Can't set it's value to: \"{1}\"" @@ -258,6 +262,7 @@ class IntegrateFtrackApi(pyblish.api.InstancePlugin): continue except Exception: session.rollback() + session._configure_locations() self.log.warning(( "Custom Attrubute \"{0}\"" @@ -272,6 +277,7 @@ class IntegrateFtrackApi(pyblish.api.InstancePlugin): except Exception: tp, value, tb = sys.exc_info() session.rollback() + session._configure_locations() six.reraise(tp, value, tb) # Component @@ -316,6 +322,7 @@ class IntegrateFtrackApi(pyblish.api.InstancePlugin): except Exception: tp, value, tb = sys.exc_info() session.rollback() + session._configure_locations() six.reraise(tp, value, tb) # Reset members in memory @@ -432,6 +439,7 @@ class IntegrateFtrackApi(pyblish.api.InstancePlugin): except Exception: tp, value, tb = sys.exc_info() session.rollback() + session._configure_locations() six.reraise(tp, value, tb) if assetversion_entity not in used_asset_versions: diff --git a/pype/plugins/ftrack/publish/integrate_ftrack_instances.py b/pype/plugins/ftrack/publish/integrate_ftrack_instances.py index 9ef82cbc4b..2646dc90cc 100644 --- a/pype/plugins/ftrack/publish/integrate_ftrack_instances.py +++ b/pype/plugins/ftrack/publish/integrate_ftrack_instances.py @@ -88,8 +88,14 @@ class IntegrateFtrackInstance(pyblish.api.InstancePlugin): instance.data["frameEnd"] - instance.data["frameStart"] ) - if not comp.get('fps'): - comp['fps'] = instance.context.data['fps'] + fps = comp.get('fps') + if fps is None: + fps = instance.data.get( + "fps", instance.context.data['fps'] + ) + + comp['fps'] = fps + location = self.get_ftrack_location( 'ftrack.server', ft_session ) diff --git a/pype/plugins/ftrack/publish/integrate_ftrack_note.py b/pype/plugins/ftrack/publish/integrate_ftrack_note.py index 9566207145..acd295854d 100644 --- a/pype/plugins/ftrack/publish/integrate_ftrack_note.py +++ b/pype/plugins/ftrack/publish/integrate_ftrack_note.py @@ -145,4 +145,5 @@ class IntegrateFtrackNote(pyblish.api.InstancePlugin): except Exception: tp, value, tb = sys.exc_info() session.rollback() + session._configure_locations() six.reraise(tp, value, tb) diff --git a/pype/plugins/ftrack/publish/integrate_hierarchy_ftrack.py b/pype/plugins/ftrack/publish/integrate_hierarchy_ftrack.py index cc569ce2d1..7d4e0333d6 100644 --- a/pype/plugins/ftrack/publish/integrate_hierarchy_ftrack.py +++ b/pype/plugins/ftrack/publish/integrate_hierarchy_ftrack.py @@ -40,9 +40,11 @@ class IntegrateHierarchyToFtrack(pyblish.api.ContextPlugin): def process(self, context): self.context = context - if "hierarchyContext" not in context.data: + if "hierarchyContext" not in self.context.data: return + hierarchy_context = self.context.data["hierarchyContext"] + self.session = self.context.data["ftrackSession"] project_name = self.context.data["projectEntity"]["name"] query = 'Project where full_name is "{}"'.format(project_name) @@ -55,7 +57,7 @@ class IntegrateHierarchyToFtrack(pyblish.api.ContextPlugin): self.ft_project = None - input_data = context.data["hierarchyContext"] + input_data = hierarchy_context # disable termporarily ftrack project's autosyncing if auto_sync_state: @@ -128,6 +130,7 @@ class IntegrateHierarchyToFtrack(pyblish.api.ContextPlugin): except Exception: tp, value, tb = sys.exc_info() self.session.rollback() + self.session._configure_locations() six.reraise(tp, value, tb) # TASKS @@ -156,6 +159,7 @@ class IntegrateHierarchyToFtrack(pyblish.api.ContextPlugin): except Exception: tp, value, tb = sys.exc_info() self.session.rollback() + self.session._configure_locations() six.reraise(tp, value, tb) # Incoming links. @@ -165,8 +169,31 @@ class IntegrateHierarchyToFtrack(pyblish.api.ContextPlugin): except Exception: tp, value, tb = sys.exc_info() self.session.rollback() + self.session._configure_locations() six.reraise(tp, value, tb) + # Create notes. + user = self.session.query( + "User where username is \"{}\"".format(self.session.api_user) + ).first() + if user: + for comment in entity_data.get("comments", []): + entity.create_note(comment, user) + else: + self.log.warning( + "Was not able to query current User {}".format( + self.session.api_user + ) + ) + try: + self.session.commit() + except Exception: + tp, value, tb = sys.exc_info() + self.session.rollback() + self.session._configure_locations() + six.reraise(tp, value, tb) + + # Import children. if 'childs' in entity_data: self.import_to_ftrack( entity_data['childs'], entity) @@ -180,6 +207,7 @@ class IntegrateHierarchyToFtrack(pyblish.api.ContextPlugin): except Exception: tp, value, tb = sys.exc_info() self.session.rollback() + self.session._configure_locations() six.reraise(tp, value, tb) # Create new links. @@ -221,6 +249,7 @@ class IntegrateHierarchyToFtrack(pyblish.api.ContextPlugin): except Exception: tp, value, tb = sys.exc_info() self.session.rollback() + self.session._configure_locations() six.reraise(tp, value, tb) return task @@ -235,6 +264,7 @@ class IntegrateHierarchyToFtrack(pyblish.api.ContextPlugin): except Exception: tp, value, tb = sys.exc_info() self.session.rollback() + self.session._configure_locations() six.reraise(tp, value, tb) return entity @@ -249,7 +279,8 @@ class IntegrateHierarchyToFtrack(pyblish.api.ContextPlugin): except Exception: tp, value, tb = sys.exc_info() self.session.rollback() - raise + self.session._configure_locations() + six.reraise(tp, value, tb) def auto_sync_on(self, project): @@ -262,4 +293,5 @@ class IntegrateHierarchyToFtrack(pyblish.api.ContextPlugin): except Exception: tp, value, tb = sys.exc_info() self.session.rollback() - raise + self.session._configure_locations() + six.reraise(tp, value, tb) diff --git a/pype/plugins/global/load/copy_file.py b/pype/plugins/global/load/copy_file.py index bbb8e1d6f7..1acacf6b27 100644 --- a/pype/plugins/global/load/copy_file.py +++ b/pype/plugins/global/load/copy_file.py @@ -20,8 +20,8 @@ class CopyFile(api.Loader): def copy_file_to_clipboard(path): from avalon.vendor.Qt import QtCore, QtWidgets - app = QtWidgets.QApplication.instance() - assert app, "Must have running QApplication instance" + clipboard = QtWidgets.QApplication.clipboard() + assert clipboard, "Must have running QApplication instance" # Build mime data for clipboard data = QtCore.QMimeData() @@ -29,5 +29,4 @@ class CopyFile(api.Loader): data.setUrls([url]) # Set to Clipboard - clipboard = app.clipboard() clipboard.setMimeData(data) diff --git a/pype/plugins/global/load/copy_file_path.py b/pype/plugins/global/load/copy_file_path.py index cfda9dc271..f64f3e76d8 100644 --- a/pype/plugins/global/load/copy_file_path.py +++ b/pype/plugins/global/load/copy_file_path.py @@ -19,11 +19,10 @@ class CopyFilePath(api.Loader): @staticmethod def copy_path_to_clipboard(path): - from avalon.vendor.Qt import QtCore, QtWidgets + from avalon.vendor.Qt import QtWidgets - app = QtWidgets.QApplication.instance() - assert app, "Must have running QApplication instance" + clipboard = QtWidgets.QApplication.clipboard() + assert clipboard, "Must have running QApplication instance" # Set to Clipboard - clipboard = app.clipboard() clipboard.setText(os.path.normpath(path)) diff --git a/pype/plugins/global/publish/collect_anatomy_instance_data.py b/pype/plugins/global/publish/collect_anatomy_instance_data.py index 44a4d43946..446f671b86 100644 --- a/pype/plugins/global/publish/collect_anatomy_instance_data.py +++ b/pype/plugins/global/publish/collect_anatomy_instance_data.py @@ -23,123 +23,256 @@ Provides: import copy import json +import collections from avalon import io import pyblish.api -class CollectAnatomyInstanceData(pyblish.api.InstancePlugin): - """Collect Instance specific Anatomy data.""" +class CollectAnatomyInstanceData(pyblish.api.ContextPlugin): + """Collect Instance specific Anatomy data. + + Plugin is running for all instances on context even not active instances. + """ order = pyblish.api.CollectorOrder + 0.49 label = "Collect Anatomy Instance data" - def process(self, instance): - # get all the stuff from the database - anatomy_data = copy.deepcopy(instance.context.data["anatomyData"]) - project_entity = instance.context.data["projectEntity"] - context_asset_entity = instance.context.data["assetEntity"] - instance_asset_entity = instance.data.get("assetEntity") + def process(self, context): + self.log.info("Collecting anatomy data for all instances.") - asset_name = instance.data["asset"] + self.fill_missing_asset_docs(context) + self.fill_latest_versions(context) + self.fill_anatomy_data(context) - # There is possibility that assetEntity on instance is already set - # which can happen in standalone publisher - if ( - instance_asset_entity - and instance_asset_entity["name"] == asset_name - ): - asset_entity = instance_asset_entity + self.log.info("Anatomy Data collection finished.") - # Check if asset name is the same as what is in context - # - they may be different, e.g. in NukeStudio - elif context_asset_entity["name"] == asset_name: - asset_entity = context_asset_entity + def fill_missing_asset_docs(self, context): + self.log.debug("Qeurying asset documents for instances.") - else: - asset_entity = io.find_one({ - "type": "asset", - "name": asset_name, - "parent": project_entity["_id"] - }) + context_asset_doc = context.data["assetEntity"] - subset_name = instance.data["subset"] - version_number = instance.data.get("version") - latest_version = None + instances_with_missing_asset_doc = collections.defaultdict(list) + for instance in context: + instance_asset_doc = instance.data.get("assetEntity") + _asset_name = instance.data["asset"] - if asset_entity: - subset_entity = io.find_one({ - "type": "subset", - "name": subset_name, - "parent": asset_entity["_id"] - }) + # There is possibility that assetEntity on instance is already set + # which can happen in standalone publisher + if ( + instance_asset_doc + and instance_asset_doc["name"] == _asset_name + ): + continue + + # Check if asset name is the same as what is in context + # - they may be different, e.g. in NukeStudio + if context_asset_doc["name"] == _asset_name: + instance.data["assetEntity"] = context_asset_doc - if subset_entity is None: - self.log.debug("Subset entity does not exist yet.") else: - version_entity = io.find_one( - { - "type": "version", - "parent": subset_entity["_id"] - }, - sort=[("name", -1)] - ) - if version_entity: - latest_version = version_entity["name"] + instances_with_missing_asset_doc[_asset_name].append(instance) - # If version is not specified for instance or context - if version_number is None: - # TODO we should be able to change default version by studio - # preferences (like start with version number `0`) - version_number = 1 - # use latest version (+1) if already any exist - if latest_version is not None: - version_number += int(latest_version) + if not instances_with_missing_asset_doc: + self.log.debug("All instances already had right asset document.") + return - anatomy_updates = { - "asset": asset_name, - "family": instance.data["family"], - "subset": subset_name, - "version": version_number + asset_names = list(instances_with_missing_asset_doc.keys()) + self.log.debug("Querying asset documents with names: {}".format( + ", ".join(["\"{}\"".format(name) for name in asset_names]) + )) + asset_docs = io.find({ + "type": "asset", + "name": {"$in": asset_names} + }) + asset_docs_by_name = { + asset_doc["name"]: asset_doc + for asset_doc in asset_docs } - if ( - asset_entity - and asset_entity["_id"] != context_asset_entity["_id"] - ): - parents = asset_entity["data"].get("parents") or list() - anatomy_updates["hierarchy"] = "/".join(parents) - task_name = instance.data.get("task") - if task_name: - anatomy_updates["task"] = task_name + not_found_asset_names = [] + for asset_name, instances in instances_with_missing_asset_doc.items(): + asset_doc = asset_docs_by_name.get(asset_name) + if not asset_doc: + not_found_asset_names.append(asset_name) + continue - # Version should not be collected since may be instance - anatomy_data.update(anatomy_updates) + for _instance in instances: + _instance.data["assetEntity"] = asset_doc - resolution_width = instance.data.get("resolutionWidth") - if resolution_width: - anatomy_data["resolution_width"] = resolution_width + if not_found_asset_names: + joined_asset_names = ", ".join( + ["\"{}\"".format(name) for name in not_found_asset_names] + ) + self.log.warning(( + "Not found asset documents with names \"{}\"." + ).format(joined_asset_names)) - resolution_height = instance.data.get("resolutionHeight") - if resolution_height: - anatomy_data["resolution_height"] = resolution_height + def fill_latest_versions(self, context): + """Try to find latest version for each instance's subset. - pixel_aspect = instance.data.get("pixelAspect") - if pixel_aspect: - anatomy_data["pixel_aspect"] = float("{:0.2f}".format( - float(pixel_aspect))) + Key "latestVersion" is always set to latest version or `None`. - fps = instance.data.get("fps") - if fps: - anatomy_data["fps"] = float("{:0.2f}".format( - float(fps))) + Args: + context (pyblish.Context) - instance.data["projectEntity"] = project_entity - instance.data["assetEntity"] = asset_entity - instance.data["anatomyData"] = anatomy_data - instance.data["latestVersion"] = latest_version - # TODO should be version number set here? - instance.data["version"] = version_number + Returns: + None - self.log.info("Instance anatomy Data collected") - self.log.debug(json.dumps(anatomy_data, indent=4)) + """ + self.log.debug("Qeurying latest versions for instances.") + + hierarchy = {} + subset_names = set() + asset_ids = set() + for instance in context: + # Make sure `"latestVersion"` key is set + latest_version = instance.data.get("latestVersion") + instance.data["latestVersion"] = latest_version + + # Skip instances withou "assetEntity" + asset_doc = instance.data.get("assetEntity") + if not asset_doc: + continue + + # Store asset ids and subset names for queries + asset_id = asset_doc["_id"] + subset_name = instance.data["subset"] + asset_ids.add(asset_id) + subset_names.add(subset_name) + + # Prepare instance hiearchy for faster filling latest versions + if asset_id not in hierarchy: + hierarchy[asset_id] = {} + if subset_name not in hierarchy[asset_id]: + hierarchy[asset_id][subset_name] = [] + hierarchy[asset_id][subset_name].append(instance) + + subset_docs = list(io.find({ + "type": "subset", + "parent": {"$in": list(asset_ids)}, + "name": {"$in": list(subset_names)} + })) + + subset_ids = [ + subset_doc["_id"] + for subset_doc in subset_docs + ] + + last_version_by_subset_id = self._query_last_versions(subset_ids) + for subset_doc in subset_docs: + subset_id = subset_doc["_id"] + last_version = last_version_by_subset_id.get(subset_id) + if last_version is None: + continue + + asset_id = subset_doc["parent"] + subset_name = subset_doc["name"] + _instances = hierarchy[asset_id][subset_name] + for _instance in _instances: + _instance.data["latestVersion"] = last_version + + def _query_last_versions(self, subset_ids): + """Retrieve all latest versions for entered subset_ids. + + Args: + subset_ids (list): List of subset ids with type `ObjectId`. + + Returns: + dict: Key is subset id and value is last version name. + """ + _pipeline = [ + # Find all versions of those subsets + {"$match": { + "type": "version", + "parent": {"$in": subset_ids} + }}, + # Sorting versions all together + {"$sort": {"name": 1}}, + # Group them by "parent", but only take the last + {"$group": { + "_id": "$parent", + "_version_id": {"$last": "$_id"}, + "name": {"$last": "$name"} + }} + ] + + last_version_by_subset_id = {} + for doc in io.aggregate(_pipeline): + subset_id = doc["_id"] + last_version_by_subset_id[subset_id] = doc["name"] + + return last_version_by_subset_id + + def fill_anatomy_data(self, context): + self.log.debug("Storing anatomy data to instance data.") + + project_doc = context.data["projectEntity"] + context_asset_doc = context.data["assetEntity"] + + for instance in context: + version_number = instance.data.get("version") + # If version is not specified for instance or context + if version_number is None: + # TODO we should be able to change default version by studio + # preferences (like start with version number `0`) + version_number = 1 + # use latest version (+1) if already any exist + latest_version = instance.data["latestVersion"] + if latest_version is not None: + version_number += int(latest_version) + + anatomy_updates = { + "asset": instance.data["asset"], + "family": instance.data["family"], + "subset": instance.data["subset"], + "version": version_number + } + + # Hiearchy + asset_doc = instance.data.get("assetEntity") + if asset_doc and asset_doc["_id"] != context_asset_doc["_id"]: + parents = asset_doc["data"].get("parents") or list() + anatomy_updates["hierarchy"] = "/".join(parents) + + # Task + task_name = instance.data.get("task") + if task_name: + anatomy_updates["task"] = task_name + + # Additional data + resolution_width = instance.data.get("resolutionWidth") + if resolution_width: + anatomy_updates["resolution_width"] = resolution_width + + resolution_height = instance.data.get("resolutionHeight") + if resolution_height: + anatomy_updates["resolution_height"] = resolution_height + + pixel_aspect = instance.data.get("pixelAspect") + if pixel_aspect: + anatomy_updates["pixel_aspect"] = float( + "{:0.2f}".format(float(pixel_aspect)) + ) + + fps = instance.data.get("fps") + if fps: + anatomy_updates["fps"] = float("{:0.2f}".format(float(fps))) + + anatomy_data = copy.deepcopy(context.data["anatomyData"]) + anatomy_data.update(anatomy_updates) + + # Store anatomy data + instance.data["projectEntity"] = project_doc + instance.data["anatomyData"] = anatomy_data + instance.data["version"] = version_number + + # Log collected data + instance_name = instance.data["name"] + instance_label = instance.data.get("label") + if instance_label: + instance_name += "({})".format(instance_label) + self.log.debug("Anatomy data for instance {}: {}".format( + instance_name, + json.dumps(anatomy_data, indent=4) + )) diff --git a/pype/plugins/global/publish/collect_current_pype_user.py b/pype/plugins/global/publish/collect_current_pype_user.py index 359e6b852c..a8947dd8fb 100644 --- a/pype/plugins/global/publish/collect_current_pype_user.py +++ b/pype/plugins/global/publish/collect_current_pype_user.py @@ -13,7 +13,7 @@ class CollectCurrentUserPype(pyblish.api.ContextPlugin): def process(self, context): user = os.getenv("PYPE_USERNAME", "").strip() if not user: - return + user = context.data.get("user", getpass.getuser()) context.data["user"] = user - self.log.debug("Pype user is \"{}\"".format(user)) + self.log.debug("Colected user \"{}\"".format(user)) diff --git a/pype/plugins/global/publish/extract_burnin.py b/pype/plugins/global/publish/extract_burnin.py index fd73fd4a04..a613e39113 100644 --- a/pype/plugins/global/publish/extract_burnin.py +++ b/pype/plugins/global/publish/extract_burnin.py @@ -26,6 +26,7 @@ class ExtractBurnin(pype.api.Extractor): "nukestudio", "premiere", "standalonepublisher", + "harmony" "fusion" ] optional = True @@ -195,11 +196,14 @@ class ExtractBurnin(pype.api.Extractor): if "delete" in new_repre["tags"]: new_repre["tags"].remove("delete") - # Update name and outputName to be able have multiple outputs - # Join previous "outputName" with filename suffix - new_name = "_".join([new_repre["outputName"], filename_suffix]) - new_repre["name"] = new_name - new_repre["outputName"] = new_name + if len(repre_burnin_defs.keys()) > 1: + # Update name and outputName to be + # able have multiple outputs in case of more burnin presets + # Join previous "outputName" with filename suffix + new_name = "_".join( + [new_repre["outputName"], filename_suffix]) + new_repre["name"] = new_name + new_repre["outputName"] = new_name # Prepare paths and files for process. self.input_output_paths(new_repre, temp_data, filename_suffix) @@ -311,12 +315,15 @@ class ExtractBurnin(pype.api.Extractor): "comment": context.data.get("comment") or "" }) - intent_label = context.data.get("intent") + intent_label = context.data.get("intent") or "" if intent_label and isinstance(intent_label, dict): - intent_label = intent_label.get("label") + value = intent_label.get("value") + if value: + intent_label = intent_label["label"] + else: + intent_label = "" - if intent_label: - burnin_data["intent"] = intent_label + burnin_data["intent"] = intent_label temp_data = { "frame_start": frame_start, diff --git a/pype/plugins/global/publish/extract_hierarchy_avalon.py b/pype/plugins/global/publish/extract_hierarchy_avalon.py index 7cea825541..2d82eca6b2 100644 --- a/pype/plugins/global/publish/extract_hierarchy_avalon.py +++ b/pype/plugins/global/publish/extract_hierarchy_avalon.py @@ -1,6 +1,6 @@ import pyblish.api from avalon import io - +from copy import deepcopy class ExtractHierarchyToAvalon(pyblish.api.ContextPlugin): """Create entities in Avalon based on collected data.""" @@ -10,14 +10,35 @@ class ExtractHierarchyToAvalon(pyblish.api.ContextPlugin): families = ["clip", "shot"] def process(self, context): + # processing starts here if "hierarchyContext" not in context.data: self.log.info("skipping IntegrateHierarchyToAvalon") return + hierarchy_context = deepcopy(context.data["hierarchyContext"]) if not io.Session: io.install() - input_data = context.data["hierarchyContext"] + active_assets = [] + # filter only the active publishing insatnces + for instance in context: + if instance.data.get("publish") is False: + continue + + if not instance.data.get("asset"): + continue + + active_assets.append(instance.data["asset"]) + + # remove duplicity in list + self.active_assets = list(set(active_assets)) + self.log.debug("__ self.active_assets: {}".format(self.active_assets)) + + hierarchy_context = self._get_assets(hierarchy_context) + + self.log.debug("__ hierarchy_context: {}".format(hierarchy_context)) + input_data = context.data["hierarchyContext"] = hierarchy_context + self.project = None self.import_to_avalon(input_data) @@ -38,7 +59,7 @@ class ExtractHierarchyToAvalon(pyblish.api.ContextPlugin): data["inputs"] = entity_data.get("inputs", []) # Tasks. - tasks = entity_data.get("tasks", []) + tasks = entity_data.get("tasks", {}) if tasks is not None or len(tasks) > 0: data["tasks"] = tasks parents = [] @@ -78,12 +99,13 @@ class ExtractHierarchyToAvalon(pyblish.api.ContextPlugin): if entity: # Do not override data, only update cur_entity_data = entity.get("data") or {} - new_tasks = data.pop("tasks", []) + new_tasks = data.pop("tasks", {}) if "tasks" in cur_entity_data and new_tasks: - for task_name in new_tasks: - if task_name not in cur_entity_data["tasks"]: - cur_entity_data["tasks"].append(task_name) - + for task_name in new_tasks.keys(): + if task_name \ + not in cur_entity_data["tasks"].keys(): + cur_entity_data["tasks"][task_name] = \ + new_tasks[task_name] cur_entity_data.update(data) data = cur_entity_data else: @@ -150,3 +172,24 @@ class ExtractHierarchyToAvalon(pyblish.api.ContextPlugin): entity_id = io.insert_one(item).inserted_id return io.find_one({"_id": entity_id}) + + def _get_assets(self, input_dict): + """ Returns only asset dictionary. + Usually the last part of deep dictionary which + is not having any children + """ + input_dict_copy = deepcopy(input_dict) + + for key in input_dict.keys(): + self.log.debug("__ key: {}".format(key)) + # check if child key is available + if input_dict[key].get("childs"): + # loop deeper + input_dict_copy[key]["childs"] = self._get_assets( + input_dict[key]["childs"]) + else: + # filter out unwanted assets + if key not in self.active_assets: + input_dict_copy.pop(key, None) + + return input_dict_copy diff --git a/pype/plugins/global/publish/extract_jpeg.py b/pype/plugins/global/publish/extract_jpeg.py index b13ef9d1b9..dd822e9d56 100644 --- a/pype/plugins/global/publish/extract_jpeg.py +++ b/pype/plugins/global/publish/extract_jpeg.py @@ -48,10 +48,11 @@ class ExtractJpegEXR(pyblish.api.InstancePlugin): continue if not isinstance(repre['files'], (list, tuple)): - continue + input_file = repre['files'] + else: + input_file = repre['files'][0] stagingdir = os.path.normpath(repre.get("stagingDir")) - input_file = repre['files'][0] # input_file = ( # collections[0].format('{head}{padding}{tail}') % start @@ -80,6 +81,11 @@ class ExtractJpegEXR(pyblish.api.InstancePlugin): jpeg_items.append("-i {}".format(full_input_path)) # output arguments from presets jpeg_items.extend(ffmpeg_args.get("output") or []) + + # If its a movie file, we just want one frame. + if repre["ext"] == "mov": + jpeg_items.append("-vframes 1") + # output file jpeg_items.append(full_output_path) diff --git a/pype/plugins/global/publish/extract_review.py b/pype/plugins/global/publish/extract_review.py index 36d6138384..0b1c0f0745 100644 --- a/pype/plugins/global/publish/extract_review.py +++ b/pype/plugins/global/publish/extract_review.py @@ -51,7 +51,7 @@ class ExtractReview(pyblish.api.InstancePlugin): to_height = 1080 def process(self, instance): - # # Skip review when requested. + # Skip review when requested. if not instance.data.get("review", True): return @@ -634,6 +634,26 @@ class ExtractReview(pyblish.api.InstancePlugin): input_width = int(input_data["width"]) input_height = int(input_data["height"]) + # Make sure input width and height is not an odd number + input_width_is_odd = bool(input_width % 2 != 0) + input_height_is_odd = bool(input_height % 2 != 0) + if input_width_is_odd or input_height_is_odd: + # Add padding to input and make sure this filter is at first place + filters.append("pad=width=ceil(iw/2)*2:height=ceil(ih/2)*2") + + # Change input width or height as first filter will change them + if input_width_is_odd: + self.log.info(( + "Converting input width from odd to even number. {} -> {}" + ).format(input_width, input_width + 1)) + input_width += 1 + + if input_height_is_odd: + self.log.info(( + "Converting input height from odd to even number. {} -> {}" + ).format(input_height, input_height + 1)) + input_height += 1 + self.log.debug("pixel_aspect: `{}`".format(pixel_aspect)) self.log.debug("input_width: `{}`".format(input_width)) self.log.debug("input_height: `{}`".format(input_height)) @@ -655,6 +675,22 @@ class ExtractReview(pyblish.api.InstancePlugin): output_width = int(output_width) output_height = int(output_height) + # Make sure output width and height is not an odd number + # When this can happen: + # - if output definition has set width and height with odd number + # - `instance.data` contain width and height with odd numbeer + if output_width % 2 != 0: + self.log.warning(( + "Converting output width from odd to even number. {} -> {}" + ).format(output_width, output_width + 1)) + output_width += 1 + + if output_height % 2 != 0: + self.log.warning(( + "Converting output height from odd to even number. {} -> {}" + ).format(output_height, output_height + 1)) + output_height += 1 + self.log.debug( "Output resolution is {}x{}".format(output_width, output_height) ) diff --git a/pype/plugins/global/publish/integrate_new.py b/pype/plugins/global/publish/integrate_new.py index a3c2ffe52b..68549e9186 100644 --- a/pype/plugins/global/publish/integrate_new.py +++ b/pype/plugins/global/publish/integrate_new.py @@ -6,6 +6,8 @@ import copy import clique import errno import six +import re +import shutil from pymongo import DeleteOne, InsertOne import pyblish.api @@ -680,6 +682,14 @@ class IntegrateAssetNew(pyblish.api.InstancePlugin): instance.data.get('subsetGroup')}} ) + # Update families on subset. + families = [instance.data["family"]] + families.extend(instance.data.get("families", [])) + io.update_many( + {"type": "subset", "_id": io.ObjectId(subset["_id"])}, + {"$set": {"data.families": families}} + ) + return subset def create_version(self, subset, version_number, data=None): @@ -952,21 +962,37 @@ class IntegrateAssetNew(pyblish.api.InstancePlugin): """ if integrated_file_sizes: for file_url, _file_size in integrated_file_sizes.items(): + if not os.path.exists(file_url): + self.log.debug( + "File {} was not found.".format(file_url) + ) + continue + try: if mode == 'remove': - self.log.debug("Removing file ...{}".format(file_url)) + self.log.debug("Removing file {}".format(file_url)) os.remove(file_url) if mode == 'finalize': - self.log.debug("Renaming file ...{}".format(file_url)) - import re - os.rename(file_url, - re.sub('\.{}$'.format(self.TMP_FILE_EXT), - '', - file_url) - ) + new_name = re.sub( + r'\.{}$'.format(self.TMP_FILE_EXT), + '', + file_url + ) - except FileNotFoundError: - pass # file not there, nothing to delete + if os.path.exists(new_name): + self.log.debug( + "Overwriting file {} to {}".format( + file_url, new_name + ) + ) + shutil.copy(file_url, new_name) + else: + self.log.debug( + "Renaming file {} to {}".format( + file_url, new_name + ) + ) + os.rename(file_url, new_name) except OSError: self.log.error("Cannot {} file {}".format(mode, file_url), exc_info=True) diff --git a/pype/plugins/global/publish/submit_publish_job.py b/pype/plugins/global/publish/submit_publish_job.py index 2fe6735e90..d26ec2bf14 100644 --- a/pype/plugins/global/publish/submit_publish_job.py +++ b/pype/plugins/global/publish/submit_publish_job.py @@ -174,7 +174,8 @@ class ProcessSubmittedJobOnFarm(pyblish.api.InstancePlugin): "FTRACK_SERVER", "PYPE_METADATA_FILE", "AVALON_PROJECT", - "PYPE_LOG_NO_COLORS" + "PYPE_LOG_NO_COLORS", + "PYPE_USERNAME" ] # custom deadline atributes @@ -278,26 +279,14 @@ class ProcessSubmittedJobOnFarm(pyblish.api.InstancePlugin): # Mandatory for Deadline, may be empty "AuxFiles": [], } - """ - In this part we will add file dependencies instead of job dependencies. - This way we don't need to take care of tile assembly job, getting its - id or name. We expect it to produce specific file with specific name - and we are just waiting for them. - """ + + # add assembly jobs as dependencies if instance.data.get("tileRendering"): - self.log.info("Adding tile assembly results as dependencies...") - asset_index = 0 - for inst in instances: - for represenation in inst.get("representations", []): - if isinstance(represenation["files"], (list, tuple)): - for file in represenation["files"]: - dependency = os.path.join(output_dir, file) - payload["JobInfo"]["AssetDependency{}".format(asset_index)] = dependency # noqa: E501 - else: - dependency = os.path.join( - output_dir, represenation["files"]) - payload["JobInfo"]["AssetDependency{}".format(asset_index)] = dependency # noqa: E501 - asset_index += 1 + self.log.info("Adding tile assembly jobs as dependencies...") + job_index = 0 + for assembly_id in instance.data.get("assemblySubmissionJobs"): + payload["JobInfo"]["JobDependency{}".format(job_index)] = assembly_id # noqa: E501 + job_index += 1 else: payload["JobInfo"]["JobDependency0"] = job["_id"] @@ -309,6 +298,7 @@ class ProcessSubmittedJobOnFarm(pyblish.api.InstancePlugin): environment["PYPE_METADATA_FILE"] = roothless_metadata_path environment["AVALON_PROJECT"] = io.Session["AVALON_PROJECT"] environment["PYPE_LOG_NO_COLORS"] = "1" + environment["PYPE_USERNAME"] = instance.context.data["user"] try: environment["PYPE_PYTHON_EXE"] = os.environ["PYPE_PYTHON_EXE"] except KeyError: @@ -440,7 +430,7 @@ class ProcessSubmittedJobOnFarm(pyblish.api.InstancePlugin): "to render, don't know what to do " "with them.") col = rem[0] - _, ext = os.path.splitext(col) + ext = os.path.splitext(col)[1].lstrip(".") else: # but we really expect only one collection. # Nothing else make sense. @@ -729,7 +719,9 @@ class ProcessSubmittedJobOnFarm(pyblish.api.InstancePlugin): "pixelAspect": data.get("pixelAspect", 1), "resolutionWidth": data.get("resolutionWidth", 1920), "resolutionHeight": data.get("resolutionHeight", 1080), - "multipartExr": data.get("multipartExr", False) + "multipartExr": data.get("multipartExr", False), + "jobBatchName": data.get("jobBatchName", ""), + "review": data.get("review", True) } if "prerender" in instance.data["families"]: @@ -906,8 +898,13 @@ class ProcessSubmittedJobOnFarm(pyblish.api.InstancePlugin): # We still use data from it so lets fake it. # # Batch name reflect original scene name - render_job["Props"]["Batch"] = os.path.splitext(os.path.basename( - context.data.get("currentFile")))[0] + + if instance.data.get("assemblySubmissionJobs"): + render_job["Props"]["Batch"] = instance.data.get( + "jobBatchName") + else: + render_job["Props"]["Batch"] = os.path.splitext( + os.path.basename(context.data.get("currentFile")))[0] # User is deadline user render_job["Props"]["User"] = context.data.get( "deadlineUser", getpass.getuser()) diff --git a/pype/plugins/global/publish/validate_intent.py b/pype/plugins/global/publish/validate_intent.py new file mode 100644 index 0000000000..80bcb0e164 --- /dev/null +++ b/pype/plugins/global/publish/validate_intent.py @@ -0,0 +1,31 @@ +import pyblish.api +import os + + +class ValidateIntent(pyblish.api.ContextPlugin): + """Validate intent of the publish. + + It is required to fill the intent of this publish. Chech the log + for more details + """ + + order = pyblish.api.ValidatorOrder + + label = "Validate Intent" + # TODO: this should be off by default and only activated viac config + tasks = ["animation"] + hosts = ["harmony"] + if os.environ.get("AVALON_TASK") not in tasks: + active = False + + def process(self, context): + msg = ( + "Please make sure that you select the intent of this publish." + ) + + intent = context.data.get("intent") + self.log.debug(intent) + assert intent, msg + + intent_value = intent.get("value") + assert intent is not "", msg diff --git a/pype/plugins/global/publish/validate_version.py b/pype/plugins/global/publish/validate_version.py index 9c7ce72307..6701041541 100644 --- a/pype/plugins/global/publish/validate_version.py +++ b/pype/plugins/global/publish/validate_version.py @@ -10,7 +10,7 @@ class ValidateVersion(pyblish.api.InstancePlugin): order = pyblish.api.ValidatorOrder label = "Validate Version" - hosts = ["nuke", "maya", "blender"] + hosts = ["nuke", "maya", "blender", "standalonepublisher"] def process(self, instance): version = instance.data.get("version") diff --git a/pype/plugins/harmony/create/create_render.py b/pype/plugins/harmony/create/create_render.py index 493c585a09..a94e395241 100644 --- a/pype/plugins/harmony/create/create_render.py +++ b/pype/plugins/harmony/create/create_render.py @@ -13,13 +13,14 @@ class CreateRender(harmony.Creator): super(CreateRender, self).__init__(*args, **kwargs) def setup_node(self, node): - func = """function func(args) + sig = harmony.signature() + func = """function %s(args) { node.setTextAttr(args[0], "DRAWING_TYPE", 1, "PNG4"); node.setTextAttr(args[0], "DRAWING_NAME", 1, args[1]); node.setTextAttr(args[0], "MOVIE_PATH", 1, args[1]); } - func - """ + %s + """ % (sig, sig) path = "{0}/{0}".format(node.split("/")[-1]) harmony.send({"function": func, "args": [node, path]}) diff --git a/pype/plugins/harmony/load/load_audio.py b/pype/plugins/harmony/load/load_audio.py index 694fda3247..71ce5b30e0 100644 --- a/pype/plugins/harmony/load/load_audio.py +++ b/pype/plugins/harmony/load/load_audio.py @@ -1,6 +1,6 @@ from avalon import api, harmony - +sig = harmony.signature() func = """ function getUniqueColumnName( column_prefix ) { @@ -18,20 +18,20 @@ function getUniqueColumnName( column_prefix ) return column_name; } -function func(args) +function %s(args) { var uniqueColumnName = getUniqueColumnName(args[0]); column.add(uniqueColumnName , "SOUND"); column.importSound(uniqueColumnName, 1, args[1]); } -func -""" +%s +""" % (sig, sig) class ImportAudioLoader(api.Loader): """Import audio.""" - families = ["shot"] + families = ["shot", "audio"] representations = ["wav"] label = "Import Audio" diff --git a/pype/plugins/harmony/load/load_background.py b/pype/plugins/harmony/load/load_background.py index f96fc275be..5ef4535576 100644 --- a/pype/plugins/harmony/load/load_background.py +++ b/pype/plugins/harmony/load/load_background.py @@ -1,11 +1,9 @@ import os -import uuid - -import clique +import json from avalon import api, harmony import pype.lib -import json + copy_files = """function copyFile(srcFilename, dstFilename) { @@ -256,7 +254,9 @@ class BackgroundLoader(api.Loader): container_nodes = [] for layer in sorted(layers): - file_to_import = [os.path.join(bg_folder, layer).replace("\\", "/")] + file_to_import = [ + os.path.join(bg_folder, layer).replace("\\", "/") + ] read_node = harmony.send( { @@ -301,8 +301,10 @@ class BackgroundLoader(api.Loader): print(container) for layer in sorted(layers): - file_to_import = [os.path.join(bg_folder, layer).replace("\\", "/")] - print(20*"#") + file_to_import = [ + os.path.join(bg_folder, layer).replace("\\", "/") + ] + print(20 * "#") print(f"FILE TO REPLACE: {file_to_import}") print(f"LAYER: {layer}") node = harmony.find_node_by_name(layer, "READ") @@ -324,9 +326,9 @@ class BackgroundLoader(api.Loader): )["result"] container['nodes'].append(read_node) - # Colour node. - func = """function func(args){ + sig = harmony.signature("set_color") + func = """function %s(args){ for( var i =0; i <= args[0].length - 1; ++i) { var red_color = new ColorRGBA(255, 0, 0, 255); @@ -339,8 +341,8 @@ class BackgroundLoader(api.Loader): } } } - func - """ + %s + """ % (sig, sig) if pype.lib.is_latest(representation): harmony.send({"function": func, "args": [node, "green"]}) else: diff --git a/pype/plugins/harmony/load/load_imagesequence.py b/pype/plugins/harmony/load/load_imagesequence.py index 774782b092..056d5554ad 100644 --- a/pype/plugins/harmony/load/load_imagesequence.py +++ b/pype/plugins/harmony/load/load_imagesequence.py @@ -230,7 +230,7 @@ class ImageSequenceLoader(api.Loader): """Load images Stores the imported asset in a container named after the asset. """ - families = ["shot", "render", "image", "plate"] + families = ["shot", "render", "image", "plate", "reference"] representations = ["jpeg", "png", "jpg"] def load(self, context, name=None, namespace=None, data=None): @@ -301,7 +301,8 @@ class ImageSequenceLoader(api.Loader): ) # Colour node. - func = """function func(args){ + sig = harmony.signature("copyFile") + func = """function %s(args){ for( var i =0; i <= args[0].length - 1; ++i) { var red_color = new ColorRGBA(255, 0, 0, 255); @@ -314,8 +315,8 @@ class ImageSequenceLoader(api.Loader): } } } - func - """ + %s + """ % (sig, sig) if pype.lib.is_latest(representation): harmony.send({"function": func, "args": [node, "green"]}) else: diff --git a/pype/plugins/harmony/load/load_template_workfile.py b/pype/plugins/harmony/load/load_template_workfile.py index 3e79cc1903..1d1fd7f7be 100644 --- a/pype/plugins/harmony/load/load_template_workfile.py +++ b/pype/plugins/harmony/load/load_template_workfile.py @@ -21,15 +21,16 @@ class ImportTemplateLoader(api.Loader): with zipfile.ZipFile(zip_file, "r") as zip_ref: zip_ref.extractall(template_path) - func = """function func(args) + sig = harmony.signature("paste") + func = """function %s(args) { var template_path = args[0]; var drag_object = copyPaste.pasteTemplateIntoGroup( template_path, "Top", 1 ); } - func - """ + %s + """ % (sig, sig) harmony.send({"function": func, "args": [template_path]}) diff --git a/pype/plugins/harmony/publish/collect_current_file.py b/pype/plugins/harmony/publish/collect_current_file.py index aab66c2b62..40c154e847 100644 --- a/pype/plugins/harmony/publish/collect_current_file.py +++ b/pype/plugins/harmony/publish/collect_current_file.py @@ -13,15 +13,16 @@ class CollectCurrentFile(pyblish.api.ContextPlugin): def process(self, context): """Inject the current working file""" - func = """function func() + sig = harmony.signature() + func = """function %s() { return ( scene.currentProjectPath() + "/" + scene.currentVersionName() + ".xstage" ); } - func - """ + %s + """ % (sig, sig) current_file = harmony.send({"function": func})["result"] context.data["currentFile"] = os.path.normpath(current_file) diff --git a/pype/plugins/harmony/publish/collect_palettes.py b/pype/plugins/harmony/publish/collect_palettes.py index 2a2c1066c0..dc573c381f 100644 --- a/pype/plugins/harmony/publish/collect_palettes.py +++ b/pype/plugins/harmony/publish/collect_palettes.py @@ -13,7 +13,8 @@ class CollectPalettes(pyblish.api.ContextPlugin): hosts = ["harmony"] def process(self, context): - func = """function func() + sig = harmony.signature() + func = """function %s() { var palette_list = PaletteObjectManager.getScenePaletteList(); @@ -26,8 +27,8 @@ class CollectPalettes(pyblish.api.ContextPlugin): return palettes; } - func - """ + %s + """ % (sig, sig) palettes = harmony.send({"function": func})["result"] for name, id in palettes.items(): diff --git a/pype/plugins/harmony/publish/extract_palette.py b/pype/plugins/harmony/publish/extract_palette.py index 9bca005278..9b5f1f5dc9 100644 --- a/pype/plugins/harmony/publish/extract_palette.py +++ b/pype/plugins/harmony/publish/extract_palette.py @@ -13,14 +13,15 @@ class ExtractPalette(pype.api.Extractor): families = ["harmony.palette"] def process(self, instance): - func = """function func(args) + sig = harmony.signature() + func = """function %s(args) { var palette_list = PaletteObjectManager.getScenePaletteList(); var palette = palette_list.getPaletteById(args[0]); return (palette.getPath() + "/" + palette.getName() + ".plt"); } - func - """ + %s + """ % (sig, sig) palette_file = harmony.send( {"function": func, "args": [instance.data["id"]]} )["result"] diff --git a/pype/plugins/harmony/publish/extract_render.py b/pype/plugins/harmony/publish/extract_render.py index 45b52e0307..9d3ae33e23 100644 --- a/pype/plugins/harmony/publish/extract_render.py +++ b/pype/plugins/harmony/publish/extract_render.py @@ -4,6 +4,7 @@ import subprocess import pyblish.api from avalon import harmony +import pype.lib import clique @@ -20,7 +21,8 @@ class ExtractRender(pyblish.api.InstancePlugin): def process(self, instance): # Collect scene data. - func = """function func(write_node) + sig = harmony.signature() + func = """function %s(write_node) { return [ about.getApplicationPath(), @@ -32,8 +34,8 @@ class ExtractRender(pyblish.api.InstancePlugin): sound.getSoundtrackAll().path() ] } - func - """ + %s + """ % (sig, sig) result = harmony.send( {"function": func, "args": [instance[0]]} )["result"] @@ -44,14 +46,17 @@ class ExtractRender(pyblish.api.InstancePlugin): frame_end = result[5] audio_path = result[6] + instance.data["fps"] = frame_rate + # Set output path to temp folder. path = tempfile.mkdtemp() - func = """function func(args) + sig = harmony.signature() + func = """function %s(args) { node.setTextAttr(args[0], "DRAWING_NAME", 1, args[1]); } - func - """ + %s + """ % (sig, sig) result = harmony.send( { "function": func, @@ -85,19 +90,15 @@ class ExtractRender(pyblish.api.InstancePlugin): if len(collections) > 1: for col in collections: if len(list(col)) > 1: - collection = col + collection = col else: - # assert len(collections) == 1, ( - # "There should only be one image sequence in {}. Found: {}".format( - # path, len(collections) - # ) - # ) collection = collections[0] # Generate thumbnail. thumbnail_path = os.path.join(path, "thumbnail.png") + ffmpeg_path = pype.lib.get_ffmpeg_tool_path("ffmpeg") args = [ - "ffmpeg", "-y", + ffmpeg_path, "-y", "-i", os.path.join(path, list(collections[0])[0]), "-vf", "scale=300:-1", "-vframes", "1", @@ -117,57 +118,17 @@ class ExtractRender(pyblish.api.InstancePlugin): self.log.debug(output.decode("utf-8")) - # Generate mov. - mov_path = os.path.join(path, instance.data["name"] + ".mov") - if os.path.isfile(audio_path): - args = [ - "ffmpeg", "-y", - "-i", audio_path, - "-i", - os.path.join(path, collection.head + "%04d" + collection.tail), - mov_path - ] - else: - args = [ - "ffmpeg", "-y", - "-i", - os.path.join(path, collection.head + "%04d" + collection.tail), - mov_path - ] - - process = subprocess.Popen( - args, - stdout=subprocess.PIPE, - stderr=subprocess.STDOUT, - stdin=subprocess.PIPE - ) - - output = process.communicate()[0] - - if process.returncode != 0: - raise ValueError(output.decode("utf-8")) - - self.log.debug(output.decode("utf-8")) - # Generate representations. extension = collection.tail[1:] representation = { "name": extension, "ext": extension, "files": list(collection), - "stagingDir": path - } - movie = { - "name": "mov", - "ext": "mov", - "files": os.path.basename(mov_path), "stagingDir": path, - "frameStart": frame_start, - "frameEnd": frame_end, - "fps": frame_rate, - "preview": True, - "tags": ["review", "ftrackreview"] + "tags": ["review"], + "fps": frame_rate } + thumbnail = { "name": "thumbnail", "ext": "png", @@ -175,7 +136,10 @@ class ExtractRender(pyblish.api.InstancePlugin): "stagingDir": path, "tags": ["thumbnail"] } - instance.data["representations"] = [representation, movie, thumbnail] + instance.data["representations"] = [representation, thumbnail] + + if audio_path and os.path.exists(audio_path): + instance.data["audio"] = [{"filename": audio_path}] # Required for extract_review plugin (L222 onwards). instance.data["frameStart"] = frame_start diff --git a/pype/plugins/harmony/publish/extract_template.py b/pype/plugins/harmony/publish/extract_template.py index 1ba0befc54..d6851e4027 100644 --- a/pype/plugins/harmony/publish/extract_template.py +++ b/pype/plugins/harmony/publish/extract_template.py @@ -2,7 +2,7 @@ import os import shutil import pype.api -import avalon.harmony +from avalon import harmony import pype.hosts.harmony @@ -30,7 +30,7 @@ class ExtractTemplate(pype.api.Extractor): unique_backdrops = [backdrops[x] for x in set(backdrops.keys())] # Get non-connected nodes within backdrops. - all_nodes = avalon.harmony.send( + all_nodes = harmony.send( {"function": "node.subNodes", "args": ["Top"]} )["result"] for node in [x for x in all_nodes if x not in dependencies]: @@ -66,7 +66,8 @@ class ExtractTemplate(pype.api.Extractor): instance.data["representations"] = [representation] def get_backdrops(self, node): - func = """function func(probe_node) + sig = harmony.signature() + func = """function %s(probe_node) { var backdrops = Backdrop.backdrops("Top"); var valid_backdrops = []; @@ -92,14 +93,15 @@ class ExtractTemplate(pype.api.Extractor): } return valid_backdrops; } - func - """ - return avalon.harmony.send( + %s + """ % (sig, sig) + return harmony.send( {"function": func, "args": [node]} )["result"] def get_dependencies(self, node, dependencies): - func = """function func(args) + sig = harmony.signature() + func = """function %s(args) { var target_node = args[0]; var numInput = node.numberOfInputPorts(target_node); @@ -110,10 +112,10 @@ class ExtractTemplate(pype.api.Extractor): } return dependencies; } - func - """ + %s + """ % (sig, sig) - current_dependencies = avalon.harmony.send( + current_dependencies = harmony.send( {"function": func, "args": [node]} )["result"] diff --git a/pype/plugins/harmony/publish/extract_workfile.py b/pype/plugins/harmony/publish/extract_workfile.py index 304b70e293..3c5af11021 100644 --- a/pype/plugins/harmony/publish/extract_workfile.py +++ b/pype/plugins/harmony/publish/extract_workfile.py @@ -2,7 +2,7 @@ import os import shutil import pype.api -import avalon.harmony +from avalon import harmony import pype.hosts.harmony @@ -15,10 +15,10 @@ class ExtractWorkfile(pype.api.Extractor): def process(self, instance): # Export template. - backdrops = avalon.harmony.send( + backdrops = harmony.send( {"function": "Backdrop.backdrops", "args": ["Top"]} )["result"] - nodes = avalon.harmony.send( + nodes = harmony.send( {"function": "node.subNodes", "args": ["Top"]} )["result"] staging_dir = self.staging_dir(instance) diff --git a/pype/plugins/harmony/publish/validate_audio.py b/pype/plugins/harmony/publish/validate_audio.py index ba113e7610..cc8d2cdc35 100644 --- a/pype/plugins/harmony/publish/validate_audio.py +++ b/pype/plugins/harmony/publish/validate_audio.py @@ -1,14 +1,17 @@ -import json import os import pyblish.api -import avalon.harmony -import pype.hosts.harmony +from avalon import harmony class ValidateAudio(pyblish.api.InstancePlugin): - """Ensures that there is an audio file in the scene. If you are sure that you want to send render without audio, you can disable this validator before clicking on "publish" """ + """Ensures that there is an audio file in the scene. + + If you are sure that you want to send render without audio, you can + disable this validator before clicking on "publish" + + """ order = pyblish.api.ValidatorOrder label = "Validate Audio" @@ -26,7 +29,7 @@ class ValidateAudio(pyblish.api.InstancePlugin): } func """ - result = avalon.harmony.send( + result = harmony.send( {"function": func, "args": [instance[0]]} )["result"] diff --git a/pype/plugins/harmony/publish/validate_scene_settings.py b/pype/plugins/harmony/publish/validate_scene_settings.py index d7895804bd..fbeedeab77 100644 --- a/pype/plugins/harmony/publish/validate_scene_settings.py +++ b/pype/plugins/harmony/publish/validate_scene_settings.py @@ -2,7 +2,7 @@ import json import pyblish.api -import avalon.harmony +from avalon import harmony import pype.hosts.harmony @@ -46,7 +46,8 @@ class ValidateSceneSettings(pyblish.api.InstancePlugin): for string in self.frame_check_filter): expected_settings.pop("frameEnd") - func = """function func() + sig = harmony.signature() + func = """function %s() { return { "fps": scene.getFrameRate(), @@ -56,9 +57,9 @@ class ValidateSceneSettings(pyblish.api.InstancePlugin): "resolutionHeight": scene.defaultResolutionY() }; } - func - """ - current_settings = avalon.harmony.send({"function": func})["result"] + %s + """ % (sig, sig) + current_settings = harmony.send({"function": func})["result"] invalid_settings = [] for key, value in expected_settings.items(): diff --git a/pype/plugins/maya/create/create_render.py b/pype/plugins/maya/create/create_render.py index 9f05226f69..6826d33c58 100644 --- a/pype/plugins/maya/create/create_render.py +++ b/pype/plugins/maya/create/create_render.py @@ -186,6 +186,8 @@ class CreateRender(avalon.maya.Creator): self.data["useMayaBatch"] = False self.data["vrayScene"] = False self.data["tileRendering"] = False + self.data["tilesX"] = 2 + self.data["tilesY"] = 2 # Disable for now as this feature is not working yet # self.data["assScene"] = False diff --git a/pype/plugins/maya/create/create_review.py b/pype/plugins/maya/create/create_review.py index d76b78c580..97731d7950 100644 --- a/pype/plugins/maya/create/create_review.py +++ b/pype/plugins/maya/create/create_review.py @@ -12,6 +12,7 @@ class CreateReview(avalon.maya.Creator): icon = "video-camera" defaults = ['Main'] keepImages = False + isolate = False def __init__(self, *args, **kwargs): super(CreateReview, self).__init__(*args, **kwargs) @@ -22,6 +23,7 @@ class CreateReview(avalon.maya.Creator): for key, value in animation_data.items(): data[key] = value + data["isolate"] = self.isolate data["keepImages"] = self.keepImages self.data = data diff --git a/pype/plugins/maya/load/load_audio.py b/pype/plugins/maya/load/load_audio.py index e1860d0ca6..81bcca48e1 100644 --- a/pype/plugins/maya/load/load_audio.py +++ b/pype/plugins/maya/load/load_audio.py @@ -1,6 +1,9 @@ from maya import cmds, mel +import pymel.core as pc -from avalon import api +from avalon import api, io +from avalon.maya.pipeline import containerise +from avalon.maya import lib class AudioLoader(api.Loader): @@ -24,4 +27,55 @@ class AudioLoader(api.Loader): displaySound=True ) - return [sound_node] + asset = context["asset"]["name"] + namespace = namespace or lib.unique_namespace( + asset + "_", + prefix="_" if asset[0].isdigit() else "", + suffix="_", + ) + + return containerise( + name=name, + namespace=namespace, + nodes=[sound_node], + context=context, + loader=self.__class__.__name__ + ) + + def update(self, container, representation): + audio_node = None + for node in pc.PyNode(container["objectName"]).members(): + if node.nodeType() == "audio": + audio_node = node + + assert audio_node is not None, "Audio node not found." + + path = api.get_representation_path(representation) + audio_node.filename.set(path) + cmds.setAttr( + container["objectName"] + ".representation", + str(representation["_id"]), + type="string" + ) + + # Set frame range. + version = io.find_one({"_id": representation["parent"]}) + subset = io.find_one({"_id": version["parent"]}) + asset = io.find_one({"_id": subset["parent"]}) + audio_node.sourceStart.set(1 - asset["data"]["frameStart"]) + audio_node.sourceEnd.set(asset["data"]["frameEnd"]) + + def switch(self, container, representation): + self.update(container, representation) + + def remove(self, container): + members = cmds.sets(container['objectName'], query=True) + cmds.lockNode(members, lock=False) + cmds.delete([container['objectName']] + members) + + # Clean up the namespace + try: + cmds.namespace(removeNamespace=container['namespace'], + deleteNamespaceContent=True) + except RuntimeError: + pass diff --git a/pype/plugins/maya/load/load_image_plane.py b/pype/plugins/maya/load/load_image_plane.py index 08f7c99156..e17382f7ed 100644 --- a/pype/plugins/maya/load/load_image_plane.py +++ b/pype/plugins/maya/load/load_image_plane.py @@ -1,7 +1,7 @@ import pymel.core as pc import maya.cmds as cmds -from avalon import api +from avalon import api, io from avalon.maya.pipeline import containerise from avalon.maya import lib from Qt import QtWidgets @@ -12,7 +12,7 @@ class ImagePlaneLoader(api.Loader): families = ["plate", "render"] label = "Create imagePlane on selected camera." - representations = ["mov", "exr"] + representations = ["mov", "exr", "preview", "png"] icon = "image" color = "orange" @@ -29,6 +29,8 @@ class ImagePlaneLoader(api.Loader): # Getting camera from selection. selection = pc.ls(selection=True) + camera = None + if len(selection) > 1: QtWidgets.QMessageBox.critical( None, @@ -39,25 +41,29 @@ class ImagePlaneLoader(api.Loader): return if len(selection) < 1: - QtWidgets.QMessageBox.critical( + result = QtWidgets.QMessageBox.critical( None, "Error!", - "No camera selected.", - QtWidgets.QMessageBox.Ok + "No camera selected. Do you want to create a camera?", + QtWidgets.QMessageBox.Ok, + QtWidgets.QMessageBox.Cancel ) - return - - relatives = pc.listRelatives(selection[0], shapes=True) - if not pc.ls(relatives, type="camera"): - QtWidgets.QMessageBox.critical( - None, - "Error!", - "Selected node is not a camera.", - QtWidgets.QMessageBox.Ok - ) - return - - camera = selection[0] + if result == QtWidgets.QMessageBox.Ok: + camera = pc.createNode("camera") + else: + return + else: + relatives = pc.listRelatives(selection[0], shapes=True) + if pc.ls(relatives, type="camera"): + camera = selection[0] + else: + QtWidgets.QMessageBox.critical( + None, + "Error!", + "Selected node is not a camera.", + QtWidgets.QMessageBox.Ok + ) + return try: camera.displayResolution.set(1) @@ -81,9 +87,11 @@ class ImagePlaneLoader(api.Loader): image_plane_shape.frameOffset.set(1 - start_frame) image_plane_shape.frameIn.set(start_frame) image_plane_shape.frameOut.set(end_frame) + image_plane_shape.frameCache.set(end_frame) image_plane_shape.useFrameExtension.set(1) - if context["representation"]["name"] == "mov": + movie_representations = ["mov", "preview"] + if context["representation"]["name"] in movie_representations: # Need to get "type" by string, because its a method as well. pc.Attribute(image_plane_shape + ".type").set(2) @@ -139,6 +147,17 @@ class ImagePlaneLoader(api.Loader): type="string" ) + # Set frame range. + version = io.find_one({"_id": representation["parent"]}) + subset = io.find_one({"_id": version["parent"]}) + asset = io.find_one({"_id": subset["parent"]}) + start_frame = asset["data"]["frameStart"] + end_frame = asset["data"]["frameEnd"] + image_plane_shape.frameOffset.set(1 - start_frame) + image_plane_shape.frameIn.set(start_frame) + image_plane_shape.frameOut.set(end_frame) + image_plane_shape.frameCache.set(end_frame) + def switch(self, container, representation): self.update(container, representation) diff --git a/pype/plugins/maya/load/load_look.py b/pype/plugins/maya/load/load_look.py index b9c0d81104..c5b58c9bd5 100644 --- a/pype/plugins/maya/load/load_look.py +++ b/pype/plugins/maya/load/load_look.py @@ -3,6 +3,8 @@ from avalon import api, io import json import pype.hosts.maya.lib from collections import defaultdict +from pype.widgets.message_window import ScrollMessageBox +from Qt import QtWidgets class LookLoader(pype.hosts.maya.plugin.ReferenceLoader): @@ -44,18 +46,33 @@ class LookLoader(pype.hosts.maya.plugin.ReferenceLoader): self.update(container, representation) def update(self, container, representation): + """ + Called by Scene Inventory when look should be updated to current + version. + If any reference edits cannot be applied, eg. shader renamed and + material not present, reference is unloaded and cleaned. + All failed edits are highlighted to the user via message box. + Args: + container: object that has look to be updated + representation: (dict): relationship data to get proper + representation from DB and persisted + data in .json + Returns: + None + """ import os from maya import cmds - node = container["objectName"] - path = api.get_representation_path(representation) # Get reference node from container members members = cmds.sets(node, query=True, nodesOnly=True) reference_node = self._get_reference_node(members) + shader_nodes = cmds.ls(members, type='shadingEngine') + orig_nodes = set(self._get_nodes_with_shader(shader_nodes)) + file_type = { "ma": "mayaAscii", "mb": "mayaBinary", @@ -66,6 +83,104 @@ class LookLoader(pype.hosts.maya.plugin.ReferenceLoader): assert os.path.exists(path), "%s does not exist." % path + self._load_reference(file_type, node, path, reference_node) + + # Remove any placeHolderList attribute entries from the set that + # are remaining from nodes being removed from the referenced file. + members = cmds.sets(node, query=True) + invalid = [x for x in members if ".placeHolderList" in x] + if invalid: + cmds.sets(invalid, remove=node) + + # get new applied shaders and nodes from new version + shader_nodes = cmds.ls(members, type='shadingEngine') + nodes = set(self._get_nodes_with_shader(shader_nodes)) + + json_representation = io.find_one({ + "type": "representation", + "parent": representation['parent'], + "name": "json" + }) + + # Load relationships + shader_relation = api.get_representation_path(json_representation) + with open(shader_relation, "r") as f: + relationships = json.load(f) + + # update of reference could result in failed edits - material is not + # present because of renaming etc. + failed_edits = cmds.referenceQuery(reference_node, + editStrings=True, + failedEdits=True, + successfulEdits=False) + + # highlight failed edits to user + if failed_edits: + # clean references - removes failed reference edits + cmds.file(cr=reference_node) # cleanReference + + # reapply shading groups from json representation on orig nodes + pype.hosts.maya.lib.apply_shaders(relationships, + shader_nodes, + orig_nodes) + + msg = ["During reference update some edits failed.", + "All successful edits were kept intact.\n", + "Failed and removed edits:"] + msg.extend(failed_edits) + msg = ScrollMessageBox(QtWidgets.QMessageBox.Warning, + "Some reference edit failed", + msg) + msg.exec_() + + attributes = relationships.get("attributes", []) + + # region compute lookup + nodes_by_id = defaultdict(list) + for n in nodes: + nodes_by_id[pype.hosts.maya.lib.get_id(n)].append(n) + pype.hosts.maya.lib.apply_attributes(attributes, nodes_by_id) + + # Update metadata + cmds.setAttr("{}.representation".format(node), + str(representation["_id"]), + type="string") + + def _get_nodes_with_shader(self, shader_nodes): + """ + Returns list of nodes belonging to specific shaders + Args: + shader_nodes: of Shader groups + Returns + node names + """ + import maya.cmds as cmds + # Get container members + + nodes_list = [] + for shader in shader_nodes: + connections = cmds.listConnections(cmds.listHistory(shader, f=1), + type='mesh') + if connections: + for connection in connections: + nodes_list.extend(cmds.listRelatives(connection, + shapes=True)) + return nodes_list + + def _load_reference(self, file_type, node, path, reference_node): + """ + Load reference from 'path' on 'reference_node'. Used when change + of look (version/update) is triggered. + Args: + file_type: extension of referenced file + node: + path: (string) location of referenced file + reference_node: (string) - name of node that should be applied + on + Returns: + None + """ + import maya.cmds as cmds try: content = cmds.file(path, loadReference=reference_node, @@ -86,57 +201,10 @@ class LookLoader(pype.hosts.maya.plugin.ReferenceLoader): raise self.log.warning("Ignoring file read error:\n%s", exc) - # Fix PLN-40 for older containers created with Avalon that had the # `.verticesOnlySet` set to True. if cmds.getAttr("{}.verticesOnlySet".format(node)): self.log.info("Setting %s.verticesOnlySet to False", node) cmds.setAttr("{}.verticesOnlySet".format(node), False) - # Add new nodes of the reference to the container cmds.sets(content, forceElement=node) - - # Remove any placeHolderList attribute entries from the set that - # are remaining from nodes being removed from the referenced file. - members = cmds.sets(node, query=True) - invalid = [x for x in members if ".placeHolderList" in x] - if invalid: - cmds.sets(invalid, remove=node) - - # Get container members - shader_nodes = cmds.ls(members, type='shadingEngine') - - nodes_list = [] - for shader in shader_nodes: - connections = cmds.listConnections(cmds.listHistory(shader, f=1), - type='mesh') - if connections: - for connection in connections: - nodes_list.extend(cmds.listRelatives(connection, - shapes=True)) - nodes = set(nodes_list) - - json_representation = io.find_one({ - "type": "representation", - "parent": representation['parent'], - "name": "json" - }) - - # Load relationships - shader_relation = api.get_representation_path(json_representation) - with open(shader_relation, "r") as f: - relationships = json.load(f) - - attributes = relationships.get("attributes", []) - - # region compute lookup - nodes_by_id = defaultdict(list) - for n in nodes: - nodes_by_id[pype.hosts.maya.lib.get_id(n)].append(n) - - pype.hosts.maya.lib.apply_attributes(attributes, nodes_by_id) - - # Update metadata - cmds.setAttr("{}.representation".format(node), - str(representation["_id"]), - type="string") diff --git a/pype/plugins/maya/publish/collect_render.py b/pype/plugins/maya/publish/collect_render.py index dfae6ed0af..91230fcc46 100644 --- a/pype/plugins/maya/publish/collect_render.py +++ b/pype/plugins/maya/publish/collect_render.py @@ -244,6 +244,8 @@ class CollectMayaRender(pyblish.api.ContextPlugin): "resolutionHeight": cmds.getAttr("defaultResolution.height"), "pixelAspect": cmds.getAttr("defaultResolution.pixelAspect"), "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, "priority": render_instance.data.get("priority") } diff --git a/pype/plugins/maya/publish/collect_review.py b/pype/plugins/maya/publish/collect_review.py index e2df54c10b..886e3b82dd 100644 --- a/pype/plugins/maya/publish/collect_review.py +++ b/pype/plugins/maya/publish/collect_review.py @@ -64,6 +64,7 @@ class CollectReview(pyblish.api.InstancePlugin): data['handles'] = instance.data.get('handles', None) data['step'] = instance.data['step'] data['fps'] = instance.data['fps'] + data["isolate"] = instance.data["isolate"] cmds.setAttr(str(instance) + '.active', 1) self.log.debug('data {}'.format(instance.context[i].data)) instance.context[i].data.update(data) diff --git a/pype/plugins/maya/publish/collect_yeti_cache.py b/pype/plugins/maya/publish/collect_yeti_cache.py index 4af3e1ea18..26c3f601f6 100644 --- a/pype/plugins/maya/publish/collect_yeti_cache.py +++ b/pype/plugins/maya/publish/collect_yeti_cache.py @@ -30,7 +30,6 @@ class CollectYetiCache(pyblish.api.InstancePlugin): label = "Collect Yeti Cache" families = ["yetiRig", "yeticache"] hosts = ["maya"] - tasks = ["animation", "fx"] def process(self, instance): diff --git a/pype/plugins/maya/publish/extract_camera_mayaScene.py b/pype/plugins/maya/publish/extract_camera_mayaScene.py index 03dde031e9..1a0f4694d1 100644 --- a/pype/plugins/maya/publish/extract_camera_mayaScene.py +++ b/pype/plugins/maya/publish/extract_camera_mayaScene.py @@ -101,7 +101,7 @@ class ExtractCameraMayaScene(pype.api.Extractor): self.log.info( "Using {} as scene type".format(self.scene_type)) break - except AttributeError: + except KeyError: # no preset found pass diff --git a/pype/plugins/maya/publish/extract_maya_scene_raw.py b/pype/plugins/maya/publish/extract_maya_scene_raw.py index 2971572552..d273646af8 100644 --- a/pype/plugins/maya/publish/extract_maya_scene_raw.py +++ b/pype/plugins/maya/publish/extract_maya_scene_raw.py @@ -33,7 +33,7 @@ class ExtractMayaSceneRaw(pype.api.Extractor): self.log.info( "Using {} as scene type".format(self.scene_type)) break - except AttributeError: + except KeyError: # no preset found pass # Define extract output file path diff --git a/pype/plugins/maya/publish/extract_model.py b/pype/plugins/maya/publish/extract_model.py index 330e471e53..d77e65f989 100644 --- a/pype/plugins/maya/publish/extract_model.py +++ b/pype/plugins/maya/publish/extract_model.py @@ -41,7 +41,7 @@ class ExtractModel(pype.api.Extractor): self.log.info( "Using {} as scene type".format(self.scene_type)) break - except AttributeError: + except KeyError: # no preset found pass # Define extract output file path diff --git a/pype/plugins/maya/publish/extract_playblast.py b/pype/plugins/maya/publish/extract_playblast.py index 80a4dadf28..8f8b7fcb36 100644 --- a/pype/plugins/maya/publish/extract_playblast.py +++ b/pype/plugins/maya/publish/extract_playblast.py @@ -76,6 +76,11 @@ class ExtractPlayblast(pype.api.Extractor): pm.currentTime(refreshFrameInt - 1, edit=True) pm.currentTime(refreshFrameInt, edit=True) + # Isolate view is requested by having objects in the set besides a + # camera. + if instance.data.get("isolate"): + preset["isolate"] = instance.data["setMembers"] + with maintained_time(): filename = preset.get("filename", "%TEMP%") diff --git a/pype/plugins/maya/publish/extract_thumbnail.py b/pype/plugins/maya/publish/extract_thumbnail.py index c0eb2a608e..524fc1e17c 100644 --- a/pype/plugins/maya/publish/extract_thumbnail.py +++ b/pype/plugins/maya/publish/extract_thumbnail.py @@ -77,6 +77,11 @@ class ExtractThumbnail(pype.api.Extractor): pm.currentTime(refreshFrameInt - 1, edit=True) pm.currentTime(refreshFrameInt, edit=True) + # Isolate view is requested by having objects in the set besides a + # camera. + if instance.data.get("isolate"): + preset["isolate"] = instance.data["setMembers"] + with maintained_time(): filename = preset.get("filename", "%TEMP%") diff --git a/pype/plugins/maya/publish/extract_yeti_rig.py b/pype/plugins/maya/publish/extract_yeti_rig.py index 2f66d3e026..d48a956b88 100644 --- a/pype/plugins/maya/publish/extract_yeti_rig.py +++ b/pype/plugins/maya/publish/extract_yeti_rig.py @@ -111,7 +111,7 @@ class ExtractYetiRig(pype.api.Extractor): self.log.info( "Using {} as scene type".format(self.scene_type)) break - except AttributeError: + except KeyError: # no preset found pass yeti_nodes = cmds.ls(instance, type="pgYetiMaya") diff --git a/pype/plugins/maya/publish/submit_maya_deadline.py b/pype/plugins/maya/publish/submit_maya_deadline.py index 7fe20c779d..0ae19cbb81 100644 --- a/pype/plugins/maya/publish/submit_maya_deadline.py +++ b/pype/plugins/maya/publish/submit_maya_deadline.py @@ -16,11 +16,16 @@ Attributes: """ +from __future__ import print_function import os import json import getpass import copy import re +import hashlib +from datetime import datetime +import itertools +from collections import OrderedDict import clique import requests @@ -61,6 +66,98 @@ payload_skeleton = { } +def _format_tiles( + filename, index, tiles_x, tiles_y, + width, height, prefix): + """Generate tile entries for Deadline tile job. + + Returns two dictionaries - one that can be directly used in Deadline + job, second that can be used for Deadline Assembly job configuration + file. + + This will format tile names: + + Example:: + { + "OutputFilename0Tile0": "_tile_1x1_4x4_Main_beauty.1001.exr", + "OutputFilename0Tile1": "_tile_2x1_4x4_Main_beauty.1001.exr" + } + + And add tile prefixes like: + + Example:: + Image prefix is: + `maya///_` + + Result for tile 0 for 4x4 will be: + `maya///_tile_1x1_4x4__` + + Calculating coordinates is tricky as in Job they are defined as top, + left, bottom, right with zero being in top-left corner. But Assembler + configuration file takes tile coordinates as X, Y, Width and Height and + zero is bottom left corner. + + Args: + filename (str): Filename to process as tiles. + index (int): Index of that file if it is sequence. + tiles_x (int): Number of tiles in X. + tiles_y (int): Number if tikes in Y. + width (int): Width resolution of final image. + height (int): Height resolution of final image. + prefix (str): Image prefix. + + Returns: + (dict, dict): Tuple of two dictionaires - first can be used to + extend JobInfo, second has tiles x, y, width and height + used for assembler configuration. + + """ + tile = 0 + out = {"JobInfo": {}, "PluginInfo": {}} + cfg = OrderedDict() + w_space = width / tiles_x + h_space = height / tiles_y + + cfg["TilesCropped"] = "False" + + for tile_x in range(1, tiles_x + 1): + for tile_y in reversed(range(1, tiles_y + 1)): + tile_prefix = "_tile_{}x{}_{}x{}_".format( + tile_x, tile_y, + tiles_x, + tiles_y + ) + out_tile_index = "OutputFilename{}Tile{}".format( + str(index), tile + ) + new_filename = "{}/{}{}".format( + os.path.dirname(filename), + tile_prefix, + os.path.basename(filename) + ) + out["JobInfo"][out_tile_index] = new_filename + out["PluginInfo"]["RegionPrefix{}".format(str(tile))] = \ + "/{}".format(tile_prefix).join(prefix.rsplit("/", 1)) + + out["PluginInfo"]["RegionTop{}".format(tile)] = int(height) - (tile_y * h_space) # noqa: E501 + out["PluginInfo"]["RegionBottom{}".format(tile)] = int(height) - ((tile_y - 1) * h_space) - 1 # noqa: E501 + out["PluginInfo"]["RegionLeft{}".format(tile)] = (tile_x - 1) * w_space # noqa: E501 + out["PluginInfo"]["RegionRight{}".format(tile)] = (tile_x * w_space) - 1 # noqa: E501 + + cfg["Tile{}".format(tile)] = new_filename + cfg["Tile{}Tile".format(tile)] = new_filename + cfg["Tile{}FileName".format(tile)] = new_filename + cfg["Tile{}X".format(tile)] = (tile_x - 1) * w_space + + cfg["Tile{}Y".format(tile)] = int(height) - (tile_y * h_space) + + cfg["Tile{}Width".format(tile)] = w_space + cfg["Tile{}Height".format(tile)] = h_space + + tile += 1 + return out, cfg + + def get_renderer_variables(renderlayer, root): """Retrieve the extension which has been set in the VRay settings. @@ -164,6 +261,8 @@ class MayaSubmitDeadline(pyblish.api.InstancePlugin): optional = True use_published = True + tile_assembler_plugin = "PypeTileAssembler" + asset_dependencies = False def process(self, instance): """Plugin entry point.""" @@ -249,7 +348,7 @@ class MayaSubmitDeadline(pyblish.api.InstancePlugin): comment = context.data.get("comment", "") dirname = os.path.join(workspace, "renders") renderlayer = instance.data['setMembers'] # rs_beauty - deadline_user = context.data.get("deadlineUser", getpass.getuser()) + deadline_user = context.data.get("user", getpass.getuser()) jobname = "%s - %s" % (filename, instance.name) # Get the variables depending on the renderer @@ -309,7 +408,7 @@ class MayaSubmitDeadline(pyblish.api.InstancePlugin): # Optional, enable double-click to preview rendered # frames from Deadline Monitor payload_skeleton["JobInfo"]["OutputDirectory0"] = \ - os.path.dirname(output_filename_0) + os.path.dirname(output_filename_0).replace("\\", "/") payload_skeleton["JobInfo"]["OutputFilename0"] = \ output_filename_0.replace("\\", "/") @@ -319,9 +418,10 @@ class MayaSubmitDeadline(pyblish.api.InstancePlugin): # Adding file dependencies. dependencies = instance.context.data["fileDependencies"] dependencies.append(filepath) - for dependency in dependencies: - key = "AssetDependency" + str(dependencies.index(dependency)) - payload_skeleton["JobInfo"][key] = dependency + if self.asset_dependencies: + for dependency in dependencies: + key = "AssetDependency" + str(dependencies.index(dependency)) + payload_skeleton["JobInfo"][key] = dependency # Handle environments ----------------------------------------------- # We need those to pass them to pype for it to set correct context @@ -376,9 +476,8 @@ class MayaSubmitDeadline(pyblish.api.InstancePlugin): # Add list of expected files to job --------------------------------- exp = instance.data.get("expectedFiles") - - output_filenames = {} exp_index = 0 + output_filenames = {} if isinstance(exp[0], dict): # we have aovs and we need to iterate over them @@ -390,33 +489,237 @@ class MayaSubmitDeadline(pyblish.api.InstancePlugin): assert len(rem) == 1, ("Found multiple non related files " "to render, don't know what to do " "with them.") - payload['JobInfo']['OutputFilename' + str(exp_index)] = rem[0] # noqa: E501 output_file = rem[0] + if not instance.data.get("tileRendering"): + payload['JobInfo']['OutputFilename' + str(exp_index)] = output_file # noqa: E501 else: output_file = col[0].format('{head}{padding}{tail}') - payload['JobInfo']['OutputFilename' + str(exp_index)] = output_file # noqa: E501 - output_filenames[exp_index] = output_file + if not instance.data.get("tileRendering"): + payload['JobInfo']['OutputFilename' + str(exp_index)] = output_file # noqa: E501 + + output_filenames['OutputFilename' + str(exp_index)] = output_file # noqa: E501 exp_index += 1 else: - col, rem = clique.assemble(files) + col, rem = clique.assemble(exp) if not col and rem: # we couldn't find any collections but have # individual files. assert len(rem) == 1, ("Found multiple non related files " "to render, don't know what to do " "with them.") - payload['JobInfo']['OutputFilename' + str(exp_index)] = rem[0] # noqa: E501 + + output_file = rem[0] + if not instance.data.get("tileRendering"): + payload['JobInfo']['OutputFilename' + str(exp_index)] = output_file # noqa: E501 else: output_file = col[0].format('{head}{padding}{tail}') - payload['JobInfo']['OutputFilename' + str(exp_index)] = output_file # noqa: E501 + if not instance.data.get("tileRendering"): + payload['JobInfo']['OutputFilename' + str(exp_index)] = output_file # noqa: E501 + + output_filenames['OutputFilename' + str(exp_index)] = output_file plugin = payload["JobInfo"]["Plugin"] self.log.info("using render plugin : {}".format(plugin)) + # Store output dir for unified publisher (filesequence) + instance.data["outputDir"] = os.path.dirname(output_filename_0) + self.preflight_check(instance) - # Submit job to farm ------------------------------------------------ - if not instance.data.get("tileRendering"): + # Prepare tiles data ------------------------------------------------ + if instance.data.get("tileRendering"): + # if we have sequence of files, we need to create tile job for + # every frame + + payload["JobInfo"]["TileJob"] = True + payload["JobInfo"]["TileJobTilesInX"] = instance.data.get("tilesX") + payload["JobInfo"]["TileJobTilesInY"] = instance.data.get("tilesY") + payload["PluginInfo"]["ImageHeight"] = instance.data.get("resolutionHeight") # noqa: E501 + payload["PluginInfo"]["ImageWidth"] = instance.data.get("resolutionWidth") # noqa: E501 + payload["PluginInfo"]["RegionRendering"] = True + + assembly_payload = { + "AuxFiles": [], + "JobInfo": { + "BatchName": payload["JobInfo"]["BatchName"], + "Frames": 1, + "Name": "{} - Tile Assembly Job".format( + payload["JobInfo"]["Name"]), + "OutputDirectory0": + payload["JobInfo"]["OutputDirectory0"].replace( + "\\", "/"), + "Plugin": self.tile_assembler_plugin, + "MachineLimit": 1 + }, + "PluginInfo": { + "CleanupTiles": 1, + "ErrorOnMissing": True + } + } + assembly_payload["JobInfo"].update(output_filenames) + assembly_payload["JobInfo"]["Priority"] = self._instance.data.get( + "priority", 50) + assembly_payload["JobInfo"]["UserName"] = deadline_user + + frame_payloads = [] + assembly_payloads = [] + + R_FRAME_NUMBER = re.compile(r".+\.(?P[0-9]+)\..+") # noqa: N806, E501 + REPL_FRAME_NUMBER = re.compile(r"(.+\.)([0-9]+)(\..+)") # noqa: N806, E501 + + if isinstance(exp[0], dict): + # we have aovs and we need to iterate over them + # get files from `beauty` + files = exp[0].get("beauty") + # assembly files are used for assembly jobs as we need to put + # together all AOVs + assembly_files = list( + itertools.chain.from_iterable( + [f for _, f in exp[0].items()])) + if not files: + # if beauty doesn't exists, use first aov we found + files = exp[0].get(list(exp[0].keys())[0]) + else: + files = exp + assembly_files = files + + frame_jobs = {} + + file_index = 1 + for file in files: + frame = re.search(R_FRAME_NUMBER, file).group("frame") + new_payload = copy.deepcopy(payload) + new_payload["JobInfo"]["Name"] = \ + "{} (Frame {} - {} tiles)".format( + payload["JobInfo"]["Name"], + frame, + instance.data.get("tilesX") * instance.data.get("tilesY") # noqa: E501 + ) + self.log.info( + "... preparing job {}".format( + new_payload["JobInfo"]["Name"])) + new_payload["JobInfo"]["TileJobFrame"] = frame + + tiles_data = _format_tiles( + file, 0, + instance.data.get("tilesX"), + instance.data.get("tilesY"), + instance.data.get("resolutionWidth"), + instance.data.get("resolutionHeight"), + payload["PluginInfo"]["OutputFilePrefix"] + )[0] + new_payload["JobInfo"].update(tiles_data["JobInfo"]) + new_payload["PluginInfo"].update(tiles_data["PluginInfo"]) + + job_hash = hashlib.sha256("{}_{}".format(file_index, file)) + frame_jobs[frame] = job_hash.hexdigest() + new_payload["JobInfo"]["ExtraInfo0"] = job_hash.hexdigest() + new_payload["JobInfo"]["ExtraInfo1"] = file + + frame_payloads.append(new_payload) + file_index += 1 + + file_index = 1 + for file in assembly_files: + frame = re.search(R_FRAME_NUMBER, file).group("frame") + new_assembly_payload = copy.deepcopy(assembly_payload) + new_assembly_payload["JobInfo"]["Name"] = \ + "{} (Frame {})".format( + assembly_payload["JobInfo"]["Name"], + frame) + new_assembly_payload["JobInfo"]["OutputFilename0"] = re.sub( + REPL_FRAME_NUMBER, + "\\1{}\\3".format("#" * len(frame)), file) + + new_assembly_payload["PluginInfo"]["Renderer"] = self._instance.data["renderer"] # noqa: E501 + new_assembly_payload["JobInfo"]["ExtraInfo0"] = frame_jobs[frame] # noqa: E501 + new_assembly_payload["JobInfo"]["ExtraInfo1"] = file + assembly_payloads.append(new_assembly_payload) + file_index += 1 + + self.log.info( + "Submitting tile job(s) [{}] ...".format(len(frame_payloads))) + + url = "{}/api/jobs".format(self._deadline_url) + tiles_count = instance.data.get("tilesX") * instance.data.get("tilesY") # noqa: E501 + + for tile_job in frame_payloads: + response = self._requests_post(url, json=tile_job) + if not response.ok: + raise Exception(response.text) + + job_id = response.json()["_id"] + hash = response.json()["Props"]["Ex0"] + + for assembly_job in assembly_payloads: + if assembly_job["JobInfo"]["ExtraInfo0"] == hash: + assembly_job["JobInfo"]["JobDependency0"] = job_id + + for assembly_job in assembly_payloads: + file = assembly_job["JobInfo"]["ExtraInfo1"] + # write assembly job config files + now = datetime.now() + + config_file = os.path.join( + os.path.dirname(output_filename_0), + "{}_config_{}.txt".format( + os.path.splitext(file)[0], + now.strftime("%Y_%m_%d_%H_%M_%S") + ) + ) + + try: + if not os.path.isdir(os.path.dirname(config_file)): + os.makedirs(os.path.dirname(config_file)) + except OSError: + # directory is not available + self.log.warning( + "Path is unreachable: `{}`".format( + os.path.dirname(config_file))) + + # add config file as job auxFile + assembly_job["AuxFiles"] = [config_file] + + with open(config_file, "w") as cf: + print("TileCount={}".format(tiles_count), file=cf) + print("ImageFileName={}".format(file), file=cf) + print("ImageWidth={}".format( + instance.data.get("resolutionWidth")), file=cf) + print("ImageHeight={}".format( + instance.data.get("resolutionHeight")), file=cf) + + tiles = _format_tiles( + file, 0, + instance.data.get("tilesX"), + instance.data.get("tilesY"), + instance.data.get("resolutionWidth"), + instance.data.get("resolutionHeight"), + payload["PluginInfo"]["OutputFilePrefix"] + )[1] + sorted(tiles) + for k, v in tiles.items(): + print("{}={}".format(k, v), file=cf) + + job_idx = 1 + instance.data["assemblySubmissionJobs"] = [] + for ass_job in assembly_payloads: + self.log.info("submitting assembly job {} of {}".format( + job_idx, len(assembly_payloads) + )) + self.log.debug(json.dumps(ass_job, indent=4, sort_keys=True)) + response = self._requests_post(url, json=ass_job) + if not response.ok: + raise Exception(response.text) + + instance.data["assemblySubmissionJobs"].append( + response.json()["_id"]) + job_idx += 1 + + instance.data["jobBatchName"] = payload["JobInfo"]["BatchName"] + self.log.info("Setting batch name on instance: {}".format( + instance.data["jobBatchName"])) + else: + # Submit job to farm -------------------------------------------- self.log.info("Submitting ...") self.log.debug(json.dumps(payload, indent=4, sort_keys=True)) @@ -426,19 +729,18 @@ class MayaSubmitDeadline(pyblish.api.InstancePlugin): if not response.ok: raise Exception(response.text) instance.data["deadlineSubmissionJob"] = response.json() - else: - self.log.info("Skipping submission, tile rendering enabled.") - - # Store output dir for unified publisher (filesequence) - instance.data["outputDir"] = os.path.dirname(output_filename_0) def _get_maya_payload(self, data): payload = copy.deepcopy(payload_skeleton) - job_info_ext = { - # Asset dependency to wait for at least the scene file to sync. - "AssetDependency0": data["filepath"], - } + if not self.asset_dependencies: + job_info_ext = {} + + else: + job_info_ext = { + # Asset dependency to wait for at least the scene file to sync. + "AssetDependency0": data["filepath"], + } plugin_info = { "SceneFile": data["filepath"], diff --git a/pype/plugins/maya/publish/validate_deadline_tile_submission.py b/pype/plugins/maya/publish/validate_deadline_tile_submission.py deleted file mode 100644 index b0b995de3e..0000000000 --- a/pype/plugins/maya/publish/validate_deadline_tile_submission.py +++ /dev/null @@ -1,69 +0,0 @@ -# -*- coding: utf-8 -*- -"""Validate settings from Deadline Submitter. - -This is useful mainly for tile rendering, where jobs on farm are created by -submitter script from Maya. - -Unfortunately Deadline doesn't expose frame number for tiles job so that -cannot be validated, even if it is important setting. Also we cannot -determine if 'Region Rendering' (tile rendering) is enabled or not because -of the same thing. - -""" -import os - -from maya import mel -from maya import cmds - -import pyblish.api -from pype.hosts.maya import lib - - -class ValidateDeadlineTileSubmission(pyblish.api.InstancePlugin): - """Validate Deadline Submission settings are OK for tile rendering.""" - - label = "Validate Deadline Tile Submission" - order = pyblish.api.ValidatorOrder - hosts = ["maya"] - families = ["renderlayer"] - if not os.environ.get("DEADLINE_REST_URL"): - active = False - - def process(self, instance): - """Entry point.""" - # try if Deadline submitter was loaded - if mel.eval("exists SubmitJobToDeadline") == 0: - # if not, try to load it manually - try: - mel.eval("source DeadlineMayaClient;") - except RuntimeError: - raise AssertionError("Deadline Maya client cannot be loaded") - mel.eval("DeadlineMayaClient();") - assert mel.eval("exists SubmitJobToDeadline") == 1, ( - "Deadline Submission script cannot be initialized.") - if instance.data.get("tileRendering"): - job_name = cmds.getAttr("defaultRenderGlobals.deadlineJobName") - scene_name = os.path.splitext(os.path.basename( - instance.context.data.get("currentFile")))[0] - if job_name != scene_name: - self.log.warning(("Job submitted through Deadline submitter " - "has different name then current scene " - "{} / {}").format(job_name, scene_name)) - if cmds.getAttr("defaultRenderGlobals.deadlineTileSingleJob") == 1: - layer = instance.data['setMembers'] - anim_override = lib.get_attr_in_layer( - "defaultRenderGlobals.animation", layer=layer) - assert anim_override, ( - "Animation must be enabled in " - "Render Settings even when rendering single frame." - ) - - start_frame = cmds.getAttr("defaultRenderGlobals.startFrame") - end_frame = cmds.getAttr("defaultRenderGlobals.endFrame") - assert start_frame == end_frame, ( - "Start frame and end frame are not equals. When " - "'Submit All Tles As A Single Job' is selected, only " - "single frame is expected to be rendered. It must match " - "the one specified in Deadline Submitter under " - "'Region Rendering'" - ) diff --git a/pype/plugins/nuke/create/create_camera b/pype/plugins/nuke/create/create_camera deleted file mode 100644 index 0d542b8ad7..0000000000 --- a/pype/plugins/nuke/create/create_camera +++ /dev/null @@ -1,3 +0,0 @@ -# create vanilla camera if no camera is selected -# if camera is selected then it will convert it into containerized object -# it is major versioned in publish diff --git a/pype/plugins/nuke/create/create_camera.py b/pype/plugins/nuke/create/create_camera.py new file mode 100644 index 0000000000..4c668925ad --- /dev/null +++ b/pype/plugins/nuke/create/create_camera.py @@ -0,0 +1,53 @@ +import avalon.nuke +from avalon.nuke import lib as anlib +import nuke + + +class CreateCamera(avalon.nuke.Creator): + """Add Publishable Backdrop""" + + name = "camera" + label = "Create 3d Camera" + family = "camera" + icon = "camera" + defaults = ["Main"] + + def __init__(self, *args, **kwargs): + super(CreateCamera, self).__init__(*args, **kwargs) + self.nodes = nuke.selectedNodes() + self.node_color = "0xff9100ff" + return + + def process(self): + nodes = list() + if (self.options or {}).get("useSelection"): + nodes = self.nodes + + if len(nodes) >= 1: + # loop selected nodes + for n in nodes: + data = self.data.copy() + if len(nodes) > 1: + # rename subset name only if more + # then one node are selected + subset = self.family + n["name"].value().capitalize() + data["subset"] = subset + + # change node color + n["tile_color"].setValue(int(self.node_color, 16)) + # add avalon knobs + anlib.imprint(n, data) + return True + else: + msg = str("Please select nodes you " + "wish to add to a container") + self.log.error(msg) + nuke.message(msg) + return + else: + # if selected is off then create one node + camera_node = nuke.createNode("Camera2") + camera_node["tile_color"].setValue(int(self.node_color, 16)) + # add avalon knobs + instance = anlib.imprint(camera_node, self.data) + return instance diff --git a/pype/plugins/nuke/load/load_backdrop.py b/pype/plugins/nuke/load/load_backdrop.py index 66f9a8e1c1..7d18893965 100644 --- a/pype/plugins/nuke/load/load_backdrop.py +++ b/pype/plugins/nuke/load/load_backdrop.py @@ -240,7 +240,6 @@ class LoadBackdropNodes(api.Loader): return update_container(GN, data_imprint) - def switch(self, container, representation): self.update(container, representation) diff --git a/pype/plugins/nuke/load/load_camera_abc.py b/pype/plugins/nuke/load/load_camera_abc.py new file mode 100644 index 0000000000..377d60e84b --- /dev/null +++ b/pype/plugins/nuke/load/load_camera_abc.py @@ -0,0 +1,187 @@ +from avalon import api, io +from avalon.nuke import lib as anlib +from avalon.nuke import containerise, update_container +import nuke + + +class AlembicCameraLoader(api.Loader): + """ + This will load alembic camera into script. + """ + + families = ["camera"] + representations = ["abc"] + + label = "Load Alembic Camera" + icon = "camera" + color = "orange" + node_color = "0x3469ffff" + + def load(self, context, name, namespace, data): + # get main variables + version = context['version'] + version_data = version.get("data", {}) + vname = version.get("name", None) + first = version_data.get("frameStart", None) + last = version_data.get("frameEnd", None) + fps = version_data.get("fps") or nuke.root()["fps"].getValue() + namespace = namespace or context['asset']['name'] + object_name = "{}_{}".format(name, namespace) + + # prepare data for imprinting + # add additional metadata from the version to imprint to Avalon knob + add_keys = ["source", "author", "fps"] + + data_imprint = {"frameStart": first, + "frameEnd": last, + "version": vname, + "objectName": object_name} + + for k in add_keys: + data_imprint.update({k: version_data[k]}) + + # getting file path + file = self.fname.replace("\\", "/") + + with anlib.maintained_selection(): + camera_node = nuke.createNode( + "Camera2", + "name {} file {} read_from_file True".format( + object_name, file), + inpanel=False + ) + camera_node.forceValidate() + camera_node["frame_rate"].setValue(float(fps)) + + # workaround because nuke's bug is not adding + # animation keys properly + xpos = camera_node.xpos() + ypos = camera_node.ypos() + nuke.nodeCopy("%clipboard%") + nuke.delete(camera_node) + nuke.nodePaste("%clipboard%") + camera_node = nuke.toNode(object_name) + camera_node.setXYpos(xpos, ypos) + + # color node by correct color by actual version + self.node_version_color(version, camera_node) + + return containerise( + node=camera_node, + name=name, + namespace=namespace, + context=context, + loader=self.__class__.__name__, + data=data_imprint) + + def update(self, container, representation): + """ + Called by Scene Inventory when look should be updated to current + version. + If any reference edits cannot be applied, eg. shader renamed and + material not present, reference is unloaded and cleaned. + All failed edits are highlighted to the user via message box. + + Args: + container: object that has look to be updated + representation: (dict): relationship data to get proper + representation from DB and persisted + data in .json + Returns: + None + """ + # Get version from io + version = io.find_one({ + "type": "version", + "_id": representation["parent"] + }) + object_name = container['objectName'] + # get corresponding node + camera_node = nuke.toNode(object_name) + + # get main variables + version_data = version.get("data", {}) + vname = version.get("name", None) + first = version_data.get("frameStart", None) + last = version_data.get("frameEnd", None) + fps = version_data.get("fps") or nuke.root()["fps"].getValue() + + # prepare data for imprinting + # add additional metadata from the version to imprint to Avalon knob + add_keys = ["source", "author", "fps"] + + data_imprint = {"representation": str(representation["_id"]), + "frameStart": first, + "frameEnd": last, + "version": vname, + "objectName": object_name} + + for k in add_keys: + data_imprint.update({k: version_data[k]}) + + # getting file path + file = api.get_representation_path(representation).replace("\\", "/") + + with anlib.maintained_selection(): + camera_node = nuke.toNode(object_name) + camera_node['selected'].setValue(True) + + # collect input output dependencies + dependencies = camera_node.dependencies() + dependent = camera_node.dependent() + + camera_node["frame_rate"].setValue(float(fps)) + camera_node["file"].setValue(file) + + # workaround because nuke's bug is + # not adding animation keys properly + xpos = camera_node.xpos() + ypos = camera_node.ypos() + nuke.nodeCopy("%clipboard%") + nuke.delete(camera_node) + nuke.nodePaste("%clipboard%") + camera_node = nuke.toNode(object_name) + camera_node.setXYpos(xpos, ypos) + + # link to original input nodes + for i, input in enumerate(dependencies): + camera_node.setInput(i, input) + # link to original output nodes + for d in dependent: + index = next((i for i, dpcy in enumerate( + d.dependencies()) + if camera_node is dpcy), 0) + d.setInput(index, camera_node) + + # color node by correct color by actual version + self.node_version_color(version, camera_node) + + self.log.info("udated to version: {}".format(version.get("name"))) + + return update_container(camera_node, data_imprint) + + def node_version_color(self, version, node): + """ Coloring a node by correct color by actual version + """ + # get all versions in list + versions = io.find({ + "type": "version", + "parent": version["parent"] + }).distinct('name') + + max_version = max(versions) + + # change color of node + if version.get("name") not in [max_version]: + node["tile_color"].setValue(int("0xd88467ff", 16)) + else: + node["tile_color"].setValue(int(self.node_color, 16)) + + def switch(self, container, representation): + self.update(container, representation) + + def remove(self, container): + from avalon.nuke import viewer_update_and_undo_stop + node = nuke.toNode(container['objectName']) + with viewer_update_and_undo_stop(): + nuke.delete(node) diff --git a/pype/plugins/nuke/load/load_gizmo.py b/pype/plugins/nuke/load/load_gizmo.py new file mode 100644 index 0000000000..c6228b95f6 --- /dev/null +++ b/pype/plugins/nuke/load/load_gizmo.py @@ -0,0 +1,163 @@ +from avalon import api, style, io +import nuke +from avalon.nuke import lib as anlib +from avalon.nuke import containerise, update_container + + +class LoadGizmo(api.Loader): + """Loading nuke Gizmo""" + + representations = ["gizmo"] + families = ["gizmo"] + + label = "Load Gizmo" + order = 0 + icon = "dropbox" + color = style.colors.light + node_color = "0x75338eff" + + def load(self, context, name, namespace, data): + """ + Loading function to get Gizmo into node graph + + Arguments: + context (dict): context of version + name (str): name of the version + namespace (str): asset name + data (dict): compulsory attribute > not used + + Returns: + nuke node: containerised nuke node object + """ + + # get main variables + version = context['version'] + version_data = version.get("data", {}) + vname = version.get("name", None) + first = version_data.get("frameStart", None) + last = version_data.get("frameEnd", None) + namespace = namespace or context['asset']['name'] + colorspace = version_data.get("colorspace", None) + object_name = "{}_{}".format(name, namespace) + + # prepare data for imprinting + # add additional metadata from the version to imprint to Avalon knob + add_keys = ["frameStart", "frameEnd", "handleStart", "handleEnd", + "source", "author", "fps"] + + data_imprint = {"frameStart": first, + "frameEnd": last, + "version": vname, + "colorspaceInput": colorspace, + "objectName": object_name} + + for k in add_keys: + data_imprint.update({k: version_data[k]}) + + # getting file path + file = self.fname.replace("\\", "/") + + # adding nodes to node graph + # just in case we are in group lets jump out of it + nuke.endGroup() + + with anlib.maintained_selection(): + # add group from nk + nuke.nodePaste(file) + + GN = nuke.selectedNode() + + GN["name"].setValue(object_name) + + return containerise( + node=GN, + name=name, + namespace=namespace, + context=context, + loader=self.__class__.__name__, + data=data_imprint) + + def update(self, container, representation): + """Update the Loader's path + + Nuke automatically tries to reset some variables when changing + the loader's path to a new file. These automatic changes are to its + inputs: + + """ + + # get main variables + # Get version from io + version = io.find_one({ + "type": "version", + "_id": representation["parent"] + }) + # get corresponding node + GN = nuke.toNode(container['objectName']) + + file = api.get_representation_path(representation).replace("\\", "/") + name = container['name'] + version_data = version.get("data", {}) + vname = version.get("name", None) + first = version_data.get("frameStart", None) + last = version_data.get("frameEnd", None) + namespace = container['namespace'] + colorspace = version_data.get("colorspace", None) + object_name = "{}_{}".format(name, namespace) + + add_keys = ["frameStart", "frameEnd", "handleStart", "handleEnd", + "source", "author", "fps"] + + data_imprint = {"representation": str(representation["_id"]), + "frameStart": first, + "frameEnd": last, + "version": vname, + "colorspaceInput": colorspace, + "objectName": object_name} + + for k in add_keys: + data_imprint.update({k: version_data[k]}) + + # adding nodes to node graph + # just in case we are in group lets jump out of it + nuke.endGroup() + + with anlib.maintained_selection(): + xpos = GN.xpos() + ypos = GN.ypos() + avalon_data = anlib.get_avalon_knob_data(GN) + nuke.delete(GN) + # add group from nk + nuke.nodePaste(file) + + GN = nuke.selectedNode() + anlib.set_avalon_knob_data(GN, avalon_data) + GN.setXYpos(xpos, ypos) + GN["name"].setValue(object_name) + + # get all versions in list + versions = io.find({ + "type": "version", + "parent": version["parent"] + }).distinct('name') + + max_version = max(versions) + + # change color of node + if version.get("name") not in [max_version]: + GN["tile_color"].setValue(int("0xd88467ff", 16)) + else: + GN["tile_color"].setValue(int(self.node_color, 16)) + + self.log.info("udated to version: {}".format(version.get("name"))) + + return update_container(GN, data_imprint) + + def switch(self, container, representation): + self.update(container, representation) + + def remove(self, container): + from avalon.nuke import viewer_update_and_undo_stop + node = nuke.toNode(container['objectName']) + with viewer_update_and_undo_stop(): + nuke.delete(node) diff --git a/pype/plugins/nuke/load/load_gizmo_ip.py b/pype/plugins/nuke/load/load_gizmo_ip.py index e735e27bbf..a2e8a6abb8 100644 --- a/pype/plugins/nuke/load/load_gizmo_ip.py +++ b/pype/plugins/nuke/load/load_gizmo_ip.py @@ -102,7 +102,6 @@ class LoadGizmoInputProcess(api.Loader): GN = nuke.toNode(container['objectName']) file = api.get_representation_path(representation).replace("\\", "/") - context = representation["context"] name = container['name'] version_data = version.get("data", {}) vname = version.get("name", None) diff --git a/pype/plugins/nuke/load/load_image.py b/pype/plugins/nuke/load/load_image.py new file mode 100644 index 0000000000..377d52aa14 --- /dev/null +++ b/pype/plugins/nuke/load/load_image.py @@ -0,0 +1,233 @@ +import re +import nuke + +from avalon.vendor import qargparse +from avalon import api, io + +from pype.hosts.nuke import presets + + +class LoadImage(api.Loader): + """Load still image into Nuke""" + + families = [ + "render2d", "source", "plate", + "render", "prerender", "review", + "image" + ] + representations = ["exr", "dpx", "jpg", "jpeg", "png", "psd"] + + label = "Load Image" + order = -10 + icon = "image" + color = "white" + + options = [ + qargparse.Integer( + "frame_number", + label="Frame Number", + default=int(nuke.root()["first_frame"].getValue()), + min=1, + max=999999, + help="What frame is reading from?" + ) + ] + + def load(self, context, name, namespace, options): + from avalon.nuke import ( + containerise, + viewer_update_and_undo_stop + ) + self.log.info("__ options: `{}`".format(options)) + frame_number = options.get("frame_number", 1) + + version = context['version'] + version_data = version.get("data", {}) + repr_id = context["representation"]["_id"] + + self.log.info("version_data: {}\n".format(version_data)) + self.log.debug( + "Representation id `{}` ".format(repr_id)) + + last = first = int(frame_number) + + # Fallback to asset name when namespace is None + if namespace is None: + namespace = context['asset']['name'] + + file = self.fname + + if not file: + repr_id = context["representation"]["_id"] + self.log.warning( + "Representation id `{}` is failing to load".format(repr_id)) + return + + file = file.replace("\\", "/") + + repr_cont = context["representation"]["context"] + frame = repr_cont.get("frame") + if frame: + padding = len(frame) + file = file.replace( + frame, + format(frame_number, "0{}".format(padding))) + + read_name = "Read_{0}_{1}_{2}".format( + repr_cont["asset"], + repr_cont["subset"], + repr_cont["representation"]) + + # Create the Loader with the filename path set + with viewer_update_and_undo_stop(): + r = nuke.createNode( + "Read", + "name {}".format(read_name)) + r["file"].setValue(file) + + # Set colorspace defined in version data + colorspace = context["version"]["data"].get("colorspace") + if colorspace: + r["colorspace"].setValue(str(colorspace)) + + # load nuke presets for Read's colorspace + read_clrs_presets = presets.get_colorspace_preset().get( + "nuke", {}).get("read", {}) + + # check if any colorspace presets for read is mathing + preset_clrsp = next((read_clrs_presets[k] + for k in read_clrs_presets + if bool(re.search(k, file))), + None) + if preset_clrsp is not None: + r["colorspace"].setValue(str(preset_clrsp)) + + r["origfirst"].setValue(first) + r["first"].setValue(first) + r["origlast"].setValue(last) + r["last"].setValue(last) + + # add additional metadata from the version to imprint Avalon knob + add_keys = ["source", "colorspace", "author", "fps", "version"] + + data_imprint = { + "frameStart": first, + "frameEnd": last + } + for k in add_keys: + if k == 'version': + data_imprint.update({k: context["version"]['name']}) + else: + data_imprint.update( + {k: context["version"]['data'].get(k, str(None))}) + + data_imprint.update({"objectName": read_name}) + + r["tile_color"].setValue(int("0x4ecd25ff", 16)) + + return containerise(r, + name=name, + namespace=namespace, + context=context, + loader=self.__class__.__name__, + data=data_imprint) + + def switch(self, container, representation): + self.update(container, representation) + + def update(self, container, representation): + """Update the Loader's path + + Nuke automatically tries to reset some variables when changing + the loader's path to a new file. These automatic changes are to its + inputs: + + """ + + from avalon.nuke import ( + update_container + ) + + node = nuke.toNode(container["objectName"]) + frame_number = node["first"].value() + + assert node.Class() == "Read", "Must be Read" + + repr_cont = representation["context"] + + file = api.get_representation_path(representation) + + if not file: + repr_id = representation["_id"] + self.log.warning( + "Representation id `{}` is failing to load".format(repr_id)) + return + + file = file.replace("\\", "/") + + frame = repr_cont.get("frame") + if frame: + padding = len(frame) + file = file.replace( + frame, + format(frame_number, "0{}".format(padding))) + + # Get start frame from version data + version = io.find_one({ + "type": "version", + "_id": representation["parent"] + }) + + # get all versions in list + versions = io.find({ + "type": "version", + "parent": version["parent"] + }).distinct('name') + + max_version = max(versions) + + version_data = version.get("data", {}) + + last = first = int(frame_number) + + # Set the global in to the start frame of the sequence + node["origfirst"].setValue(first) + node["first"].setValue(first) + node["origlast"].setValue(last) + node["last"].setValue(last) + + updated_dict = {} + updated_dict.update({ + "representation": str(representation["_id"]), + "frameStart": str(first), + "frameEnd": str(last), + "version": str(version.get("name")), + "colorspace": version_data.get("colorspace"), + "source": version_data.get("source"), + "fps": str(version_data.get("fps")), + "author": version_data.get("author"), + "outputDir": version_data.get("outputDir"), + }) + + # change color of node + if version.get("name") not in [max_version]: + node["tile_color"].setValue(int("0xd84f20ff", 16)) + else: + node["tile_color"].setValue(int("0x4ecd25ff", 16)) + + # Update the imprinted representation + update_container( + node, + updated_dict + ) + self.log.info("udated to version: {}".format(version.get("name"))) + + def remove(self, container): + + from avalon.nuke import viewer_update_and_undo_stop + + node = nuke.toNode(container['objectName']) + assert node.Class() == "Read", "Must be Read" + + with viewer_update_and_undo_stop(): + nuke.delete(node) diff --git a/pype/plugins/nuke/load/load_sequence.py b/pype/plugins/nuke/load/load_sequence.py index 601e28c7c1..c5ce288540 100644 --- a/pype/plugins/nuke/load/load_sequence.py +++ b/pype/plugins/nuke/load/load_sequence.py @@ -120,12 +120,12 @@ class LoadSequence(api.Loader): if "#" not in file: frame = repr_cont.get("frame") padding = len(frame) - file = file.replace(frame, "#"*padding) + file = file.replace(frame, "#" * padding) read_name = "Read_{0}_{1}_{2}".format( - repr_cont["asset"], - repr_cont["subset"], - repr_cont["representation"]) + repr_cont["asset"], + repr_cont["subset"], + repr_cont["representation"]) # Create the Loader with the filename path set with viewer_update_and_undo_stop(): @@ -250,7 +250,7 @@ class LoadSequence(api.Loader): if "#" not in file: frame = repr_cont.get("frame") padding = len(frame) - file = file.replace(frame, "#"*padding) + file = file.replace(frame, "#" * padding) # Get start frame from version data version = io.find_one({ @@ -276,10 +276,10 @@ class LoadSequence(api.Loader): last = version_data.get("frameEnd") if first is None: - self.log.warning("Missing start frame for updated version" - "assuming starts at frame 0 for: " - "{} ({})".format( - node['name'].value(), representation)) + self.log.warning( + "Missing start frame for updated version" + "assuming starts at frame 0 for: " + "{} ({})".format(node['name'].value(), representation)) first = 0 first -= self.handle_start diff --git a/pype/plugins/nuke/publish/collect_gizmo.py b/pype/plugins/nuke/publish/collect_gizmo.py index 11e8c17a3f..6436bdc562 100644 --- a/pype/plugins/nuke/publish/collect_gizmo.py +++ b/pype/plugins/nuke/publish/collect_gizmo.py @@ -31,7 +31,7 @@ class CollectGizmo(pyblish.api.InstancePlugin): # get version version = pype.get_version_from_path(nuke.root().name()) - instance.data['version'] = version + instance.data['version'] = int(version) # Add version data to instance version_data = { diff --git a/pype/plugins/nuke/publish/collect_instances.py b/pype/plugins/nuke/publish/collect_instances.py index 9085e12bd8..26a5bf3a2a 100644 --- a/pype/plugins/nuke/publish/collect_instances.py +++ b/pype/plugins/nuke/publish/collect_instances.py @@ -37,7 +37,6 @@ class CollectNukeInstances(pyblish.api.ContextPlugin): except Exception as E: self.log.warning(E) - # get data from avalon knob self.log.debug("node[name]: {}".format(node['name'].value())) avalon_knob_data = get_avalon_knob_data(node, ["avalon:", "ak:"]) @@ -60,7 +59,6 @@ class CollectNukeInstances(pyblish.api.ContextPlugin): families.append(family) - # except disabled nodes but exclude backdrops in test if ("nukenodes" not in family) and (node["disable"].value()): continue @@ -75,8 +73,7 @@ class CollectNukeInstances(pyblish.api.ContextPlugin): # Add all nodes in group instances. if node.Class() == "Group": # only alter families for render family - if "write" in families_ak: - + if "write" == families_ak: if node["render"].value(): self.log.info("flagged for render") add_family = "{}.local".format("render") @@ -97,7 +94,6 @@ class CollectNukeInstances(pyblish.api.ContextPlugin): self.log.debug("__ families: `{}`".format(families)) - # Get format format = root['format'].value() resolution_width = format.width() diff --git a/pype/plugins/nuke/publish/extract_camera.py b/pype/plugins/nuke/publish/extract_camera.py new file mode 100644 index 0000000000..9a1efba1df --- /dev/null +++ b/pype/plugins/nuke/publish/extract_camera.py @@ -0,0 +1,185 @@ +import nuke +import os +import math +import pyblish.api +import pype.api +from avalon.nuke import lib as anlib +from pprint import pformat + + +class ExtractCamera(pype.api.Extractor): + """ 3D camera exctractor + """ + label = 'Exctract Camera' + order = pyblish.api.ExtractorOrder + families = ["camera"] + hosts = ["nuke"] + + # presets + write_geo_knobs = [ + ("file_type", "abc"), + ("storageFormat", "Ogawa"), + ("writeGeometries", False), + ("writePointClouds", False), + ("writeAxes", False) + ] + + def process(self, instance): + handle_start = instance.context.data["handleStart"] + handle_end = instance.context.data["handleEnd"] + first_frame = int(nuke.root()["first_frame"].getValue()) + last_frame = int(nuke.root()["last_frame"].getValue()) + step = 1 + output_range = str(nuke.FrameRange(first_frame, last_frame, step)) + + self.log.info("instance.data: `{}`".format( + pformat(instance.data))) + + rm_nodes = list() + self.log.info("Crating additional nodes") + subset = instance.data["subset"] + staging_dir = self.staging_dir(instance) + + # get extension form preset + extension = next((k[1] for k in self.write_geo_knobs + if k[0] == "file_type"), None) + if not extension: + raise RuntimeError( + "Bad config for extension in presets. " + "Talk to your supervisor or pipeline admin") + + # create file name and path + filename = subset + ".{}".format(extension) + file_path = os.path.join(staging_dir, filename).replace("\\", "/") + + with anlib.maintained_selection(): + # bake camera with axeses onto word coordinate XYZ + rm_n = bakeCameraWithAxeses( + nuke.toNode(instance.data["name"]), output_range) + rm_nodes.append(rm_n) + + # create scene node + rm_n = nuke.createNode("Scene") + rm_nodes.append(rm_n) + + # create write geo node + wg_n = nuke.createNode("WriteGeo") + wg_n["file"].setValue(file_path) + # add path to write to + for k, v in self.write_geo_knobs: + wg_n[k].setValue(v) + rm_nodes.append(wg_n) + + # write out camera + nuke.execute( + wg_n, + int(first_frame), + int(last_frame) + ) + # erase additional nodes + for n in rm_nodes: + nuke.delete(n) + + self.log.info(file_path) + + # create representation data + if "representations" not in instance.data: + instance.data["representations"] = [] + + representation = { + 'name': extension, + 'ext': extension, + 'files': filename, + "stagingDir": staging_dir, + "frameStart": first_frame, + "frameEnd": last_frame + } + instance.data["representations"].append(representation) + + instance.data.update({ + "path": file_path, + "outputDir": staging_dir, + "ext": extension, + "handleStart": handle_start, + "handleEnd": handle_end, + "frameStart": first_frame + handle_start, + "frameEnd": last_frame - handle_end, + "frameStartHandle": first_frame, + "frameEndHandle": last_frame, + }) + + self.log.info("Extracted instance '{0}' to: {1}".format( + instance.name, file_path)) + + +def bakeCameraWithAxeses(camera_node, output_range): + """ Baking all perent hiearchy of axeses into camera + with transposition onto word XYZ coordinance + """ + bakeFocal = False + bakeHaperture = False + bakeVaperture = False + + camera_matrix = camera_node['world_matrix'] + + new_cam_n = nuke.createNode("Camera2") + new_cam_n.setInput(0, None) + new_cam_n['rotate'].setAnimated() + new_cam_n['translate'].setAnimated() + + old_focal = camera_node['focal'] + if old_focal.isAnimated() and not (old_focal.animation(0).constant()): + new_cam_n['focal'].setAnimated() + bakeFocal = True + else: + new_cam_n['focal'].setValue(old_focal.value()) + + old_haperture = camera_node['haperture'] + if old_haperture.isAnimated() and not ( + old_haperture.animation(0).constant()): + new_cam_n['haperture'].setAnimated() + bakeHaperture = True + else: + new_cam_n['haperture'].setValue(old_haperture.value()) + + old_vaperture = camera_node['vaperture'] + if old_vaperture.isAnimated() and not ( + old_vaperture.animation(0).constant()): + new_cam_n['vaperture'].setAnimated() + bakeVaperture = True + else: + new_cam_n['vaperture'].setValue(old_vaperture.value()) + + new_cam_n['win_translate'].setValue(camera_node['win_translate'].value()) + new_cam_n['win_scale'].setValue(camera_node['win_scale'].value()) + + for x in nuke.FrameRange(output_range): + math_matrix = nuke.math.Matrix4() + for y in range(camera_matrix.height()): + for z in range(camera_matrix.width()): + matrix_pointer = z + (y * camera_matrix.width()) + math_matrix[matrix_pointer] = camera_matrix.getValueAt( + x, (y + (z * camera_matrix.width()))) + + rot_matrix = nuke.math.Matrix4(math_matrix) + rot_matrix.rotationOnly() + rot = rot_matrix.rotationsZXY() + + new_cam_n['rotate'].setValueAt(math.degrees(rot[0]), x, 0) + new_cam_n['rotate'].setValueAt(math.degrees(rot[1]), x, 1) + new_cam_n['rotate'].setValueAt(math.degrees(rot[2]), x, 2) + new_cam_n['translate'].setValueAt( + camera_matrix.getValueAt(x, 3), x, 0) + new_cam_n['translate'].setValueAt( + camera_matrix.getValueAt(x, 7), x, 1) + new_cam_n['translate'].setValueAt( + camera_matrix.getValueAt(x, 11), x, 2) + + if bakeFocal: + new_cam_n['focal'].setValueAt(old_focal.getValueAt(x), x) + if bakeHaperture: + new_cam_n['haperture'].setValueAt(old_haperture.getValueAt(x), x) + if bakeVaperture: + new_cam_n['vaperture'].setValueAt(old_vaperture.getValueAt(x), x) + + return new_cam_n diff --git a/pype/plugins/nuke/publish/extract_thumbnail.py b/pype/plugins/nuke/publish/extract_thumbnail.py index a3ef09bc9f..a53cb4d146 100644 --- a/pype/plugins/nuke/publish/extract_thumbnail.py +++ b/pype/plugins/nuke/publish/extract_thumbnail.py @@ -15,10 +15,12 @@ class ExtractThumbnail(pype.api.Extractor): order = pyblish.api.ExtractorOrder + 0.01 label = "Extract Thumbnail" - families = ["review", "render.farm"] + families = ["review"] hosts = ["nuke"] def process(self, instance): + if "render.farm" in instance.data["families"]: + return with anlib.maintained_selection(): self.log.debug("instance: {}".format(instance)) diff --git a/pype/plugins/nukestudio/publish/collect_hierarchy_context.py b/pype/plugins/nukestudio/publish/collect_hierarchy_context.py index a41e987bdb..930efd618e 100644 --- a/pype/plugins/nukestudio/publish/collect_hierarchy_context.py +++ b/pype/plugins/nukestudio/publish/collect_hierarchy_context.py @@ -273,8 +273,6 @@ class CollectHierarchyContext(pyblish.api.ContextPlugin): instance.data["clipOut"] - instance.data["clipIn"]) - - self.log.debug( "__ instance.data[parents]: {}".format( instance.data["parents"] @@ -319,6 +317,7 @@ class CollectHierarchyContext(pyblish.api.ContextPlugin): }) in_info['tasks'] = instance.data['tasks'] + in_info["comments"] = instance.data.get("comments", []) parents = instance.data.get('parents', []) self.log.debug("__ in_info: {}".format(in_info)) diff --git a/pype/plugins/nukestudio/publish/collect_reviews.py b/pype/plugins/nukestudio/publish/collect_reviews.py index aa8c60767c..3167c66170 100644 --- a/pype/plugins/nukestudio/publish/collect_reviews.py +++ b/pype/plugins/nukestudio/publish/collect_reviews.py @@ -63,10 +63,14 @@ class CollectReviews(api.InstancePlugin): self.log.debug("Track item on plateMain") rev_inst = None for inst in instance.context[:]: - if inst.data["track"] in track: - rev_inst = inst - self.log.debug("Instance review: {}".format( - rev_inst.data["name"])) + if inst.data["track"] != track: + continue + + if inst.data["item"].name() != instance.data["item"].name(): + continue + + rev_inst = inst + break if rev_inst is None: raise RuntimeError(( @@ -82,7 +86,7 @@ class CollectReviews(api.InstancePlugin): ext = os.path.splitext(file)[-1][1:] # change label - instance.data["label"] = "{0} - {1} - ({2}) - review".format( + instance.data["label"] = "{0} - {1} - ({2})".format( instance.data['asset'], instance.data["subset"], ext ) @@ -99,7 +103,7 @@ class CollectReviews(api.InstancePlugin): "step": 1, "fps": rev_inst.data.get("fps"), "name": "preview", - "tags": ["preview"], + "tags": ["preview", "ftrackreview"], "ext": ext } diff --git a/pype/plugins/nukestudio/publish/collect_shots.py b/pype/plugins/nukestudio/publish/collect_shots.py index 455e25bf82..03fc7ab282 100644 --- a/pype/plugins/nukestudio/publish/collect_shots.py +++ b/pype/plugins/nukestudio/publish/collect_shots.py @@ -40,11 +40,12 @@ class CollectShots(api.InstancePlugin): data["name"] = data["subset"] + "_" + data["asset"] data["label"] = ( - "{} - {} - tasks:{} - assetbuilds:{}".format( + "{} - {} - tasks:{} - assetbuilds:{} - comments:{}".format( data["asset"], data["subset"], - data["tasks"], - [x["name"] for x in data.get("assetbuilds", [])] + data["tasks"].keys(), + [x["name"] for x in data.get("assetbuilds", [])], + len(data.get("comments", [])) ) ) diff --git a/pype/plugins/nukestudio/publish/collect_tag_comments.py b/pype/plugins/nukestudio/publish/collect_tag_comments.py index 1ec98e3d3b..e14e53d439 100644 --- a/pype/plugins/nukestudio/publish/collect_tag_comments.py +++ b/pype/plugins/nukestudio/publish/collect_tag_comments.py @@ -17,7 +17,7 @@ class CollectClipTagComments(api.InstancePlugin): for tag in instance.data["tags"]: if tag["name"].lower() == "comment": instance.data["comments"].append( - tag.metadata().dict()["tag.note"] + tag["metadata"]["tag.note"] ) # Find tags on the source clip. diff --git a/pype/plugins/nukestudio/publish/extract_review_cutup_video.py b/pype/plugins/nukestudio/publish/extract_review_cutup_video.py index a4fbf90bed..d1ce3675b1 100644 --- a/pype/plugins/nukestudio/publish/extract_review_cutup_video.py +++ b/pype/plugins/nukestudio/publish/extract_review_cutup_video.py @@ -76,7 +76,7 @@ class ExtractReviewCutUpVideo(pype.api.Extractor): # check if audio stream is in input video file ffprob_cmd = ( - "{ffprobe_path} -i {full_input_path} -show_streams " + "{ffprobe_path} -i \"{full_input_path}\" -show_streams " "-select_streams a -loglevel error" ).format(**locals()) self.log.debug("ffprob_cmd: {}".format(ffprob_cmd)) @@ -106,7 +106,7 @@ class ExtractReviewCutUpVideo(pype.api.Extractor): # try to get video native resolution data try: resolution_output = pype.api.subprocess(( - "{ffprobe_path} -i {full_input_path} -v error " + "{ffprobe_path} -i \"{full_input_path}\" -v error " "-select_streams v:0 -show_entries " "stream=width,height -of csv=s=x:p=0" ).format(**locals())) @@ -193,7 +193,7 @@ class ExtractReviewCutUpVideo(pype.api.Extractor): # append ffmpeg input video clip input_args.append("-ss {:0.2f}".format(start_sec)) input_args.append("-t {:0.2f}".format(duration_sec)) - input_args.append("-i {}".format(full_input_path)) + input_args.append("-i \"{}\"".format(full_input_path)) # add copy audio video codec if only shortening clip if ("_cut-bigger" in tags) and (not empty_add): @@ -203,8 +203,7 @@ class ExtractReviewCutUpVideo(pype.api.Extractor): output_args.append("-intra") # output filename - output_args.append("-y") - output_args.append(full_output_path) + output_args.append("-y \"{}\"".format(full_output_path)) mov_args = [ ffmpeg_path, diff --git a/pype/plugins/photoshop/create/create_image.py b/pype/plugins/photoshop/create/create_image.py index 5b2f9f7981..c1a7d92a2c 100644 --- a/pype/plugins/photoshop/create/create_image.py +++ b/pype/plugins/photoshop/create/create_image.py @@ -1,5 +1,6 @@ -from avalon import api, photoshop +from avalon import api from avalon.vendor import Qt +from avalon import photoshop class CreateImage(api.Creator): @@ -13,11 +14,12 @@ class CreateImage(api.Creator): groups = [] layers = [] create_group = False - group_constant = photoshop.get_com_objects().constants().psLayerSet + + stub = photoshop.stub() if (self.options or {}).get("useSelection"): multiple_instances = False - selection = photoshop.get_selected_layers() - + selection = stub.get_selected_layers() + self.log.info("selection {}".format(selection)) if len(selection) > 1: # Ask user whether to create one image or image per selected # item. @@ -40,19 +42,18 @@ class CreateImage(api.Creator): if multiple_instances: for item in selection: - if item.LayerType == group_constant: + if item.group: groups.append(item) else: layers.append(item) else: - group = photoshop.group_selected_layers() - group.Name = self.name + group = stub.group_selected_layers(self.name) groups.append(group) elif len(selection) == 1: # One selected item. Use group if its a LayerSet (group), else # create a new group. - if selection[0].LayerType == group_constant: + if selection[0].group: groups.append(selection[0]) else: layers.append(selection[0]) @@ -63,16 +64,14 @@ class CreateImage(api.Creator): create_group = True if create_group: - group = photoshop.app().ActiveDocument.LayerSets.Add() - group.Name = self.name + group = stub.create_group(self.name) groups.append(group) for layer in layers: - photoshop.select_layers([layer]) - group = photoshop.group_selected_layers() - group.Name = layer.Name + stub.select_layers([layer]) + group = stub.group_selected_layers(layer.name) groups.append(group) for group in groups: - self.data.update({"subset": "image" + group.Name}) - photoshop.imprint(group, self.data) + self.data.update({"subset": "image" + group.name}) + stub.imprint(group, self.data) diff --git a/pype/plugins/photoshop/load/load_image.py b/pype/plugins/photoshop/load/load_image.py index 18efe750d5..75c02bb327 100644 --- a/pype/plugins/photoshop/load/load_image.py +++ b/pype/plugins/photoshop/load/load_image.py @@ -1,5 +1,7 @@ from avalon import api, photoshop +stub = photoshop.stub() + class ImageLoader(api.Loader): """Load images @@ -12,7 +14,7 @@ class ImageLoader(api.Loader): def load(self, context, name=None, namespace=None, data=None): with photoshop.maintained_selection(): - layer = photoshop.import_smart_object(self.fname) + layer = stub.import_smart_object(self.fname) self[:] = [layer] @@ -28,11 +30,11 @@ class ImageLoader(api.Loader): layer = container.pop("layer") with photoshop.maintained_selection(): - photoshop.replace_smart_object( + stub.replace_smart_object( layer, api.get_representation_path(representation) ) - photoshop.imprint( + stub.imprint( layer, {"representation": str(representation["_id"])} ) diff --git a/pype/plugins/photoshop/publish/collect_current_file.py b/pype/plugins/photoshop/publish/collect_current_file.py index 4308588559..3cc3e3f636 100644 --- a/pype/plugins/photoshop/publish/collect_current_file.py +++ b/pype/plugins/photoshop/publish/collect_current_file.py @@ -1,6 +1,7 @@ import os import pyblish.api + from avalon import photoshop @@ -13,5 +14,5 @@ class CollectCurrentFile(pyblish.api.ContextPlugin): def process(self, context): context.data["currentFile"] = os.path.normpath( - photoshop.app().ActiveDocument.FullName + photoshop.stub().get_active_document_full_name() ).replace("\\", "/") diff --git a/pype/plugins/photoshop/publish/collect_instances.py b/pype/plugins/photoshop/publish/collect_instances.py index 4937f2a1e4..81d1c80bf6 100644 --- a/pype/plugins/photoshop/publish/collect_instances.py +++ b/pype/plugins/photoshop/publish/collect_instances.py @@ -1,9 +1,9 @@ import pythoncom -from avalon import photoshop - import pyblish.api +from avalon import photoshop + class CollectInstances(pyblish.api.ContextPlugin): """Gather instances by LayerSet and file metadata @@ -27,8 +27,11 @@ class CollectInstances(pyblish.api.ContextPlugin): # can be. pythoncom.CoInitialize() - for layer in photoshop.get_layers_in_document(): - layer_data = photoshop.read(layer) + stub = photoshop.stub() + layers = stub.get_layers() + layers_meta = stub.get_layers_metadata() + for layer in layers: + layer_data = stub.read(layer, layers_meta) # Skip layers without metadata. if layer_data is None: @@ -38,18 +41,19 @@ class CollectInstances(pyblish.api.ContextPlugin): if "container" in layer_data["id"]: continue - child_layers = [*layer.Layers] - if not child_layers: - self.log.info("%s skipped, it was empty." % layer.Name) - continue + # child_layers = [*layer.Layers] + # self.log.debug("child_layers {}".format(child_layers)) + # if not child_layers: + # self.log.info("%s skipped, it was empty." % layer.Name) + # continue - instance = context.create_instance(layer.Name) + instance = context.create_instance(layer.name) instance.append(layer) instance.data.update(layer_data) instance.data["families"] = self.families_mapping[ layer_data["family"] ] - instance.data["publish"] = layer.Visible + instance.data["publish"] = layer.visible # Produce diagnostic message for any graphical # user interface interested in visualising it. diff --git a/pype/plugins/photoshop/publish/extract_image.py b/pype/plugins/photoshop/publish/extract_image.py index 6dfccdc4f2..38920b5557 100644 --- a/pype/plugins/photoshop/publish/extract_image.py +++ b/pype/plugins/photoshop/publish/extract_image.py @@ -21,35 +21,37 @@ class ExtractImage(pype.api.Extractor): self.log.info("Outputting image to {}".format(staging_dir)) # Perform extraction + stub = photoshop.stub() files = {} with photoshop.maintained_selection(): self.log.info("Extracting %s" % str(list(instance))) with photoshop.maintained_visibility(): # Hide all other layers. - extract_ids = [ - x.id for x in photoshop.get_layers_in_layers([instance[0]]) - ] - for layer in photoshop.get_layers_in_document(): - if layer.id not in extract_ids: - layer.Visible = False + extract_ids = set([ll.id for ll in stub. + get_layers_in_layers([instance[0]])]) - save_options = {} + for layer in stub.get_layers(): + # limit unnecessary calls to client + if layer.visible and layer.id not in extract_ids: + stub.set_visible(layer.id, False) + if not layer.visible and layer.id in extract_ids: + stub.set_visible(layer.id, True) + + save_options = [] if "png" in self.formats: - save_options["png"] = photoshop.com_objects.PNGSaveOptions() + save_options.append('png') if "jpg" in self.formats: - save_options["jpg"] = photoshop.com_objects.JPEGSaveOptions() + save_options.append('jpg') file_basename = os.path.splitext( - photoshop.app().ActiveDocument.Name + stub.get_active_document_name() )[0] - for extension, save_option in save_options.items(): + for extension in save_options: _filename = "{}.{}".format(file_basename, extension) files[extension] = _filename full_filename = os.path.join(staging_dir, _filename) - photoshop.app().ActiveDocument.SaveAs( - full_filename, save_option, True - ) + stub.saveAs(full_filename, extension, True) representations = [] for extension, filename in files.items(): diff --git a/pype/plugins/photoshop/publish/extract_review.py b/pype/plugins/photoshop/publish/extract_review.py index 078ee53899..6fb50bba9f 100644 --- a/pype/plugins/photoshop/publish/extract_review.py +++ b/pype/plugins/photoshop/publish/extract_review.py @@ -13,10 +13,11 @@ class ExtractReview(pype.api.Extractor): families = ["review"] def process(self, instance): - staging_dir = self.staging_dir(instance) self.log.info("Outputting image to {}".format(staging_dir)) + stub = photoshop.stub() + layers = [] for image_instance in instance.context: if image_instance.data["family"] != "image": @@ -25,25 +26,22 @@ class ExtractReview(pype.api.Extractor): # Perform extraction output_image = "{}.jpg".format( - os.path.splitext(photoshop.app().ActiveDocument.Name)[0] + os.path.splitext(stub.get_active_document_name())[0] ) output_image_path = os.path.join(staging_dir, output_image) with photoshop.maintained_visibility(): # Hide all other layers. - extract_ids = [ - x.id for x in photoshop.get_layers_in_layers(layers) - ] - for layer in photoshop.get_layers_in_document(): - if layer.id in extract_ids: - layer.Visible = True - else: - layer.Visible = False + extract_ids = set([ll.id for ll in stub. + get_layers_in_layers(layers)]) + self.log.info("extract_ids {}".format(extract_ids)) + for layer in stub.get_layers(): + # limit unnecessary calls to client + if layer.visible and layer.id not in extract_ids: + stub.set_visible(layer.id, False) + if not layer.visible and layer.id in extract_ids: + stub.set_visible(layer.id, True) - photoshop.app().ActiveDocument.SaveAs( - output_image_path, - photoshop.com_objects.JPEGSaveOptions(), - True - ) + stub.saveAs(output_image_path, 'jpg', True) ffmpeg_path = pype.lib.get_ffmpeg_tool_path("ffmpeg") @@ -66,8 +64,6 @@ class ExtractReview(pype.api.Extractor): ] output = pype.lib._subprocess(args) - self.log.debug(output) - instance.data["representations"].append({ "name": "thumbnail", "ext": "jpg", @@ -75,7 +71,6 @@ class ExtractReview(pype.api.Extractor): "stagingDir": staging_dir, "tags": ["thumbnail"] }) - # Generate mov. mov_path = os.path.join(staging_dir, "review.mov") args = [ @@ -86,9 +81,7 @@ class ExtractReview(pype.api.Extractor): mov_path ] output = pype.lib._subprocess(args) - self.log.debug(output) - instance.data["representations"].append({ "name": "mov", "ext": "mov", diff --git a/pype/plugins/photoshop/publish/extract_save_scene.py b/pype/plugins/photoshop/publish/extract_save_scene.py index b3d4f0e447..63a4b7b7ea 100644 --- a/pype/plugins/photoshop/publish/extract_save_scene.py +++ b/pype/plugins/photoshop/publish/extract_save_scene.py @@ -11,4 +11,4 @@ class ExtractSaveScene(pype.api.Extractor): families = ["workfile"] def process(self, instance): - photoshop.app().ActiveDocument.Save() + photoshop.stub().save() diff --git a/pype/plugins/photoshop/publish/increment_workfile.py b/pype/plugins/photoshop/publish/increment_workfile.py index ba9ab8606a..eca2583595 100644 --- a/pype/plugins/photoshop/publish/increment_workfile.py +++ b/pype/plugins/photoshop/publish/increment_workfile.py @@ -1,6 +1,7 @@ import pyblish.api from pype.action import get_errored_plugins_from_data from pype.lib import version_up + from avalon import photoshop @@ -24,6 +25,6 @@ class IncrementWorkfile(pyblish.api.InstancePlugin): ) scene_path = version_up(instance.context.data["currentFile"]) - photoshop.app().ActiveDocument.SaveAs(scene_path) + photoshop.stub().saveAs(scene_path, 'psd', True) self.log.info("Incremented workfile to: {}".format(scene_path)) diff --git a/pype/plugins/photoshop/publish/validate_instance_asset.py b/pype/plugins/photoshop/publish/validate_instance_asset.py index ab1d02269f..f05d9601dd 100644 --- a/pype/plugins/photoshop/publish/validate_instance_asset.py +++ b/pype/plugins/photoshop/publish/validate_instance_asset.py @@ -23,11 +23,12 @@ class ValidateInstanceAssetRepair(pyblish.api.Action): # Apply pyblish.logic to get the instances for the plug-in instances = pyblish.api.instances_by_plugin(failed, plugin) - + stub = photoshop.stub() for instance in instances: - data = photoshop.read(instance[0]) + data = stub.read(instance[0]) + data["asset"] = os.environ["AVALON_ASSET"] - photoshop.imprint(instance[0], data) + stub.imprint(instance[0], data) class ValidateInstanceAsset(pyblish.api.InstancePlugin): diff --git a/pype/plugins/photoshop/publish/validate_naming.py b/pype/plugins/photoshop/publish/validate_naming.py index 51e00da352..2483adcb5e 100644 --- a/pype/plugins/photoshop/publish/validate_naming.py +++ b/pype/plugins/photoshop/publish/validate_naming.py @@ -21,13 +21,14 @@ class ValidateNamingRepair(pyblish.api.Action): # Apply pyblish.logic to get the instances for the plug-in instances = pyblish.api.instances_by_plugin(failed, plugin) - + stub = photoshop.stub() for instance in instances: + self.log.info("validate_naming instance {}".format(instance)) name = instance.data["name"].replace(" ", "_") instance[0].Name = name - data = photoshop.read(instance[0]) + data = stub.read(instance[0]) data["subset"] = "image" + name - photoshop.imprint(instance[0], data) + stub.imprint(instance[0], data) return True diff --git a/pype/plugins/resolve/create/create_shot_clip.py b/pype/plugins/resolve/create/create_shot_clip.py new file mode 100644 index 0000000000..bd2e013fac --- /dev/null +++ b/pype/plugins/resolve/create/create_shot_clip.py @@ -0,0 +1,79 @@ +from pprint import pformat +from pype.hosts import resolve +from pype.hosts.resolve import lib + + +class CreateShotClip(resolve.Creator): + """Publishable clip""" + + label = "Shot" + family = "clip" + icon = "film" + defaults = ["Main"] + + gui_name = "Pype sequencial rename with hirerarchy" + gui_info = "Define sequencial rename and fill hierarchy data." + gui_inputs = { + "clipName": "{episode}{sequence}{shot}", + "hierarchy": "{folder}/{sequence}/{shot}", + "countFrom": 10, + "steps": 10, + "hierarchyData": { + "folder": "shots", + "shot": "sh####", + "track": "{track}", + "sequence": "sc010", + "episode": "ep01" + } + } + presets = None + + def process(self): + # solve gui inputs overwrites from presets + # overwrite gui inputs from presets + for k, v in self.gui_inputs.items(): + if isinstance(v, dict): + # nested dictionary (only one level allowed) + for _k, _v in v.items(): + if self.presets.get(_k): + self.gui_inputs[k][_k] = self.presets[_k] + if self.presets.get(k): + self.gui_inputs[k] = self.presets[k] + + # open widget for plugins inputs + widget = self.widget(self.gui_name, self.gui_info, self.gui_inputs) + widget.exec_() + + print(f"__ selected_clips: {self.selected}") + if len(self.selected) < 1: + return + + if not widget.result: + print("Operation aborted") + return + + # sequence attrs + sq_frame_start = self.sequence.GetStartFrame() + sq_markers = self.sequence.GetMarkers() + print(f"__ sq_frame_start: {pformat(sq_frame_start)}") + print(f"__ seq_markers: {pformat(sq_markers)}") + + # create media bin for compound clips (trackItems) + mp_folder = resolve.create_current_sequence_media_bin(self.sequence) + print(f"_ mp_folder: {mp_folder.GetName()}") + + lib.rename_add = 0 + for i, t_data in enumerate(self.selected): + lib.rename_index = i + + # clear color after it is done + t_data["clip"]["item"].ClearClipColor() + + # convert track item to timeline media pool item + resolve.create_compound_clip( + t_data, + mp_folder, + rename=True, + **dict( + {"presets": widget.result}) + ) diff --git a/pype/plugins/resolve/publish/collect_clips.py b/pype/plugins/resolve/publish/collect_clips.py new file mode 100644 index 0000000000..f86e5c8384 --- /dev/null +++ b/pype/plugins/resolve/publish/collect_clips.py @@ -0,0 +1,162 @@ +import os +from pyblish import api +from pype.hosts import resolve +import json + + +class CollectClips(api.ContextPlugin): + """Collect all Track items selection.""" + + order = api.CollectorOrder + 0.01 + label = "Collect Clips" + hosts = ["resolve"] + + def process(self, context): + # create asset_names conversion table + if not context.data.get("assetsShared"): + self.log.debug("Created `assetsShared` in context") + context.data["assetsShared"] = dict() + + projectdata = context.data["projectEntity"]["data"] + selection = resolve.get_current_track_items( + filter=True, selecting_color="Pink") + + for clip_data in selection: + data = dict() + + # get basic objects form data + project = clip_data["project"] + sequence = clip_data["sequence"] + clip = clip_data["clip"] + + # sequence attrs + sq_frame_start = sequence.GetStartFrame() + self.log.debug(f"sq_frame_start: {sq_frame_start}") + + sq_markers = sequence.GetMarkers() + + # get details of objects + clip_item = clip["item"] + track = clip_data["track"] + + mp = project.GetMediaPool() + + # get clip attributes + clip_metadata = resolve.get_pype_clip_metadata(clip_item) + clip_metadata = json.loads(clip_metadata) + self.log.debug(f"clip_metadata: {clip_metadata}") + + compound_source_prop = clip_metadata["sourceProperties"] + self.log.debug(f"compound_source_prop: {compound_source_prop}") + + asset_name = clip_item.GetName() + mp_item = clip_item.GetMediaPoolItem() + mp_prop = mp_item.GetClipProperty() + source_first = int(compound_source_prop["Start"]) + source_last = int(compound_source_prop["End"]) + source_duration = compound_source_prop["Frames"] + fps = float(mp_prop["FPS"]) + self.log.debug(f"source_first: {source_first}") + self.log.debug(f"source_last: {source_last}") + self.log.debug(f"source_duration: {source_duration}") + self.log.debug(f"fps: {fps}") + + source_path = os.path.normpath( + compound_source_prop["File Path"]) + source_name = compound_source_prop["File Name"] + source_id = clip_metadata["sourceId"] + self.log.debug(f"source_path: {source_path}") + self.log.debug(f"source_name: {source_name}") + self.log.debug(f"source_id: {source_id}") + + clip_left_offset = int(clip_item.GetLeftOffset()) + clip_right_offset = int(clip_item.GetRightOffset()) + self.log.debug(f"clip_left_offset: {clip_left_offset}") + self.log.debug(f"clip_right_offset: {clip_right_offset}") + + # source in/out + source_in = int(source_first + clip_left_offset) + source_out = int(source_first + clip_right_offset) + self.log.debug(f"source_in: {source_in}") + self.log.debug(f"source_out: {source_out}") + + clip_in = int(clip_item.GetStart() - sq_frame_start) + clip_out = int(clip_item.GetEnd() - sq_frame_start) + clip_duration = int(clip_item.GetDuration()) + self.log.debug(f"clip_in: {clip_in}") + self.log.debug(f"clip_out: {clip_out}") + self.log.debug(f"clip_duration: {clip_duration}") + + is_sequence = False + + self.log.debug( + "__ assets_shared: {}".format( + context.data["assetsShared"])) + + # Check for clips with the same range + # this is for testing if any vertically neighbouring + # clips has been already processed + clip_matching_with_range = next( + (k for k, v in context.data["assetsShared"].items() + if (v.get("_clipIn", 0) == clip_in) + and (v.get("_clipOut", 0) == clip_out) + ), False) + + # check if clip name is the same in matched + # vertically neighbouring clip + # if it is then it is correct and resent variable to False + # not to be rised wrong name exception + if asset_name in str(clip_matching_with_range): + clip_matching_with_range = False + + # rise wrong name exception if found one + assert (not clip_matching_with_range), ( + "matching clip: {asset}" + " timeline range ({clip_in}:{clip_out})" + " conflicting with {clip_matching_with_range}" + " >> rename any of clips to be the same as the other <<" + ).format( + **locals()) + + if ("[" in source_name) and ("]" in source_name): + is_sequence = True + + data.update({ + "name": "_".join([ + track["name"], asset_name, source_name]), + "item": clip_item, + "source": mp_item, + # "timecodeStart": str(source.timecodeStart()), + "timelineStart": sq_frame_start, + "sourcePath": source_path, + "sourceFileHead": source_name, + "isSequence": is_sequence, + "track": track["name"], + "trackIndex": track["index"], + "sourceFirst": source_first, + + "sourceIn": source_in, + "sourceOut": source_out, + "mediaDuration": source_duration, + "clipIn": clip_in, + "clipOut": clip_out, + "clipDuration": clip_duration, + "asset": asset_name, + "subset": "plateMain", + "family": "clip", + "families": [], + "handleStart": projectdata.get("handleStart", 0), + "handleEnd": projectdata.get("handleEnd", 0)}) + + instance = context.create_instance(**data) + + self.log.info("Created instance: {}".format(instance)) + self.log.info("Created instance.data: {}".format(instance.data)) + + context.data["assetsShared"][asset_name] = { + "_clipIn": clip_in, + "_clipOut": clip_out + } + self.log.info( + "context.data[\"assetsShared\"]: {}".format( + context.data["assetsShared"])) diff --git a/pype/plugins/resolve/publish/collect_host.py b/pype/plugins/resolve/publish/collect_host.py deleted file mode 100644 index a5c4b0936c..0000000000 --- a/pype/plugins/resolve/publish/collect_host.py +++ /dev/null @@ -1,17 +0,0 @@ -import pyblish.api -from pype.hosts.resolve.utils import get_resolve_module - - -class CollectProject(pyblish.api.ContextPlugin): - """Collect Project object""" - - order = pyblish.api.CollectorOrder - 0.1 - label = "Collect Project" - hosts = ["resolve"] - - def process(self, context): - resolve = get_resolve_module() - PM = resolve.GetProjectManager() - P = PM.GetCurrentProject() - - self.log.info(P.GetName()) diff --git a/pype/plugins/resolve/publish/collect_project.py b/pype/plugins/resolve/publish/collect_project.py new file mode 100644 index 0000000000..aa57f93619 --- /dev/null +++ b/pype/plugins/resolve/publish/collect_project.py @@ -0,0 +1,29 @@ +import os +import pyblish.api +from pype.hosts.resolve.utils import get_resolve_module + + +class CollectProject(pyblish.api.ContextPlugin): + """Collect Project object""" + + order = pyblish.api.CollectorOrder - 0.1 + label = "Collect Project" + hosts = ["resolve"] + + def process(self, context): + exported_projet_ext = ".drp" + current_dir = os.getenv("AVALON_WORKDIR") + resolve = get_resolve_module() + PM = resolve.GetProjectManager() + P = PM.GetCurrentProject() + name = P.GetName() + + fname = name + exported_projet_ext + current_file = os.path.join(current_dir, fname) + normalised = os.path.normpath(current_file) + + context.data["project"] = P + context.data["currentFile"] = normalised + + self.log.info(name) + self.log.debug(normalised) diff --git a/pype/plugins/standalonepublisher/publish/collect_clip_instances.py b/pype/plugins/standalonepublisher/publish/collect_clip_instances.py index a7af8df143..def0c13a78 100644 --- a/pype/plugins/standalonepublisher/publish/collect_clip_instances.py +++ b/pype/plugins/standalonepublisher/publish/collect_clip_instances.py @@ -17,13 +17,13 @@ class CollectClipInstances(pyblish.api.InstancePlugin): subsets = { "referenceMain": { "family": "review", - "families": ["review", "ftrack"], + "families": ["clip", "ftrack"], # "ftrackFamily": "review", "extension": ".mp4" }, "audioMain": { "family": "audio", - "families": ["ftrack"], + "families": ["clip", "ftrack"], # "ftrackFamily": "audio", "extension": ".wav", # "version": 1 diff --git a/pype/plugins/standalonepublisher/publish/collect_context.py b/pype/plugins/standalonepublisher/publish/collect_context.py index a5479fdf13..9dbeec93fb 100644 --- a/pype/plugins/standalonepublisher/publish/collect_context.py +++ b/pype/plugins/standalonepublisher/publish/collect_context.py @@ -123,7 +123,7 @@ class CollectContextDataSAPublish(pyblish.api.ContextPlugin): "label": subset, "name": subset, "family": in_data["family"], - "version": in_data.get("version", 1), + # "version": in_data.get("version", 1), "frameStart": in_data.get("representations", [None])[0].get( "frameStart", None ), diff --git a/pype/plugins/standalonepublisher/publish/collect_editorial.py b/pype/plugins/standalonepublisher/publish/collect_editorial.py index a31125d9a8..5e6fd106e4 100644 --- a/pype/plugins/standalonepublisher/publish/collect_editorial.py +++ b/pype/plugins/standalonepublisher/publish/collect_editorial.py @@ -32,7 +32,7 @@ class CollectEditorial(pyblish.api.InstancePlugin): actions = [] # presets - extensions = [".mov"] + extensions = [".mov", ".mp4"] def process(self, instance): # remove context test attribute diff --git a/pype/plugins/standalonepublisher/publish/collect_instance_data.py b/pype/plugins/standalonepublisher/publish/collect_instance_data.py new file mode 100644 index 0000000000..1b32ea9144 --- /dev/null +++ b/pype/plugins/standalonepublisher/publish/collect_instance_data.py @@ -0,0 +1,29 @@ +""" +Requires: + Nothing + +Provides: + Instance +""" + +import pyblish.api +from pprint import pformat + + +class CollectInstanceData(pyblish.api.InstancePlugin): + """ + Collector with only one reason for its existence - remove 'ftrack' + family implicitly added by Standalone Publisher + """ + + label = "Collect instance data" + order = pyblish.api.CollectorOrder + 0.49 + families = ["render", "plate"] + hosts = ["standalonepublisher"] + + def process(self, instance): + fps = instance.data["assetEntity"]["data"]["fps"] + instance.data.update({ + "fps": fps + }) + self.log.debug(f"instance.data: {pformat(instance.data)}") diff --git a/pype/plugins/standalonepublisher/publish/collect_psd_instances.py b/pype/plugins/standalonepublisher/publish/collect_psd_instances.py index 9c8e2eae83..b5db437473 100644 --- a/pype/plugins/standalonepublisher/publish/collect_psd_instances.py +++ b/pype/plugins/standalonepublisher/publish/collect_psd_instances.py @@ -9,7 +9,7 @@ class CollectPsdInstances(pyblish.api.InstancePlugin): """ label = "Collect Psd Instances" - order = pyblish.api.CollectorOrder + 0.492 + order = pyblish.api.CollectorOrder + 0.489 hosts = ["standalonepublisher"] families = ["background_batch"] @@ -34,8 +34,6 @@ class CollectPsdInstances(pyblish.api.InstancePlugin): context = instance.context asset_data = instance.data["assetEntity"] asset_name = instance.data["asset"] - anatomy_data = instance.data["anatomyData"] - for subset_name, subset_data in self.subsets.items(): instance_name = f"{asset_name}_{subset_name}" task = subset_data.get("task", "background") @@ -55,16 +53,8 @@ class CollectPsdInstances(pyblish.api.InstancePlugin): new_instance.data["label"] = f"{instance_name}" new_instance.data["subset"] = subset_name + new_instance.data["task"] = task - # fix anatomy data - anatomy_data_new = copy.deepcopy(anatomy_data) - # updating hierarchy data - anatomy_data_new.update({ - "asset": asset_data["name"], - "task": task, - "subset": subset_name - }) - new_instance.data["anatomyData"] = anatomy_data_new if subset_name in self.unchecked_by_default: new_instance.data["publish"] = False diff --git a/pype/plugins/standalonepublisher/publish/extract_shot_data.py b/pype/plugins/standalonepublisher/publish/extract_shot_data.py index c39247d6d6..d5af7638ee 100644 --- a/pype/plugins/standalonepublisher/publish/extract_shot_data.py +++ b/pype/plugins/standalonepublisher/publish/extract_shot_data.py @@ -10,7 +10,7 @@ class ExtractShotData(pype.api.Extractor): label = "Extract Shot Data" hosts = ["standalonepublisher"] - families = ["review", "audio"] + families = ["clip"] # presets diff --git a/pype/plugins/standalonepublisher/publish/extract_thumbnail.py b/pype/plugins/standalonepublisher/publish/extract_thumbnail.py index cddc9c3a82..5882775083 100644 --- a/pype/plugins/standalonepublisher/publish/extract_thumbnail.py +++ b/pype/plugins/standalonepublisher/publish/extract_thumbnail.py @@ -64,6 +64,7 @@ class ExtractThumbnailSP(pyblish.api.InstancePlugin): else: # Convert to jpeg if not yet full_input_path = os.path.join(thumbnail_repre["stagingDir"], file) + full_input_path = '"{}"'.format(full_input_path) self.log.info("input {}".format(full_input_path)) full_thumbnail_path = tempfile.mkstemp(suffix=".jpg")[1] diff --git a/pype/plugins/standalonepublisher/publish/validate_editorial_resources.py b/pype/plugins/standalonepublisher/publish/validate_editorial_resources.py index ebc449c4ec..7e1694fbd1 100644 --- a/pype/plugins/standalonepublisher/publish/validate_editorial_resources.py +++ b/pype/plugins/standalonepublisher/publish/validate_editorial_resources.py @@ -1,5 +1,3 @@ -import os - import pyblish.api import pype.api @@ -9,10 +7,14 @@ class ValidateEditorialResources(pyblish.api.InstancePlugin): label = "Validate Editorial Resources" hosts = ["standalonepublisher"] - families = ["audio", "review"] + families = ["clip"] + order = pype.api.ValidateContentsOrder def process(self, instance): + self.log.debug( + f"Instance: {instance}, Families: " + f"{[instance.data['family']] + instance.data['families']}") check_file = instance.data["editorialVideoPath"] msg = f"Missing \"{check_file}\"." assert check_file, msg diff --git a/pype/resources/app_icons/hiero.png b/pype/resources/app_icons/hiero.png new file mode 100644 index 0000000000..04bbf6265b Binary files /dev/null and b/pype/resources/app_icons/hiero.png differ diff --git a/pype/resources/app_icons/maya.png b/pype/resources/app_icons/maya.png index e84a6a3742..95c605f50d 100644 Binary files a/pype/resources/app_icons/maya.png and b/pype/resources/app_icons/maya.png differ diff --git a/pype/scripts/otio_burnin.py b/pype/scripts/otio_burnin.py index 156896a759..6607726c73 100644 --- a/pype/scripts/otio_burnin.py +++ b/pype/scripts/otio_burnin.py @@ -15,7 +15,7 @@ ffprobe_path = pype.lib.get_ffmpeg_tool_path("ffprobe") FFMPEG = ( - '{} -loglevel panic -i %(input)s %(filters)s %(args)s%(output)s' + '{} -loglevel panic -i "%(input)s" %(filters)s %(args)s%(output)s' ).format(ffmpeg_path) FFPROBE = ( diff --git a/pype/settings/__init__.py b/pype/settings/__init__.py new file mode 100644 index 0000000000..7e73d541a4 --- /dev/null +++ b/pype/settings/__init__.py @@ -0,0 +1,9 @@ +from .lib import ( + system_settings, + project_settings +) + +__all__ = ( + "system_settings", + "project_settings" +) diff --git a/pype/settings/defaults/project_anatomy/colorspace.json b/pype/settings/defaults/project_anatomy/colorspace.json new file mode 100644 index 0000000000..8b934f810d --- /dev/null +++ b/pype/settings/defaults/project_anatomy/colorspace.json @@ -0,0 +1,42 @@ +{ + "nuke": { + "root": { + "colorManagement": "Nuke", + "OCIO_config": "nuke-default", + "defaultViewerLUT": "Nuke Root LUTs", + "monitorLut": "sRGB", + "int8Lut": "sRGB", + "int16Lut": "sRGB", + "logLut": "Cineon", + "floatLut": "linear" + }, + "viewer": { + "viewerProcess": "sRGB" + }, + "write": { + "render": { + "colorspace": "linear" + }, + "prerender": { + "colorspace": "linear" + }, + "still": { + "colorspace": "sRGB" + } + }, + "read": { + "[^-a-zA-Z0-9]beauty[^-a-zA-Z0-9]": "linear", + "[^-a-zA-Z0-9](P|N|Z|crypto)[^-a-zA-Z0-9]": "linear", + "[^-a-zA-Z0-9](plateRef)[^-a-zA-Z0-9]": "sRGB" + } + }, + "maya": { + + }, + "houdini": { + + }, + "resolve": { + + } +} diff --git a/pype/settings/defaults/project_anatomy/dataflow.json b/pype/settings/defaults/project_anatomy/dataflow.json new file mode 100644 index 0000000000..d2f470b5bc --- /dev/null +++ b/pype/settings/defaults/project_anatomy/dataflow.json @@ -0,0 +1,55 @@ +{ + "nuke": { + "nodes": { + "connected": true, + "modifymetadata": { + "_id": "connect_metadata", + "_previous": "ENDING", + "metadata.set.pype_studio_name": "{PYPE_STUDIO_NAME}", + "metadata.set.avalon_project_name": "{AVALON_PROJECT}", + "metadata.set.avalon_project_code": "{PYPE_STUDIO_CODE}", + "metadata.set.avalon_asset_name": "{AVALON_ASSET}" + }, + "crop": { + "_id": "connect_crop", + "_previous": "connect_metadata", + "box": [ + "{metadata.crop.x}", + "{metadata.crop.y}", + "{metadata.crop.right}", + "{metadata.crop.top}" + ] + }, + "write": { + "render": { + "_id": "output_write", + "_previous": "connect_crop", + "file_type": "exr", + "datatype": "16 bit half", + "compression": "Zip (1 scanline)", + "autocrop": true, + "tile_color": "0xff0000ff", + "channels": "rgb" + }, + "prerender": { + "_id": "output_write", + "_previous": "connect_crop", + "file_type": "exr", + "datatype": "16 bit half", + "compression": "Zip (1 scanline)", + "autocrop": false, + "tile_color": "0xc9892aff", + "channels": "rgba" + }, + "still": { + "_previous": "connect_crop", + "channels": "rgba", + "file_type": "tiff", + "datatype": "16 bit", + "compression": "LZW", + "tile_color": "0x4145afff" + } + } + } + } +} diff --git a/pype/settings/defaults/project_anatomy/roots.json b/pype/settings/defaults/project_anatomy/roots.json new file mode 100644 index 0000000000..0282471a60 --- /dev/null +++ b/pype/settings/defaults/project_anatomy/roots.json @@ -0,0 +1,5 @@ +{ + "windows": "C:/projects", + "linux": "/mnt/share/projects", + "darwin": "/Volumes/path" +} diff --git a/pype/settings/defaults/project_anatomy/templates.json b/pype/settings/defaults/project_anatomy/templates.json new file mode 100644 index 0000000000..0fff0265b3 --- /dev/null +++ b/pype/settings/defaults/project_anatomy/templates.json @@ -0,0 +1,30 @@ +{ + "version_padding": 3, + "version": "v{version:0>{@version_padding}}", + "frame_padding": 4, + "frame": "{frame:0>{@frame_padding}}", + "work": { + "folder": "{root}/{project[name]}/{hierarchy}/{asset}/work/{task}", + "file": "{project[code]}_{asset}_{task}_{@version}<_{comment}>.{ext}", + "path": "{@folder}/{@file}" + }, + "render": { + "folder": "{root}/{project[name]}/{hierarchy}/{asset}/publish/render/{subset}/{@version}", + "file": "{project[code]}_{asset}_{subset}_{@version}<_{output}><.{@frame}>.{representation}", + "path": "{@folder}/{@file}" + }, + "texture": { + "path": "{root}/{project[name]}/{hierarchy}/{asset}/publish/{family}/{subset}" + }, + "publish": { + "folder": "{root}/{project[name]}/{hierarchy}/{asset}/publish/{family}/{subset}/{@version}", + "file": "{project[code]}_{asset}_{subset}_{@version}<_{output}><.{@frame}>.{representation}", + "path": "{@folder}/{@file}", + "thumbnail": "{thumbnail_root}/{project[name]}/{_id}_{thumbnail_type}{ext}" + }, + "master": { + "folder": "{root}/{project[name]}/{hierarchy}/{asset}/publish/{family}/{subset}/master", + "file": "{project[code]}_{asset}_{subset}_master<_{output}><.{frame}>.{representation}", + "path": "{@folder}/{@file}" + } +} \ No newline at end of file diff --git a/pype/settings/defaults/project_settings/ftrack/ftrack_config.json b/pype/settings/defaults/project_settings/ftrack/ftrack_config.json new file mode 100644 index 0000000000..1ef3a9d69f --- /dev/null +++ b/pype/settings/defaults/project_settings/ftrack/ftrack_config.json @@ -0,0 +1,16 @@ +{ + "sync_to_avalon": { + "statuses_name_change": ["not ready", "ready"] + }, + + "status_update": { + "_ignore_": ["in progress", "ommited", "on hold"], + "Ready": ["not ready"], + "In Progress" : ["_any_"] + }, + "status_version_to_task": { + "__description__": "Status `from` (key) must be lowered!", + "in progress": "in progress", + "approved": "approved" + } +} diff --git a/pype/settings/defaults/project_settings/ftrack/ftrack_custom_attributes.json b/pype/settings/defaults/project_settings/ftrack/ftrack_custom_attributes.json new file mode 100644 index 0000000000..f03d473cd0 --- /dev/null +++ b/pype/settings/defaults/project_settings/ftrack/ftrack_custom_attributes.json @@ -0,0 +1,165 @@ +[{ + "label": "FPS", + "key": "fps", + "type": "number", + "is_hierarchical": true, + "group": "avalon", + "write_security_role": ["ALL"], + "read_security_role": ["ALL"], + "default": null, + "config": { + "isdecimal": true + } +}, { + "label": "Applications", + "key": "applications", + "type": "enumerator", + "entity_type": "show", + "group": "avalon", + "config": { + "multiselect": true, + "data": [ + {"blender_2.80": "Blender 2.80"}, + {"blender_2.81": "Blender 2.81"}, + {"blender_2.82": "Blender 2.82"}, + {"blender_2.83": "Blender 2.83"}, + {"celaction_local": "CelAction2D Local"}, + {"maya_2017": "Maya 2017"}, + {"maya_2018": "Maya 2018"}, + {"maya_2019": "Maya 2019"}, + {"nuke_10.0": "Nuke 10.0"}, + {"nuke_11.2": "Nuke 11.2"}, + {"nuke_11.3": "Nuke 11.3"}, + {"nuke_12.0": "Nuke 12.0"}, + {"nukex_10.0": "NukeX 10.0"}, + {"nukex_11.2": "NukeX 11.2"}, + {"nukex_11.3": "NukeX 11.3"}, + {"nukex_12.0": "NukeX 12.0"}, + {"nukestudio_10.0": "NukeStudio 10.0"}, + {"nukestudio_11.2": "NukeStudio 11.2"}, + {"nukestudio_11.3": "NukeStudio 11.3"}, + {"nukestudio_12.0": "NukeStudio 12.0"}, + {"harmony_17": "Harmony 17"}, + {"houdini_16.5": "Houdini 16.5"}, + {"houdini_17": "Houdini 17"}, + {"houdini_18": "Houdini 18"}, + {"photoshop_2020": "Photoshop 2020"}, + {"python_3": "Python 3"}, + {"python_2": "Python 2"}, + {"premiere_2019": "Premiere Pro 2019"}, + {"premiere_2020": "Premiere Pro 2020"}, + {"resolve_16": "BM DaVinci Resolve 16"} + ] + } +}, { + "label": "Avalon auto-sync", + "key": "avalon_auto_sync", + "type": "boolean", + "entity_type": "show", + "group": "avalon", + "write_security_role": ["API", "Administrator"], + "read_security_role": ["API", "Administrator"] +}, { + "label": "Intent", + "key": "intent", + "type": "enumerator", + "entity_type": "assetversion", + "group": "avalon", + "config": { + "multiselect": false, + "data": [ + {"test": "Test"}, + {"wip": "WIP"}, + {"final": "Final"} + ] + } +}, { + "label": "Library Project", + "key": "library_project", + "type": "boolean", + "entity_type": "show", + "group": "avalon", + "write_security_role": ["API", "Administrator"], + "read_security_role": ["API", "Administrator"] +}, { + "label": "Clip in", + "key": "clipIn", + "type": "number", + "is_hierarchical": true, + "group": "avalon", + "default": null +}, { + "label": "Clip out", + "key": "clipOut", + "type": "number", + "is_hierarchical": true, + "group": "avalon", + "default": null +}, { + "label": "Frame start", + "key": "frameStart", + "type": "number", + "is_hierarchical": true, + "group": "avalon", + "default": null +}, { + "label": "Frame end", + "key": "frameEnd", + "type": "number", + "is_hierarchical": true, + "group": "avalon", + "default": null +}, { + "label": "Tools", + "key": "tools_env", + "type": "enumerator", + "is_hierarchical": true, + "group": "avalon", + "config": { + "multiselect": true, + "data": [ + {"mtoa_3.0.1": "mtoa_3.0.1"}, + {"mtoa_3.1.1": "mtoa_3.1.1"}, + {"mtoa_3.2.0": "mtoa_3.2.0"}, + {"yeti_2.1.2": "yeti_2.1"} + ] + } +}, { + "label": "Resolution Width", + "key": "resolutionWidth", + "type": "number", + "is_hierarchical": true, + "group": "avalon", + "default": null +}, { + "label": "Resolution Height", + "key": "resolutionHeight", + "type": "number", + "is_hierarchical": true, + "group": "avalon", + "default": null +}, { + "label": "Pixel aspect", + "key": "pixelAspect", + "type": "number", + "is_hierarchical": true, + "group": "avalon", + "config": { + "isdecimal": true + } +}, { + "label": "Frame handles start", + "key": "handleStart", + "type": "number", + "is_hierarchical": true, + "group": "avalon", + "default": null +}, { + "label": "Frame handles end", + "key": "handleEnd", + "type": "number", + "is_hierarchical": true, + "group": "avalon", + "default": null +} +] diff --git a/pype/settings/defaults/project_settings/ftrack/partnership_ftrack_cred.json b/pype/settings/defaults/project_settings/ftrack/partnership_ftrack_cred.json new file mode 100644 index 0000000000..6b3a32f181 --- /dev/null +++ b/pype/settings/defaults/project_settings/ftrack/partnership_ftrack_cred.json @@ -0,0 +1,5 @@ +{ + "server_url": "", + "api_key": "", + "api_user": "" +} diff --git a/pype/settings/defaults/project_settings/ftrack/plugins/server.json b/pype/settings/defaults/project_settings/ftrack/plugins/server.json new file mode 100644 index 0000000000..0967ef424b --- /dev/null +++ b/pype/settings/defaults/project_settings/ftrack/plugins/server.json @@ -0,0 +1 @@ +{} diff --git a/pype/settings/defaults/project_settings/ftrack/plugins/user.json b/pype/settings/defaults/project_settings/ftrack/plugins/user.json new file mode 100644 index 0000000000..1ba8e9b511 --- /dev/null +++ b/pype/settings/defaults/project_settings/ftrack/plugins/user.json @@ -0,0 +1,5 @@ +{ + "TestAction": { + "ignore_me": true + } +} diff --git a/pype/settings/defaults/project_settings/ftrack/project_defaults.json b/pype/settings/defaults/project_settings/ftrack/project_defaults.json new file mode 100644 index 0000000000..a4e3aa3362 --- /dev/null +++ b/pype/settings/defaults/project_settings/ftrack/project_defaults.json @@ -0,0 +1,18 @@ +{ + "fps": 25, + "frameStart": 1001, + "frameEnd": 1100, + "clipIn": 1001, + "clipOut": 1100, + "handleStart": 10, + "handleEnd": 10, + + "resolutionHeight": 1080, + "resolutionWidth": 1920, + "pixelAspect": 1.0, + "applications": [ + "maya_2019", "nuke_11.3", "nukex_11.3", "nukestudio_11.3", "deadline" + ], + "tools_env": [], + "avalon_auto_sync": true +} diff --git a/pype/settings/defaults/project_settings/global/creator.json b/pype/settings/defaults/project_settings/global/creator.json new file mode 100644 index 0000000000..d14e779f01 --- /dev/null +++ b/pype/settings/defaults/project_settings/global/creator.json @@ -0,0 +1,8 @@ +{ + "Model": ["model"], + "Render Globals": ["light", "render"], + "Layout": ["layout"], + "Set Dress": ["setdress"], + "Look": ["look"], + "Rig": ["rigging"] +} diff --git a/pype/settings/defaults/project_settings/global/project_folder_structure.json b/pype/settings/defaults/project_settings/global/project_folder_structure.json new file mode 100644 index 0000000000..83bd5f12a9 --- /dev/null +++ b/pype/settings/defaults/project_settings/global/project_folder_structure.json @@ -0,0 +1,22 @@ +{ + "__project_root__": { + "prod" : {}, + "resources" : { + "footage": { + "plates": {}, + "offline": {} + }, + "audio": {}, + "art_dept": {} + }, + "editorial" : {}, + "assets[ftrack.Library]": { + "characters[ftrack]": {}, + "locations[ftrack]": {} + }, + "shots[ftrack.Sequence]": { + "scripts": {}, + "editorial[ftrack.Folder]": {} + } + } +} diff --git a/pype/settings/defaults/project_settings/global/sw_folders.json b/pype/settings/defaults/project_settings/global/sw_folders.json new file mode 100644 index 0000000000..a154935dce --- /dev/null +++ b/pype/settings/defaults/project_settings/global/sw_folders.json @@ -0,0 +1,8 @@ +{ + "compositing": ["nuke", "ae"], + "modeling": ["maya", "app2"], + "lookdev": ["substance"], + "animation": [], + "lighting": [], + "rigging": [] +} diff --git a/pype/settings/defaults/project_settings/global/workfiles.json b/pype/settings/defaults/project_settings/global/workfiles.json new file mode 100644 index 0000000000..393b2e3c10 --- /dev/null +++ b/pype/settings/defaults/project_settings/global/workfiles.json @@ -0,0 +1,7 @@ +{ + "last_workfile_on_startup": [ + { + "enabled": false + } + ] +} diff --git a/pype/settings/defaults/project_settings/maya/capture.json b/pype/settings/defaults/project_settings/maya/capture.json new file mode 100644 index 0000000000..b6c4893034 --- /dev/null +++ b/pype/settings/defaults/project_settings/maya/capture.json @@ -0,0 +1,108 @@ +{ + "Codec": { + "compression": "jpg", + "format": "image", + "quality": 95 + }, + "Display Options": { + "background": [ + 0.7137254901960784, + 0.7137254901960784, + 0.7137254901960784 + ], + "backgroundBottom": [ + 0.7137254901960784, + 0.7137254901960784, + 0.7137254901960784 + ], + "backgroundTop": [ + 0.7137254901960784, + 0.7137254901960784, + 0.7137254901960784 + ], + "override_display": true + }, + "Generic": { + "isolate_view": true, + "off_screen": true + }, + "IO": { + "name": "", + "open_finished": false, + "raw_frame_numbers": false, + "recent_playblasts": [], + "save_file": false + }, + "PanZoom": { + "pan_zoom": true + }, + "Renderer": { + "rendererName": "vp2Renderer" + }, + "Resolution": { + "height": 1080, + "mode": "Custom", + "percent": 1.0, + "width": 1920 + }, + "Time Range": { + "end_frame": 25, + "frame": "", + "start_frame": 0, + "time": "Time Slider" + }, + "Viewport Options": { + "cameras": false, + "clipGhosts": false, + "controlVertices": false, + "deformers": false, + "dimensions": false, + "displayLights": 0, + "dynamicConstraints": false, + "dynamics": false, + "fluids": false, + "follicles": false, + "gpuCacheDisplayFilter": false, + "greasePencils": false, + "grid": false, + "hairSystems": false, + "handles": false, + "high_quality": true, + "hud": false, + "hulls": false, + "ikHandles": false, + "imagePlane": false, + "joints": false, + "lights": false, + "locators": false, + "manipulators": false, + "motionTrails": false, + "nCloths": false, + "nParticles": false, + "nRigids": false, + "nurbsCurves": false, + "nurbsSurfaces": false, + "override_viewport_options": true, + "particleInstancers": false, + "pivots": false, + "planes": false, + "pluginShapes": false, + "polymeshes": true, + "shadows": false, + "strokes": false, + "subdivSurfaces": false, + "textures": false, + "twoSidedLighting": true + }, + "Camera Options": { + "displayGateMask": false, + "displayResolution": false, + "displayFilmGate": false, + "displayFieldChart": false, + "displaySafeAction": false, + "displaySafeTitle": false, + "displayFilmPivot": false, + "displayFilmOrigin": false, + "overscan": 1.0 + } +} diff --git a/pype/settings/defaults/project_settings/muster/templates_mapping.json b/pype/settings/defaults/project_settings/muster/templates_mapping.json new file mode 100644 index 0000000000..4edab9077d --- /dev/null +++ b/pype/settings/defaults/project_settings/muster/templates_mapping.json @@ -0,0 +1,19 @@ +{ + "3delight": 41, + "arnold": 46, + "arnold_sf": 57, + "gelato": 30, + "harware": 3, + "krakatoa": 51, + "file_layers": 7, + "mentalray": 2, + "mentalray_sf": 6, + "redshift": 55, + "renderman": 29, + "software": 1, + "software_sf": 5, + "turtle": 10, + "vector": 4, + "vray": 37, + "ffmpeg": 48 +} diff --git a/pype/settings/defaults/project_settings/plugins/celaction/publish.json b/pype/settings/defaults/project_settings/plugins/celaction/publish.json new file mode 100644 index 0000000000..fd1af23d84 --- /dev/null +++ b/pype/settings/defaults/project_settings/plugins/celaction/publish.json @@ -0,0 +1,11 @@ +{ + "ExtractCelactionDeadline": { + "enabled": true, + "deadline_department": "", + "deadline_priority": 50, + "deadline_pool": "", + "deadline_pool_secondary": "", + "deadline_group": "", + "deadline_chunk_size": 10 + } +} \ No newline at end of file diff --git a/pype/settings/defaults/project_settings/plugins/config.json b/pype/settings/defaults/project_settings/plugins/config.json new file mode 100644 index 0000000000..9e26dfeeb6 --- /dev/null +++ b/pype/settings/defaults/project_settings/plugins/config.json @@ -0,0 +1 @@ +{} \ No newline at end of file diff --git a/pype/settings/defaults/project_settings/plugins/ftrack/publish.json b/pype/settings/defaults/project_settings/plugins/ftrack/publish.json new file mode 100644 index 0000000000..d8d93a36ee --- /dev/null +++ b/pype/settings/defaults/project_settings/plugins/ftrack/publish.json @@ -0,0 +1,7 @@ +{ + "IntegrateFtrackNote": { + "enabled": false, + "note_with_intent_template": "{intent}: {comment}", + "note_labels": [] + } +} \ No newline at end of file diff --git a/pype/settings/defaults/project_settings/plugins/global/create.json b/pype/settings/defaults/project_settings/plugins/global/create.json new file mode 100644 index 0000000000..0967ef424b --- /dev/null +++ b/pype/settings/defaults/project_settings/plugins/global/create.json @@ -0,0 +1 @@ +{} diff --git a/pype/settings/defaults/project_settings/plugins/global/filter.json b/pype/settings/defaults/project_settings/plugins/global/filter.json new file mode 100644 index 0000000000..0967ef424b --- /dev/null +++ b/pype/settings/defaults/project_settings/plugins/global/filter.json @@ -0,0 +1 @@ +{} diff --git a/pype/settings/defaults/project_settings/plugins/global/load.json b/pype/settings/defaults/project_settings/plugins/global/load.json new file mode 100644 index 0000000000..0967ef424b --- /dev/null +++ b/pype/settings/defaults/project_settings/plugins/global/load.json @@ -0,0 +1 @@ +{} diff --git a/pype/settings/defaults/project_settings/plugins/global/publish.json b/pype/settings/defaults/project_settings/plugins/global/publish.json new file mode 100644 index 0000000000..0a7f6fbf3d --- /dev/null +++ b/pype/settings/defaults/project_settings/plugins/global/publish.json @@ -0,0 +1,98 @@ +{ + "IntegrateMasterVersion": { + "enabled": false + }, + "ExtractJpegEXR": { + "enabled": true, + "ffmpeg_args": { + "input": [ + "-gamma 2.2" + ], + "output": [] + } + }, + "ExtractReview": { + "enabled": true, + "profiles": [ + { + "families": [], + "hosts": [], + "outputs": { + "h264": { + "ext": "mp4", + "tags": [ + "burnin", + "ftrackreview" + ], + "ffmpeg_args": { + "video_filters": [], + "audio_filters": [], + "input": [ + "-gamma 2.2" + ], + "output": [ + "-pix_fmt yuv420p", + "-crf 18", + "-intra" + ] + }, + "filter": { + "families": [ + "render", + "review", + "ftrack" + ] + } + } + } + } + ] + }, + "ExtractBurnin": { + "enabled": false, + "options": { + "font_size": 42, + "opacity": 1, + "bg_opacity": 0, + "x_offset": 5, + "y_offset": 5, + "bg_padding": 5 + }, + "fields": {}, + "profiles": [ + { + "burnins": { + "burnin": { + "TOP_LEFT": "{yy}-{mm}-{dd}", + "TOP_RIGHT": "{anatomy[version]}", + "TOP_CENTERED": "", + "BOTTOM_RIGHT": "{frame_start}-{current_frame}-{frame_end}", + "BOTTOM_CENTERED": "{asset}", + "BOTTOM_LEFT": "{username}" + } + } + } + ] + }, + "IntegrateAssetNew": { + "template_name_profiles": { + "publish": { + "families": [], + "tasks": [] + }, + "render": { + "families": [ + "review", + "render", + "prerender" + ] + } + } + }, + "ProcessSubmittedJobOnFarm": { + "enabled": false, + "deadline_department": "", + "deadline_pool": "", + "deadline_group": "" + } +} \ No newline at end of file diff --git a/pype/settings/defaults/project_settings/plugins/maya/create.json b/pype/settings/defaults/project_settings/plugins/maya/create.json new file mode 100644 index 0000000000..0967ef424b --- /dev/null +++ b/pype/settings/defaults/project_settings/plugins/maya/create.json @@ -0,0 +1 @@ +{} diff --git a/pype/settings/defaults/project_settings/plugins/maya/filter.json b/pype/settings/defaults/project_settings/plugins/maya/filter.json new file mode 100644 index 0000000000..83d6f05f31 --- /dev/null +++ b/pype/settings/defaults/project_settings/plugins/maya/filter.json @@ -0,0 +1,9 @@ +{ + "Preset n1": { + "ValidateNoAnimation": false, + "ValidateShapeDefaultNames": false + }, + "Preset n2": { + "ValidateNoAnimation": false + } +} diff --git a/pype/settings/defaults/project_settings/plugins/maya/load.json b/pype/settings/defaults/project_settings/plugins/maya/load.json new file mode 100644 index 0000000000..260fbb35ee --- /dev/null +++ b/pype/settings/defaults/project_settings/plugins/maya/load.json @@ -0,0 +1,18 @@ +{ + "colors": { + "model": [0.821, 0.518, 0.117], + "rig": [0.144, 0.443, 0.463], + "pointcache": [0.368, 0.821, 0.117], + "animation": [0.368, 0.821, 0.117], + "ass": [1.0, 0.332, 0.312], + "camera": [0.447, 0.312, 1.0], + "fbx": [1.0, 0.931, 0.312], + "mayaAscii": [0.312, 1.0, 0.747], + "setdress": [0.312, 1.0, 0.747], + "layout": [0.312, 1.0, 0.747], + "vdbcache": [0.312, 1.0, 0.428], + "vrayproxy": [0.258, 0.95, 0.541], + "yeticache": [0.2, 0.8, 0.3], + "yetiRig": [0, 0.8, 0.5] + } +} diff --git a/pype/settings/defaults/project_settings/plugins/maya/publish.json b/pype/settings/defaults/project_settings/plugins/maya/publish.json new file mode 100644 index 0000000000..2b3637ff80 --- /dev/null +++ b/pype/settings/defaults/project_settings/plugins/maya/publish.json @@ -0,0 +1,17 @@ +{ + "ValidateModelName": { + "enabled": false, + "material_file": "/path/to/shader_name_definition.txt", + "regex": "(.*)_(\\d)*_(?P.*)_(GEO)" + }, + "ValidateAssemblyName": { + "enabled": false + }, + "ValidateShaderName": { + "enabled": false, + "regex": "(?P.*)_(.*)_SHD" + }, + "ValidateMeshHasOverlappingUVs": { + "enabled": false + } +} \ No newline at end of file diff --git a/pype/settings/defaults/project_settings/plugins/maya/workfile_build.json b/pype/settings/defaults/project_settings/plugins/maya/workfile_build.json new file mode 100644 index 0000000000..443bc2cb2c --- /dev/null +++ b/pype/settings/defaults/project_settings/plugins/maya/workfile_build.json @@ -0,0 +1,136 @@ +[ + { + "tasks": [ + "lighting" + ], + "current_context": [ + { + "subset_name_filters": [ + ".+[Mm]ain" + ], + "families": [ + "model" + ], + "repre_names": [ + "abc", + "ma" + ], + "loaders": [ + "ReferenceLoader" + ] + }, + { + "families": [ + "animation", + "pointcache" + ], + "repre_names": [ + "abc" + ], + "loaders": [ + "ReferenceLoader" + ] + }, + { + "families": [ + "rendersetup" + ], + "repre_names": [ + "json" + ], + "loaders": [ + "RenderSetupLoader" + ] + }, + { + "families": [ + "camera" + ], + "repre_names": [ + "abc" + ], + "loaders": [ + "ReferenceLoader" + ] + } + ], + "linked_assets": [ + { + "families": [ + "setdress" + ], + "repre_names": [ + "ma" + ], + "loaders": [ + "ReferenceLoader" + ] + }, + { + "families": [ + "ass" + ], + "repre_names": [ + "ass" + ], + "loaders": [ + "assLoader" + ] + } + ] + }, + { + "tasks": [ + "animation" + ], + "current_context": [ + { + "families": [ + "camera" + ], + "repre_names": [ + "abc", + "ma" + ], + "loaders": [ + "ReferenceLoader" + ] + }, + { + "families": [ + "audio" + ], + "repre_names": [ + "wav" + ], + "loaders": [ + "RenderSetupLoader" + ] + } + ], + "linked_assets": [ + { + "families": [ + "setdress" + ], + "repre_names": [ + "proxy" + ], + "loaders": [ + "ReferenceLoader" + ] + }, + { + "families": [ + "rig" + ], + "repre_names": [ + "ass" + ], + "loaders": [ + "rigLoader" + ] + } + ] + } +] \ No newline at end of file diff --git a/pype/settings/defaults/project_settings/plugins/nuke/create.json b/pype/settings/defaults/project_settings/plugins/nuke/create.json new file mode 100644 index 0000000000..79ab665696 --- /dev/null +++ b/pype/settings/defaults/project_settings/plugins/nuke/create.json @@ -0,0 +1,8 @@ +{ + "CreateWriteRender": { + "fpath_template": "{work}/renders/nuke/{subset}/{subset}.{frame}.{ext}" + }, + "CreateWritePrerender": { + "fpath_template": "{work}/prerenders/nuke/{subset}/{subset}.{frame}.{ext}" + } +} \ No newline at end of file diff --git a/pype/settings/defaults/project_settings/plugins/nuke/load.json b/pype/settings/defaults/project_settings/plugins/nuke/load.json new file mode 100644 index 0000000000..0967ef424b --- /dev/null +++ b/pype/settings/defaults/project_settings/plugins/nuke/load.json @@ -0,0 +1 @@ +{} diff --git a/pype/settings/defaults/project_settings/plugins/nuke/publish.json b/pype/settings/defaults/project_settings/plugins/nuke/publish.json new file mode 100644 index 0000000000..08a099a0a0 --- /dev/null +++ b/pype/settings/defaults/project_settings/plugins/nuke/publish.json @@ -0,0 +1,53 @@ +{ + "ExtractThumbnail": { + "enabled": true, + "nodes": { + "Reformat": [ + [ + "type", + "to format" + ], + [ + "format", + "HD_1080" + ], + [ + "filter", + "Lanczos6" + ], + [ + "black_outside", + true + ], + [ + "pbb", + false + ] + ] + } + }, + "ValidateNukeWriteKnobs": { + "enabled": false, + "knobs": { + "render": { + "review": true + } + } + }, + "ExtractReviewDataLut": { + "enabled": false + }, + "ExtractReviewDataMov": { + "enabled": true, + "viewer_lut_raw": false + }, + "ExtractSlateFrame": { + "viewer_lut_raw": false + }, + "NukeSubmitDeadline": { + "deadline_priority": 50, + "deadline_pool": "", + "deadline_pool_secondary": "", + "deadline_chunk_size": 1 + } +} \ No newline at end of file diff --git a/pype/settings/defaults/project_settings/plugins/nuke/workfile_build.json b/pype/settings/defaults/project_settings/plugins/nuke/workfile_build.json new file mode 100644 index 0000000000..4b48b46184 --- /dev/null +++ b/pype/settings/defaults/project_settings/plugins/nuke/workfile_build.json @@ -0,0 +1,23 @@ +[ + { + "tasks": [ + "compositing" + ], + "current_context": [ + { + "families": [ + "render", + "plate" + ], + "repre_names": [ + "exr", + "dpx" + ], + "loaders": [ + "LoadSequence" + ] + } + ], + "linked_assets": [] + } +] \ No newline at end of file diff --git a/pype/settings/defaults/project_settings/plugins/nukestudio/filter.json b/pype/settings/defaults/project_settings/plugins/nukestudio/filter.json new file mode 100644 index 0000000000..bd6a0dc1bd --- /dev/null +++ b/pype/settings/defaults/project_settings/plugins/nukestudio/filter.json @@ -0,0 +1,10 @@ +{ + "strict": { + "ValidateVersion": true, + "VersionUpWorkfile": true + }, + "benevolent": { + "ValidateVersion": false, + "VersionUpWorkfile": false + } +} \ No newline at end of file diff --git a/pype/settings/defaults/project_settings/plugins/nukestudio/publish.json b/pype/settings/defaults/project_settings/plugins/nukestudio/publish.json new file mode 100644 index 0000000000..d99a878c35 --- /dev/null +++ b/pype/settings/defaults/project_settings/plugins/nukestudio/publish.json @@ -0,0 +1,9 @@ +{ + "CollectInstanceVersion": { + "enabled": false + }, + "ExtractReviewCutUpVideo": { + "enabled": true, + "tags_addition": [] + } +} \ No newline at end of file diff --git a/pype/settings/defaults/project_settings/plugins/resolve/create.json b/pype/settings/defaults/project_settings/plugins/resolve/create.json new file mode 100644 index 0000000000..8ff5b15714 --- /dev/null +++ b/pype/settings/defaults/project_settings/plugins/resolve/create.json @@ -0,0 +1,7 @@ +{ + "CreateShotClip": { + "clipName": "{track}{sequence}{shot}", + "folder": "takes", + "steps": 20 + } +} \ No newline at end of file diff --git a/pype/settings/defaults/project_settings/plugins/standalonepublisher/publish.json b/pype/settings/defaults/project_settings/plugins/standalonepublisher/publish.json new file mode 100644 index 0000000000..2f1a3e7aca --- /dev/null +++ b/pype/settings/defaults/project_settings/plugins/standalonepublisher/publish.json @@ -0,0 +1,27 @@ +{ + "ExtractThumbnailSP": { + "ffmpeg_args": { + "input": [ + "-gamma 2.2" + ], + "output": [] + } + }, + "ExtractReviewSP": { + "outputs": { + "h264": { + "input": [ + "-gamma 2.2" + ], + "output": [ + "-pix_fmt yuv420p", + "-crf 18" + ], + "tags": [ + "preview" + ], + "ext": "mov" + } + } + } +} \ No newline at end of file diff --git a/pype/settings/defaults/project_settings/plugins/test/create.json b/pype/settings/defaults/project_settings/plugins/test/create.json new file mode 100644 index 0000000000..fa0b2fc05f --- /dev/null +++ b/pype/settings/defaults/project_settings/plugins/test/create.json @@ -0,0 +1,8 @@ +{ + "MyTestCreator": { + "my_test_property": "B", + "active": false, + "new_property": "new", + "family": "new_family" + } +} diff --git a/pype/settings/defaults/project_settings/plugins/test/publish.json b/pype/settings/defaults/project_settings/plugins/test/publish.json new file mode 100644 index 0000000000..3180dd5d8a --- /dev/null +++ b/pype/settings/defaults/project_settings/plugins/test/publish.json @@ -0,0 +1,10 @@ +{ + "MyTestPlugin": { + "label": "loaded from preset", + "optional": true, + "families": ["changed", "by", "preset"] + }, + "MyTestRemovedPlugin": { + "enabled": false + } +} diff --git a/pype/settings/defaults/project_settings/premiere/asset_default.json b/pype/settings/defaults/project_settings/premiere/asset_default.json new file mode 100644 index 0000000000..84d2bde3d8 --- /dev/null +++ b/pype/settings/defaults/project_settings/premiere/asset_default.json @@ -0,0 +1,5 @@ +{ + "frameStart": 1001, + "handleStart": 0, + "handleEnd": 0 +} diff --git a/pype/settings/defaults/project_settings/premiere/rules_tasks.json b/pype/settings/defaults/project_settings/premiere/rules_tasks.json new file mode 100644 index 0000000000..333c9cd70b --- /dev/null +++ b/pype/settings/defaults/project_settings/premiere/rules_tasks.json @@ -0,0 +1,21 @@ +{ + "defaultTasks": ["Layout", "Animation"], + "taskToSubsets": { + "Layout": ["reference", "audio"], + "Animation": ["audio"] + }, + "subsetToRepresentations": { + "reference": { + "preset": "h264", + "representation": "mp4" + }, + "thumbnail": { + "preset": "jpeg_thumb", + "representation": "jpg" + }, + "audio": { + "preset": "48khz", + "representation": "wav" + } + } +} diff --git a/pype/settings/defaults/project_settings/standalonepublisher/families.json b/pype/settings/defaults/project_settings/standalonepublisher/families.json new file mode 100644 index 0000000000..d05941cc26 --- /dev/null +++ b/pype/settings/defaults/project_settings/standalonepublisher/families.json @@ -0,0 +1,90 @@ +{ + "create_look": { + "name": "look", + "label": "Look", + "family": "look", + "icon": "paint-brush", + "defaults": ["Main"], + "help": "Shader connections defining shape look" + }, + "create_model": { + "name": "model", + "label": "Model", + "family": "model", + "icon": "cube", + "defaults": ["Main", "Proxy", "Sculpt"], + "help": "Polygonal static geometry" + }, + "create_workfile": { + "name": "workfile", + "label": "Workfile", + "family": "workfile", + "icon": "cube", + "defaults": ["Main"], + "help": "Working scene backup" + }, + "create_camera": { + "name": "camera", + "label": "Camera", + "family": "camera", + "icon": "video-camera", + "defaults": ["Main"], + "help": "Single baked camera" + }, + "create_pointcache": { + "name": "pointcache", + "label": "Pointcache", + "family": "pointcache", + "icon": "gears", + "defaults": ["Main"], + "help": "Alembic pointcache for animated data" + }, + "create_rig": { + "name": "rig", + "label": "Rig", + "family": "rig", + "icon": "wheelchair", + "defaults": ["Main"], + "help": "Artist-friendly rig with controls" + }, + "create_layout": { + "name": "layout", + "label": "Layout", + "family": "layout", + "icon": "cubes", + "defaults": ["Main"], + "help": "Simple scene for animators with camera" + }, + "create_plate": { + "name": "plate", + "label": "Plate", + "family": "plate", + "icon": "camera", + "defaults": ["Main", "BG", "Reference"], + "help": "Plates for compositors" + }, + "create_matchmove": { + "name": "matchmove", + "label": "Matchmove script", + "family": "matchmove", + "icon": "empire", + "defaults": ["Camera", "Object", "Mocap"], + "help": "Script exported from matchmoving application" + }, + "create_images": { + "name": "image", + "label": "Image file", + "family": "image", + "icon": "image", + "defaults": ["ConceptArt", "Reference", "Texture", "MattePaint"], + "help": "Holder for all kinds of image data" + }, + "create_editorial": { + "name": "editorial", + "label": "Editorial", + "family": "editorial", + "icon": "image", + "defaults": ["Main"], + "help": "Editorial files to generate shots." + } +} diff --git a/pype/settings/defaults/project_settings/tools/slates/example_HD.json b/pype/settings/defaults/project_settings/tools/slates/example_HD.json new file mode 100644 index 0000000000..b06391fb63 --- /dev/null +++ b/pype/settings/defaults/project_settings/tools/slates/example_HD.json @@ -0,0 +1,212 @@ +{ + "width": 1920, + "height": 1080, + "destination_path": "{destination_path}", + "style": { + "*": { + "font-family": "arial", + "font-color": "#ffffff", + "font-bold": false, + "font-italic": false, + "bg-color": "#0077ff", + "alignment-horizontal": "left", + "alignment-vertical": "top" + }, + "layer": { + "padding": 0, + "margin": 0 + }, + "rectangle": { + "padding": 0, + "margin": 0, + "bg-color": "#E9324B", + "fill": true + }, + "main_frame": { + "padding": 0, + "margin": 0, + "bg-color": "#252525" + }, + "table": { + "padding": 0, + "margin": 0, + "bg-color": "transparent" + }, + "table-item": { + "padding": 5, + "padding-bottom": 10, + "margin": 0, + "bg-color": "#212121", + "bg-alter-color": "#272727", + "font-color": "#dcdcdc", + "font-bold": false, + "font-italic": false, + "alignment-horizontal": "left", + "alignment-vertical": "top", + "word-wrap": false, + "ellide": true, + "max-lines": 1 + }, + "table-item-col[0]": { + "font-size": 20, + "font-color": "#898989", + "font-bold": true, + "ellide": false, + "word-wrap": true, + "max-lines": null + }, + "table-item-col[1]": { + "font-size": 40, + "padding-left": 10 + }, + "#colorbar": { + "bg-color": "#9932CC" + } + }, + "items": [{ + "type": "layer", + "direction": 1, + "name": "MainLayer", + "style": { + "#MainLayer": { + "width": 1094, + "height": 1000, + "margin": 25, + "padding": 0 + }, + "#LeftSide": { + "margin-right": 25 + } + }, + "items": [{ + "type": "layer", + "name": "LeftSide", + "items": [{ + "type": "layer", + "direction": 1, + "style": { + "table-item": { + "bg-color": "transparent", + "padding-bottom": 20 + }, + "table-item-col[0]": { + "font-size": 20, + "font-color": "#898989", + "alignment-horizontal": "right" + }, + "table-item-col[1]": { + "alignment-horizontal": "left", + "font-bold": true, + "font-size": 40 + } + }, + "items": [{ + "type": "table", + "values": [ + ["Show:", "{project[name]}"] + ], + "style": { + "table-item-field[0:0]": { + "width": 150 + }, + "table-item-field[0:1]": { + "width": 580 + } + } + }, { + "type": "table", + "values": [ + ["Submitting For:", "{intent}"] + ], + "style": { + "table-item-field[0:0]": { + "width": 160 + }, + "table-item-field[0:1]": { + "width": 218, + "alignment-horizontal": "right" + } + } + }] + }, { + "type": "rectangle", + "style": { + "bg-color": "#bc1015", + "width": 1108, + "height": 5, + "fill": true + } + }, { + "type": "table", + "use_alternate_color": true, + "values": [ + ["Version name:", "{version_name}"], + ["Date:", "{date}"], + ["Shot Types:", "{shot_type}"], + ["Submission Note:", "{submission_note}"] + ], + "style": { + "table-item": { + "padding-bottom": 20 + }, + "table-item-field[0:1]": { + "font-bold": true + }, + "table-item-field[3:0]": { + "word-wrap": true, + "ellide": true, + "max-lines": 4 + }, + "table-item-col[0]": { + "alignment-horizontal": "right", + "width": 150 + }, + "table-item-col[1]": { + "alignment-horizontal": "left", + "width": 958 + } + } + }] + }, { + "type": "layer", + "name": "RightSide", + "items": [{ + "type": "placeholder", + "name": "thumbnail", + "path": "{thumbnail_path}", + "style": { + "width": 730, + "height": 412 + } + }, { + "type": "placeholder", + "name": "colorbar", + "path": "{color_bar_path}", + "return_data": true, + "style": { + "width": 730, + "height": 55 + } + }, { + "type": "table", + "use_alternate_color": true, + "values": [ + ["Vendor:", "{vendor}"], + ["Shot Name:", "{shot_name}"], + ["Frames:", "{frame_start} - {frame_end} ({duration})"] + ], + "style": { + "table-item-col[0]": { + "alignment-horizontal": "left", + "width": 200 + }, + "table-item-col[1]": { + "alignment-horizontal": "right", + "width": 530, + "font-size": 30 + } + } + }] + }] + }] +} diff --git a/pype/settings/defaults/project_settings/unreal/project_setup.json b/pype/settings/defaults/project_settings/unreal/project_setup.json new file mode 100644 index 0000000000..8a4dffc526 --- /dev/null +++ b/pype/settings/defaults/project_settings/unreal/project_setup.json @@ -0,0 +1,4 @@ +{ + "dev_mode": false, + "install_unreal_python_engine": false +} diff --git a/pype/settings/defaults/system_settings/environments/avalon.json b/pype/settings/defaults/system_settings/environments/avalon.json new file mode 100644 index 0000000000..832ba07e71 --- /dev/null +++ b/pype/settings/defaults/system_settings/environments/avalon.json @@ -0,0 +1,16 @@ +{ + "AVALON_CONFIG": "pype", + "AVALON_PROJECTS": "{PYPE_PROJECTS_PATH}", + "AVALON_USERNAME": "avalon", + "AVALON_PASSWORD": "secret", + "AVALON_DEBUG": "1", + "AVALON_MONGO": "mongodb://localhost:2707", + "AVALON_DB": "avalon", + "AVALON_DB_DATA": "{PYPE_SETUP_PATH}/../mongo_db_data", + "AVALON_EARLY_ADOPTER": "1", + "AVALON_SCHEMA": "{PYPE_MODULE_ROOT}/schema", + "AVALON_LOCATION": "http://127.0.0.1", + "AVALON_LABEL": "Pype", + "AVALON_TIMEOUT": "1000", + "AVALON_THUMBNAIL_ROOT": "" +} diff --git a/pype/settings/defaults/system_settings/environments/blender.json b/pype/settings/defaults/system_settings/environments/blender.json new file mode 100644 index 0000000000..6f4f6a012d --- /dev/null +++ b/pype/settings/defaults/system_settings/environments/blender.json @@ -0,0 +1,7 @@ +{ + "BLENDER_USER_SCRIPTS": "{PYPE_SETUP_PATH}/repos/avalon-core/setup/blender", + "PYTHONPATH": [ + "{PYPE_SETUP_PATH}/repos/avalon-core/setup/blender", + "{PYTHONPATH}" + ] +} diff --git a/pype/settings/defaults/system_settings/environments/celaction.json b/pype/settings/defaults/system_settings/environments/celaction.json new file mode 100644 index 0000000000..cdd4e609ab --- /dev/null +++ b/pype/settings/defaults/system_settings/environments/celaction.json @@ -0,0 +1,3 @@ +{ + "CELACTION_TEMPLATE": "{PYPE_MODULE_ROOT}/pype/hosts/celaction/celaction_template_scene.scn" +} diff --git a/pype/settings/defaults/system_settings/environments/deadline.json b/pype/settings/defaults/system_settings/environments/deadline.json new file mode 100644 index 0000000000..e8ef52805b --- /dev/null +++ b/pype/settings/defaults/system_settings/environments/deadline.json @@ -0,0 +1,3 @@ +{ + "DEADLINE_REST_URL": "http://localhost:8082" +} diff --git a/pype/settings/defaults/system_settings/environments/ftrack.json b/pype/settings/defaults/system_settings/environments/ftrack.json new file mode 100644 index 0000000000..4f25de027b --- /dev/null +++ b/pype/settings/defaults/system_settings/environments/ftrack.json @@ -0,0 +1,18 @@ +{ + "FTRACK_SERVER": "https://pype.ftrackapp.com", + "FTRACK_ACTIONS_PATH": [ + "{PYPE_MODULE_ROOT}/pype/modules/ftrack/actions" + ], + "FTRACK_EVENTS_PATH": [ + "{PYPE_MODULE_ROOT}/pype/modules/ftrack/events" + ], + "PYTHONPATH": [ + "{PYPE_MODULE_ROOT}/pype/vendor", + "{PYTHONPATH}" + ], + "PYBLISHPLUGINPATH": [ + "{PYPE_MODULE_ROOT}/pype/plugins/ftrack/publish" + ], + "FTRACK_EVENTS_MONGO_DB": "pype", + "FTRACK_EVENTS_MONGO_COL": "ftrack_events" +} diff --git a/pype/settings/defaults/system_settings/environments/global.json b/pype/settings/defaults/system_settings/environments/global.json new file mode 100644 index 0000000000..ef528e6857 --- /dev/null +++ b/pype/settings/defaults/system_settings/environments/global.json @@ -0,0 +1,44 @@ +{ + "PYPE_STUDIO_NAME": "Studio Name", + "PYPE_STUDIO_CODE": "stu", + "PYPE_APP_ROOT": "{PYPE_SETUP_PATH}/pypeapp", + "PYPE_MODULE_ROOT": "{PYPE_SETUP_PATH}/repos/pype", + "PYPE_PROJECT_PLUGINS": "", + "STUDIO_SOFT": "{PYP_SETUP_ROOT}/soft", + "FFMPEG_PATH": { + "windows": "{VIRTUAL_ENV}/localized/ffmpeg_exec/windows/bin;{PYPE_SETUP_PATH}/vendor/ffmpeg_exec/windows/bin", + "darwin": "{VIRTUAL_ENV}/localized/ffmpeg_exec/darwin/bin:{PYPE_SETUP_PATH}/vendor/ffmpeg_exec/darwin/bin", + "linux": "{VIRTUAL_ENV}/localized/ffmpeg_exec/linux:{PYPE_SETUP_PATH}/vendor/ffmpeg_exec/linux" + }, + "DJV_PATH": { + "windows": [ + "C:/Program Files/djv-1.1.0-Windows-64/bin/djv_view.exe", + "C:/Program Files/DJV/bin/djv_view.exe", + "{STUDIO_SOFT}/djv/windows/bin/djv_view.exe" + ], + "linux": [ + "usr/local/djv/djv_view", + "{STUDIO_SOFT}/djv/linux/bin/djv_view" + ], + "darwin": "Application/DJV.app/Contents/MacOS/DJV" + }, + "PATH": [ + "{PYPE_CONFIG}/launchers", + "{PYPE_APP_ROOT}", + "{FFMPEG_PATH}", + "{PATH}" + ], + "PYPE_OCIO_CONFIG": "{STUDIO_SOFT}/OpenColorIO-Configs", + "PYTHONPATH": { + "windows": "{VIRTUAL_ENV}/Lib/site-packages;{PYPE_MODULE_ROOT}/pype/tools;{PYTHONPATH}", + "linux": "{VIRTUAL_ENV}/lib/python{PYTHON_VERSION}/site-packages:{PYPE_MODULE_ROOT}/pype/tools:{PYTHONPATH}", + "darwin": "{VIRTUAL_ENV}/lib/python{PYTHON_VERSION}/site-packages:{PYPE_MODULE_ROOT}/pype/tools:{PYTHONPATH}" + }, + "PYPE_PROJECT_CONFIGS": "{PYPE_SETUP_PATH}/../studio-project-configs", + "PYPE_PYTHON_EXE": { + "windows": "{VIRTUAL_ENV}/Scripts/python.exe", + "linux": "{VIRTUAL_ENV}/Scripts/python", + "darwin": "{VIRTUAL_ENV}/bin/python" + }, + "PYBLISH_GUI": "pyblish_pype" +} diff --git a/pype/settings/defaults/system_settings/environments/harmony.json b/pype/settings/defaults/system_settings/environments/harmony.json new file mode 100644 index 0000000000..d394343935 --- /dev/null +++ b/pype/settings/defaults/system_settings/environments/harmony.json @@ -0,0 +1,4 @@ +{ + "AVALON_HARMONY_WORKFILES_ON_LAUNCH": "1", + "PYBLISH_GUI_ALWAYS_EXEC": "1" +} diff --git a/pype/settings/defaults/system_settings/environments/houdini.json b/pype/settings/defaults/system_settings/environments/houdini.json new file mode 100644 index 0000000000..95c7d19088 --- /dev/null +++ b/pype/settings/defaults/system_settings/environments/houdini.json @@ -0,0 +1,12 @@ +{ + "HOUDINI_PATH": { + "darwin": "{PYPE_MODULE_ROOT}/setup/houdini:&", + "linux": "{PYPE_MODULE_ROOT}/setup/houdini:&", + "windows": "{PYPE_MODULE_ROOT}/setup/houdini;&" + }, + "HOUDINI_MENU_PATH": { + "darwin": "{PYPE_MODULE_ROOT}/setup/houdini:&", + "linux": "{PYPE_MODULE_ROOT}/setup/houdini:&", + "windows": "{PYPE_MODULE_ROOT}/setup/houdini;&" + } +} diff --git a/pype/settings/defaults/system_settings/environments/maya.json b/pype/settings/defaults/system_settings/environments/maya.json new file mode 100644 index 0000000000..7785b108f7 --- /dev/null +++ b/pype/settings/defaults/system_settings/environments/maya.json @@ -0,0 +1,14 @@ +{ + "PYTHONPATH": [ + "{PYPE_SETUP_PATH}/repos/avalon-core/setup/maya", + "{PYPE_SETUP_PATH}/repos/maya-look-assigner", + "{PYTHON_ENV}/python2/Lib/site-packages", + "{PYTHONPATH}" + ], + "MAYA_DISABLE_CLIC_IPM": "Yes", + "MAYA_DISABLE_CIP": "Yes", + "MAYA_DISABLE_CER": "Yes", + "PYMEL_SKIP_MEL_INIT": "Yes", + "LC_ALL": "C", + "PYPE_LOG_NO_COLORS": "Yes" +} diff --git a/pype/settings/defaults/system_settings/environments/maya_2018.json b/pype/settings/defaults/system_settings/environments/maya_2018.json new file mode 100644 index 0000000000..72a0c57ce3 --- /dev/null +++ b/pype/settings/defaults/system_settings/environments/maya_2018.json @@ -0,0 +1,11 @@ +{ + "MAYA_VERSION": "2018", + "MAYA_LOCATION": { + "darwin": "/Applications/Autodesk/maya{MAYA_VERSION}/Maya.app/Contents", + "linux": "/usr/autodesk/maya{MAYA_VERSION}", + "windows": "C:/Program Files/Autodesk/Maya{MAYA_VERSION}" + }, + "DYLD_LIBRARY_PATH": { + "darwin": "{MAYA_LOCATION}/MacOS" + } +} diff --git a/pype/settings/defaults/system_settings/environments/maya_2020.json b/pype/settings/defaults/system_settings/environments/maya_2020.json new file mode 100644 index 0000000000..efd0250bc8 --- /dev/null +++ b/pype/settings/defaults/system_settings/environments/maya_2020.json @@ -0,0 +1,11 @@ +{ + "MAYA_VERSION": "2020", + "MAYA_LOCATION": { + "darwin": "/Applications/Autodesk/maya{MAYA_VERSION}/Maya.app/Contents", + "linux": "/usr/autodesk/maya{MAYA_VERSION}", + "windows": "C:/Program Files/Autodesk/Maya{MAYA_VERSION}" + }, + "DYLD_LIBRARY_PATH": { + "darwin": "{MAYA_LOCATION}/MacOS" + } +} diff --git a/pype/settings/defaults/system_settings/environments/mayabatch.json b/pype/settings/defaults/system_settings/environments/mayabatch.json new file mode 100644 index 0000000000..7785b108f7 --- /dev/null +++ b/pype/settings/defaults/system_settings/environments/mayabatch.json @@ -0,0 +1,14 @@ +{ + "PYTHONPATH": [ + "{PYPE_SETUP_PATH}/repos/avalon-core/setup/maya", + "{PYPE_SETUP_PATH}/repos/maya-look-assigner", + "{PYTHON_ENV}/python2/Lib/site-packages", + "{PYTHONPATH}" + ], + "MAYA_DISABLE_CLIC_IPM": "Yes", + "MAYA_DISABLE_CIP": "Yes", + "MAYA_DISABLE_CER": "Yes", + "PYMEL_SKIP_MEL_INIT": "Yes", + "LC_ALL": "C", + "PYPE_LOG_NO_COLORS": "Yes" +} diff --git a/pype/settings/defaults/system_settings/environments/mayabatch_2019.json b/pype/settings/defaults/system_settings/environments/mayabatch_2019.json new file mode 100644 index 0000000000..aa7360a943 --- /dev/null +++ b/pype/settings/defaults/system_settings/environments/mayabatch_2019.json @@ -0,0 +1,11 @@ +{ + "MAYA_VERSION": "2019", + "MAYA_LOCATION": { + "darwin": "/Applications/Autodesk/maya{MAYA_VERSION}/Maya.app/Contents", + "linux": "/usr/autodesk/maya{MAYA_VERSION}", + "windows": "C:/Program Files/Autodesk/Maya{MAYA_VERSION}" + }, + "DYLD_LIBRARY_PATH": { + "darwin": "{MAYA_LOCATION}/MacOS" + } +} diff --git a/pype/settings/defaults/system_settings/environments/mtoa_3.1.1.json b/pype/settings/defaults/system_settings/environments/mtoa_3.1.1.json new file mode 100644 index 0000000000..f7b9f94d4e --- /dev/null +++ b/pype/settings/defaults/system_settings/environments/mtoa_3.1.1.json @@ -0,0 +1,23 @@ +{ + "MTOA": "{PYPE_STUDIO_SOFTWARE}/arnold/mtoa_{MAYA_VERSION}_{MTOA_VERSION}", + "MTOA_VERSION": "3.1.1", + "MAYA_RENDER_DESC_PATH": "{MTOA}", + "MAYA_MODULE_PATH": "{MTOA}", + "ARNOLD_PLUGIN_PATH": "{MTOA}/shaders", + "MTOA_EXTENSIONS_PATH": { + "darwin": "{MTOA}/extensions", + "linux": "{MTOA}/extensions", + "windows": "{MTOA}/extensions" + }, + "MTOA_EXTENSIONS": { + "darwin": "{MTOA}/extensions", + "linux": "{MTOA}/extensions", + "windows": "{MTOA}/extensions" + }, + "DYLD_LIBRARY_PATH": { + "darwin": "{MTOA}/bin" + }, + "PATH": { + "windows": "{PATH};{MTOA}/bin" + } +} diff --git a/pype/settings/defaults/system_settings/environments/muster.json b/pype/settings/defaults/system_settings/environments/muster.json new file mode 100644 index 0000000000..26f311146a --- /dev/null +++ b/pype/settings/defaults/system_settings/environments/muster.json @@ -0,0 +1,3 @@ +{ + "MUSTER_REST_URL": "http://127.0.0.1:9890" +} diff --git a/pype/settings/defaults/system_settings/environments/nuke.json b/pype/settings/defaults/system_settings/environments/nuke.json new file mode 100644 index 0000000000..50dd31ac91 --- /dev/null +++ b/pype/settings/defaults/system_settings/environments/nuke.json @@ -0,0 +1,15 @@ +{ + "NUKE_PATH": [ + "{PYPE_SETUP_PATH}/repos/avalon-core/setup/nuke/nuke_path", + "{PYPE_MODULE_ROOT}/setup/nuke/nuke_path", + "{PYPE_STUDIO_PLUGINS}/nuke" + ], + "PATH": { + "windows": "C:/Program Files (x86)/QuickTime/QTSystem/;{PATH}" + }, + "PYPE_LOG_NO_COLORS": "True", + "PYTHONPATH": { + "windows": "{VIRTUAL_ENV}/Lib/site-packages;{PYTHONPATH}", + "linux": "{VIRTUAL_ENV}/lib/python3.6/site-packages:{PYTHONPATH}" + } +} diff --git a/pype/settings/defaults/system_settings/environments/nukestudio.json b/pype/settings/defaults/system_settings/environments/nukestudio.json new file mode 100644 index 0000000000..b05e2411f0 --- /dev/null +++ b/pype/settings/defaults/system_settings/environments/nukestudio.json @@ -0,0 +1,11 @@ +{ + "HIERO_PLUGIN_PATH": [ + "{PYPE_MODULE_ROOT}/setup/nukestudio/hiero_plugin_path" + ], + "PATH": { + "windows": "C:/Program Files (x86)/QuickTime/QTSystem/;{PATH}" + }, + "WORKFILES_STARTUP": "0", + "TAG_ASSETBUILD_STARTUP": "0", + "PYPE_LOG_NO_COLORS": "True" +} diff --git a/pype/settings/defaults/system_settings/environments/nukestudio_10.0.json b/pype/settings/defaults/system_settings/environments/nukestudio_10.0.json new file mode 100644 index 0000000000..9bdcef53c9 --- /dev/null +++ b/pype/settings/defaults/system_settings/environments/nukestudio_10.0.json @@ -0,0 +1,4 @@ +{ + "PYPE_LOG_NO_COLORS": "Yes", + "QT_PREFERRED_BINDING": "PySide" +} \ No newline at end of file diff --git a/pype/settings/defaults/system_settings/environments/nukex.json b/pype/settings/defaults/system_settings/environments/nukex.json new file mode 100644 index 0000000000..2b77f44076 --- /dev/null +++ b/pype/settings/defaults/system_settings/environments/nukex.json @@ -0,0 +1,10 @@ +{ + "NUKE_PATH": [ + "{PYPE_SETUP_PATH}/repos/avalon-core/setup/nuke/nuke_path", + "{PYPE_MODULE_ROOT}/setup/nuke/nuke_path", + "{PYPE_STUDIO_PLUGINS}/nuke" + ], + "PATH": { + "windows": "C:/Program Files (x86)/QuickTime/QTSystem/;{PATH}" + } +} diff --git a/pype/settings/defaults/system_settings/environments/nukex_10.0.json b/pype/settings/defaults/system_settings/environments/nukex_10.0.json new file mode 100644 index 0000000000..9bdcef53c9 --- /dev/null +++ b/pype/settings/defaults/system_settings/environments/nukex_10.0.json @@ -0,0 +1,4 @@ +{ + "PYPE_LOG_NO_COLORS": "Yes", + "QT_PREFERRED_BINDING": "PySide" +} \ No newline at end of file diff --git a/pype/settings/defaults/system_settings/environments/photoshop.json b/pype/settings/defaults/system_settings/environments/photoshop.json new file mode 100644 index 0000000000..2208a88665 --- /dev/null +++ b/pype/settings/defaults/system_settings/environments/photoshop.json @@ -0,0 +1,4 @@ +{ + "AVALON_PHOTOSHOP_WORKFILES_ON_LAUNCH": "1", + "PYTHONPATH": "{PYTHONPATH}" +} diff --git a/pype/settings/defaults/system_settings/environments/premiere.json b/pype/settings/defaults/system_settings/environments/premiere.json new file mode 100644 index 0000000000..27dc5c564b --- /dev/null +++ b/pype/settings/defaults/system_settings/environments/premiere.json @@ -0,0 +1,11 @@ +{ + "EXTENSIONS_PATH": { + "windows": "{USERPROFILE}/AppData/Roaming/Adobe/CEP/extensions", + "darvin": "{USER}/Library/Application Support/Adobe/CEP/extensions" + }, + "EXTENSIONS_CACHE_PATH": { + "windows": "{USERPROFILE}/AppData/Local/Temp/cep_cache", + "darvin": "{USER}/Library/Application Support/Adobe/CEP/cep_cache" + }, + "installed_zxp": "" +} diff --git a/pype/settings/defaults/system_settings/environments/resolve.json b/pype/settings/defaults/system_settings/environments/resolve.json new file mode 100644 index 0000000000..1ff197dd5a --- /dev/null +++ b/pype/settings/defaults/system_settings/environments/resolve.json @@ -0,0 +1,40 @@ +{ + "RESOLVE_UTILITY_SCRIPTS_SOURCE_DIR": [ + "{STUDIO_SOFT}/davinci_resolve/scripts/python" + ], + "RESOLVE_SCRIPT_API": { + "windows": "{PROGRAMDATA}/Blackmagic Design/DaVinci Resolve/Support/Developer/Scripting", + "darvin": "/Library/Application Support/Blackmagic Design/DaVinci Resolve/Developer/Scripting", + "linux": "/opt/resolve/Developer/Scripting" + }, + "RESOLVE_SCRIPT_LIB": { + "windows": "C:/Program Files/Blackmagic Design/DaVinci Resolve/fusionscript.dll", + "darvin": "/Applications/DaVinci Resolve/DaVinci Resolve.app/Contents/Libraries/Fusion/fusionscript.so", + "linux": "/opt/resolve/libs/Fusion/fusionscript.so" + }, + "RESOLVE_UTILITY_SCRIPTS_DIR": { + "windows": "{PROGRAMDATA}/Blackmagic Design/DaVinci Resolve/Fusion/Scripts/Comp", + "darvin": "/Library/Application Support/Blackmagic Design/DaVinci Resolve/Fusion/Scripts/Comp", + "linux": "/opt/resolve/Fusion/Scripts/Comp" + }, + "PYTHON36_RESOLVE": { + "windows": "{LOCALAPPDATA}/Programs/Python/Python36", + "darvin": "~/Library/Python/3.6/bin", + "linux": "/opt/Python/3.6/bin" + }, + "PYTHONPATH": [ + "{PYTHON36_RESOLVE}/Lib/site-packages", + "{VIRTUAL_ENV}/Lib/site-packages", + "{PYTHONPATH}", + "{RESOLVE_SCRIPT_API}/Modules", + "{PYTHONPATH}" + ], + "PATH": [ + "{PYTHON36_RESOLVE}", + "{PYTHON36_RESOLVE}/Scripts", + "{PATH}" + ], + "PRE_PYTHON_SCRIPT": "{PYPE_MODULE_ROOT}/pype/resolve/preload_console.py", + "PYPE_LOG_NO_COLORS": "True", + "RESOLVE_DEV": "True" +} diff --git a/pype/settings/defaults/system_settings/environments/storyboardpro.json b/pype/settings/defaults/system_settings/environments/storyboardpro.json new file mode 100644 index 0000000000..581ad4db45 --- /dev/null +++ b/pype/settings/defaults/system_settings/environments/storyboardpro.json @@ -0,0 +1,4 @@ +{ + "AVALON_TOONBOOM_WORKFILES_ON_LAUNCH": "1", + "PYBLISH_LITE_ALWAYS_EXEC": "1" +} diff --git a/pype/settings/defaults/system_settings/environments/unreal_4.24.json b/pype/settings/defaults/system_settings/environments/unreal_4.24.json new file mode 100644 index 0000000000..8feeb0230f --- /dev/null +++ b/pype/settings/defaults/system_settings/environments/unreal_4.24.json @@ -0,0 +1,5 @@ +{ + "AVALON_UNREAL_PLUGIN": "{PYPE_SETUP_PATH}/repos/avalon-unreal-integration", + "PYPE_LOG_NO_COLORS": "True", + "QT_PREFERRED_BINDING": "PySide" +} diff --git a/pype/settings/defaults/system_settings/environments/vray_4300.json b/pype/settings/defaults/system_settings/environments/vray_4300.json new file mode 100644 index 0000000000..3212188441 --- /dev/null +++ b/pype/settings/defaults/system_settings/environments/vray_4300.json @@ -0,0 +1,15 @@ +{ + "VRAY_VERSION": "43001", + "VRAY_ROOT": "C:/vray/vray_{VRAY_VERSION}", + "MAYA_RENDER_DESC_PATH": "{VRAY_ROOT}/maya_root/bin/rendererDesc", + "VRAY_FOR_MAYA2019_MAIN": "{VRAY_ROOT}/maya_vray", + "VRAY_FOR_MAYA2019_PLUGINS": "{VRAY_ROOT}/maya_vray/vrayplugins", + "VRAY_PLUGINS": "{VRAY_ROOT}/maya_vray/vrayplugins", + "VRAY_OSL_PATH_MAYA2019": "{VRAY_ROOT}/vray/opensl", + "PATH": "{VRAY_ROOT}/maya_root/bin;{PATH}", + "MAYA_PLUG_IN_PATH": "{VRAY_ROOT}/maya_vray/plug-ins", + "MAYA_SCRIPT_PATH": "{VRAY_ROOT}/maya_vray/scripts", + "PYTHONPATH": "{VRAY_ROOT}/maya_vray/scripts;{PYTHONPATH}", + "XBMLANGPATH": "{VRAY_ROOT}/maya_vray/icons;{XBMLANGPATH}", + "VRAY_AUTH_CLIENT_FILE_PATH": "{VRAY_ROOT}" +} diff --git a/pype/settings/defaults/system_settings/global/applications.json b/pype/settings/defaults/system_settings/global/applications.json new file mode 100644 index 0000000000..e85e5864d9 --- /dev/null +++ b/pype/settings/defaults/system_settings/global/applications.json @@ -0,0 +1,34 @@ +{ + "blender_2.80": false, + "blender_2.81": false, + "blender_2.82": false, + "blender_2.83": true, + "celaction_local": true, + "celaction_remote": true, + "harmony_17": true, + "maya_2017": false, + "maya_2018": false, + "maya_2019": true, + "maya_2020": true, + "nuke_10.0": false, + "nuke_11.2": false, + "nuke_11.3": true, + "nuke_12.0": true, + "nukex_10.0": false, + "nukex_11.2": false, + "nukex_11.3": true, + "nukex_12.0": true, + "nukestudio_10.0": false, + "nukestudio_11.2": false, + "nukestudio_11.3": true, + "nukestudio_12.0": true, + "houdini_16": false, + "houdini_16.5": false, + "houdini_17": false, + "houdini_18": true, + "premiere_2019": false, + "premiere_2020": true, + "resolve_16": true, + "storyboardpro_7": true, + "unreal_4.24": true +} \ No newline at end of file diff --git a/pype/settings/defaults/system_settings/global/general.json b/pype/settings/defaults/system_settings/global/general.json new file mode 100644 index 0000000000..bd501b06eb --- /dev/null +++ b/pype/settings/defaults/system_settings/global/general.json @@ -0,0 +1,4 @@ +{ + "studio_name": "", + "studio_code": "" +} \ No newline at end of file diff --git a/pype/settings/defaults/system_settings/global/intent.json b/pype/settings/defaults/system_settings/global/intent.json new file mode 100644 index 0000000000..844bd1b518 --- /dev/null +++ b/pype/settings/defaults/system_settings/global/intent.json @@ -0,0 +1,8 @@ +{ + "items": { + "wip": "WIP", + "test": "TEST", + "final": "FINAL" + }, + "default": "wip" +} \ No newline at end of file diff --git a/pype/settings/defaults/system_settings/global/modules.json b/pype/settings/defaults/system_settings/global/modules.json new file mode 100644 index 0000000000..9bd46602cf --- /dev/null +++ b/pype/settings/defaults/system_settings/global/modules.json @@ -0,0 +1,88 @@ +{ + "Avalon": { + "AVALON_MONGO": "mongodb://localhost:2707", + "AVALON_DB_DATA": "{PYPE_SETUP_PATH}/../mongo_db_data", + "AVALON_THUMBNAIL_ROOT": "{PYPE_SETUP_PATH}/../avalon_thumails" + }, + "Ftrack": { + "enabled": true, + "ftrack_server": "https://pype.ftrackapp.com", + "ftrack_actions_path": [], + "ftrack_events_path": [], + "FTRACK_EVENTS_MONGO_DB": "pype", + "FTRACK_EVENTS_MONGO_COL": "ftrack_events", + "sync_to_avalon": { + "statuses_name_change": [ + "ready", + "not ready" + ] + }, + "status_version_to_task": {}, + "status_update": { + "Ready": [ + "Not Ready" + ], + "In Progress": [ + "_any_" + ] + }, + "intent": { + "items": { + "-": "-", + "wip": "WIP", + "final": "Final", + "test": "Test" + }, + "default": "-" + } + }, + "Rest Api": { + "default_port": 8021, + "exclude_ports": [] + }, + "Timers Manager": { + "enabled": true, + "full_time": 15.0, + "message_time": 0.5 + }, + "Clockify": { + "enabled": true, + "workspace_name": "studio name" + }, + "Deadline": { + "enabled": true, + "DEADLINE_REST_URL": "http://localhost:8082" + }, + "Muster": { + "enabled": false, + "MUSTER_REST_URL": "", + "templates_mapping": { + "file_layers": 7, + "mentalray": 2, + "mentalray_sf": 6, + "redshift": 55, + "renderman": 29, + "software": 1, + "software_sf": 5, + "turtle": 10, + "vector": 4, + "vray": 37, + "ffmpeg": 48 + } + }, + "Logging": { + "enabled": true + }, + "Adobe Communicator": { + "enabled": true + }, + "User setting": { + "enabled": true + }, + "Standalone Publish": { + "enabled": true + }, + "Idle Manager": { + "enabled": true + } +} \ No newline at end of file diff --git a/pype/settings/defaults/system_settings/global/tools.json b/pype/settings/defaults/system_settings/global/tools.json new file mode 100644 index 0000000000..1f8c2ad1ea --- /dev/null +++ b/pype/settings/defaults/system_settings/global/tools.json @@ -0,0 +1,6 @@ +{ + "mtoa_3.0.1": false, + "mtoa_3.1.1": false, + "mtoa_3.2.0": true, + "yeti_2.1.2": true +} \ No newline at end of file diff --git a/pype/settings/defaults/system_settings/launchers/blender_2.80.toml b/pype/settings/defaults/system_settings/launchers/blender_2.80.toml new file mode 100644 index 0000000000..5fea78b7b0 --- /dev/null +++ b/pype/settings/defaults/system_settings/launchers/blender_2.80.toml @@ -0,0 +1,7 @@ +application_dir = "blender" +executable = "blender_2.80" +schema = "avalon-core:application-1.0" +label = "Blender 2.80" +ftrack_label = "Blender" +icon ="blender" +ftrack_icon = '{}/app_icons/blender.png' diff --git a/pype/settings/defaults/system_settings/launchers/blender_2.81.toml b/pype/settings/defaults/system_settings/launchers/blender_2.81.toml new file mode 100644 index 0000000000..4f85ee5558 --- /dev/null +++ b/pype/settings/defaults/system_settings/launchers/blender_2.81.toml @@ -0,0 +1,7 @@ +application_dir = "blender" +executable = "blender_2.81" +schema = "avalon-core:application-1.0" +label = "Blender 2.81" +ftrack_label = "Blender" +icon ="blender" +ftrack_icon = '{}/app_icons/blender.png' diff --git a/pype/settings/defaults/system_settings/launchers/blender_2.82.toml b/pype/settings/defaults/system_settings/launchers/blender_2.82.toml new file mode 100644 index 0000000000..840001452e --- /dev/null +++ b/pype/settings/defaults/system_settings/launchers/blender_2.82.toml @@ -0,0 +1,7 @@ +application_dir = "blender" +executable = "blender_2.82" +schema = "avalon-core:application-1.0" +label = "Blender 2.82" +ftrack_label = "Blender" +icon ="blender" +ftrack_icon = '{}/app_icons/blender.png' diff --git a/pype/settings/defaults/system_settings/launchers/blender_2.83.toml b/pype/settings/defaults/system_settings/launchers/blender_2.83.toml new file mode 100644 index 0000000000..7fc8bf87b9 --- /dev/null +++ b/pype/settings/defaults/system_settings/launchers/blender_2.83.toml @@ -0,0 +1,7 @@ +application_dir = "blender" +executable = "blender_2.83" +schema = "avalon-core:application-1.0" +label = "Blender 2.83" +ftrack_label = "Blender" +icon ="blender" +ftrack_icon = '{}/app_icons/blender.png' diff --git a/pype/settings/defaults/system_settings/launchers/celaction_local.toml b/pype/settings/defaults/system_settings/launchers/celaction_local.toml new file mode 100644 index 0000000000..aef3548e08 --- /dev/null +++ b/pype/settings/defaults/system_settings/launchers/celaction_local.toml @@ -0,0 +1,8 @@ +executable = "celaction_local" +schema = "avalon-core:application-1.0" +application_dir = "celaction" +label = "CelAction2D" +ftrack_label = "CelAction2D" +icon ="celaction_local" +launch_hook = "pype/hooks/celaction/prelaunch.py/CelactionPrelaunchHook" +ftrack_icon = '{}/app_icons/celaction_local.png' diff --git a/pype/settings/defaults/system_settings/launchers/celaction_publish.toml b/pype/settings/defaults/system_settings/launchers/celaction_publish.toml new file mode 100644 index 0000000000..86f4ae39e7 --- /dev/null +++ b/pype/settings/defaults/system_settings/launchers/celaction_publish.toml @@ -0,0 +1,7 @@ +schema = "avalon-core:application-1.0" +application_dir = "shell" +executable = "celaction_publish" +label = "Shell" + +[environment] +CREATE_NEW_CONSOLE = "Yes" diff --git a/pype/settings/defaults/system_settings/launchers/darwin/blender_2.82 b/pype/settings/defaults/system_settings/launchers/darwin/blender_2.82 new file mode 100644 index 0000000000..8254411ea2 --- /dev/null +++ b/pype/settings/defaults/system_settings/launchers/darwin/blender_2.82 @@ -0,0 +1,2 @@ +#!/usr/bin/env bash +open -a blender $@ diff --git a/pype/settings/defaults/system_settings/launchers/darwin/harmony_17 b/pype/settings/defaults/system_settings/launchers/darwin/harmony_17 new file mode 100644 index 0000000000..b7eba2c2d0 --- /dev/null +++ b/pype/settings/defaults/system_settings/launchers/darwin/harmony_17 @@ -0,0 +1,9 @@ +#!/usr/bin/env bash +DIRNAME="$( cd "$( dirname "${BASH_SOURCE[0]}" )" >/dev/null 2>&1 && pwd )" +set >~/environment.tmp +if [ $? -ne -0 ] ; then + echo "ERROR: cannot write to '~/environment.tmp'!" + read -n 1 -s -r -p "Press any key to exit" + return +fi +open -a Terminal.app "$DIRNAME/harmony_17_launch" diff --git a/pype/settings/defaults/system_settings/launchers/darwin/harmony_17_launch b/pype/settings/defaults/system_settings/launchers/darwin/harmony_17_launch new file mode 100644 index 0000000000..5dcf5db57e --- /dev/null +++ b/pype/settings/defaults/system_settings/launchers/darwin/harmony_17_launch @@ -0,0 +1,5 @@ +#!/usr/bin/env bash +source ~/environment.tmp +export $(cut -d= -f1 ~/environment.tmp) +exe="/Applications/Toon Boom Harmony 17 Premium/Harmony Premium.app/Contents/MacOS/Harmony Premium" +$PYPE_PYTHON_EXE -c "import avalon.harmony;avalon.harmony.launch('$exe')" diff --git a/pype/settings/defaults/system_settings/launchers/darwin/python3 b/pype/settings/defaults/system_settings/launchers/darwin/python3 new file mode 100644 index 0000000000..c2b82c7638 --- /dev/null +++ b/pype/settings/defaults/system_settings/launchers/darwin/python3 @@ -0,0 +1,2 @@ +#!/usr/bin/env bash +open /usr/bin/python3 --args $@ diff --git a/pype/settings/defaults/system_settings/launchers/harmony_17.toml b/pype/settings/defaults/system_settings/launchers/harmony_17.toml new file mode 100644 index 0000000000..dbb76444a7 --- /dev/null +++ b/pype/settings/defaults/system_settings/launchers/harmony_17.toml @@ -0,0 +1,8 @@ +application_dir = "harmony" +label = "Harmony 17" +ftrack_label = "Harmony" +schema = "avalon-core:application-1.0" +executable = "harmony_17" +description = "" +icon ="harmony_icon" +ftrack_icon = '{}/app_icons/harmony.png' diff --git a/pype/settings/defaults/system_settings/launchers/houdini_16.toml b/pype/settings/defaults/system_settings/launchers/houdini_16.toml new file mode 100644 index 0000000000..e29fa74cad --- /dev/null +++ b/pype/settings/defaults/system_settings/launchers/houdini_16.toml @@ -0,0 +1,7 @@ +executable = "houdini_16" +schema = "avalon-core:application-1.0" +application_dir = "houdini" +label = "Houdini 16" +ftrack_label = "Houdini" +icon = "houdini_icon" +ftrack_icon = '{}/app_icons/houdini.png' diff --git a/pype/settings/defaults/system_settings/launchers/houdini_17.toml b/pype/settings/defaults/system_settings/launchers/houdini_17.toml new file mode 100644 index 0000000000..5d01364330 --- /dev/null +++ b/pype/settings/defaults/system_settings/launchers/houdini_17.toml @@ -0,0 +1,7 @@ +executable = "houdini_17" +schema = "avalon-core:application-1.0" +application_dir = "houdini" +label = "Houdini 17.0" +ftrack_label = "Houdini" +icon = "houdini_icon" +ftrack_icon = '{}/app_icons/houdini.png' diff --git a/pype/settings/defaults/system_settings/launchers/houdini_18.toml b/pype/settings/defaults/system_settings/launchers/houdini_18.toml new file mode 100644 index 0000000000..93b9a3334d --- /dev/null +++ b/pype/settings/defaults/system_settings/launchers/houdini_18.toml @@ -0,0 +1,7 @@ +executable = "houdini_18" +schema = "avalon-core:application-1.0" +application_dir = "houdini" +label = "Houdini 18" +ftrack_label = "Houdini" +icon = "houdini_icon" +ftrack_icon = '{}/app_icons/houdini.png' diff --git a/pype/settings/defaults/system_settings/launchers/linux/maya2016 b/pype/settings/defaults/system_settings/launchers/linux/maya2016 new file mode 100644 index 0000000000..98424304b1 --- /dev/null +++ b/pype/settings/defaults/system_settings/launchers/linux/maya2016 @@ -0,0 +1,8 @@ +#!/usr/bin/env bash + +maya_path = "/usr/autodesk/maya2016/bin/maya" + +if [[ -z $PYPE_LOG_NO_COLORS ]]; then + $maya_path -file "$AVALON_LAST_WORKFILE" $@ +else + $maya_path $@ diff --git a/pype/settings/defaults/system_settings/launchers/linux/maya2017 b/pype/settings/defaults/system_settings/launchers/linux/maya2017 new file mode 100644 index 0000000000..7a2662a55e --- /dev/null +++ b/pype/settings/defaults/system_settings/launchers/linux/maya2017 @@ -0,0 +1,8 @@ +#!/usr/bin/env bash + +maya_path = "/usr/autodesk/maya2017/bin/maya" + +if [[ -z $AVALON_LAST_WORKFILE ]]; then + $maya_path -file "$AVALON_LAST_WORKFILE" $@ +else + $maya_path $@ diff --git a/pype/settings/defaults/system_settings/launchers/linux/maya2018 b/pype/settings/defaults/system_settings/launchers/linux/maya2018 new file mode 100644 index 0000000000..db832b3fe7 --- /dev/null +++ b/pype/settings/defaults/system_settings/launchers/linux/maya2018 @@ -0,0 +1,8 @@ +#!/usr/bin/env bash + +maya_path = "/usr/autodesk/maya2018/bin/maya" + +if [[ -z $AVALON_LAST_WORKFILE ]]; then + $maya_path -file "$AVALON_LAST_WORKFILE" $@ +else + $maya_path $@ diff --git a/pype/settings/defaults/system_settings/launchers/linux/maya2019 b/pype/settings/defaults/system_settings/launchers/linux/maya2019 new file mode 100644 index 0000000000..8398734ab9 --- /dev/null +++ b/pype/settings/defaults/system_settings/launchers/linux/maya2019 @@ -0,0 +1,8 @@ +#!/usr/bin/env bash + +maya_path = "/usr/autodesk/maya2019/bin/maya" + +if [[ -z $AVALON_LAST_WORKFILE ]]; then + $maya_path -file "$AVALON_LAST_WORKFILE" $@ +else + $maya_path $@ diff --git a/pype/settings/defaults/system_settings/launchers/linux/maya2020 b/pype/settings/defaults/system_settings/launchers/linux/maya2020 new file mode 100644 index 0000000000..18a1edd598 --- /dev/null +++ b/pype/settings/defaults/system_settings/launchers/linux/maya2020 @@ -0,0 +1,8 @@ +#!/usr/bin/env bash + +maya_path = "/usr/autodesk/maya2020/bin/maya" + +if [[ -z $AVALON_LAST_WORKFILE ]]; then + $maya_path -file "$AVALON_LAST_WORKFILE" $@ +else + $maya_path $@ diff --git a/pype/settings/defaults/system_settings/launchers/linux/nuke11.3 b/pype/settings/defaults/system_settings/launchers/linux/nuke11.3 new file mode 100644 index 0000000000..b1c9a90d74 --- /dev/null +++ b/pype/settings/defaults/system_settings/launchers/linux/nuke11.3 @@ -0,0 +1,2 @@ +#!/usr/bin/env bash +gnome-terminal -e '/usr/local/Nuke11.3v5/Nuke11.3' diff --git a/pype/settings/defaults/system_settings/launchers/linux/nuke12.0 b/pype/settings/defaults/system_settings/launchers/linux/nuke12.0 new file mode 100644 index 0000000000..99ea1a6b0c --- /dev/null +++ b/pype/settings/defaults/system_settings/launchers/linux/nuke12.0 @@ -0,0 +1,2 @@ +#!/usr/bin/env bash +gnome-terminal -e '/usr/local/Nuke12.0v1/Nuke12.0' diff --git a/pype/settings/defaults/system_settings/launchers/linux/nukestudio11.3 b/pype/settings/defaults/system_settings/launchers/linux/nukestudio11.3 new file mode 100644 index 0000000000..750d54a7d5 --- /dev/null +++ b/pype/settings/defaults/system_settings/launchers/linux/nukestudio11.3 @@ -0,0 +1,2 @@ +#!/usr/bin/env bash +gnome-terminal -e '/usr/local/Nuke11.3v5/Nuke11.3 --studio' diff --git a/pype/settings/defaults/system_settings/launchers/linux/nukestudio12.0 b/pype/settings/defaults/system_settings/launchers/linux/nukestudio12.0 new file mode 100644 index 0000000000..ba5cf654a8 --- /dev/null +++ b/pype/settings/defaults/system_settings/launchers/linux/nukestudio12.0 @@ -0,0 +1,2 @@ +#!/usr/bin/env bash +gnome-terminal -e '/usr/local/Nuke12.0v1/Nuke12.0 --studio' diff --git a/pype/settings/defaults/system_settings/launchers/linux/nukex11.3 b/pype/settings/defaults/system_settings/launchers/linux/nukex11.3 new file mode 100644 index 0000000000..d913e4b961 --- /dev/null +++ b/pype/settings/defaults/system_settings/launchers/linux/nukex11.3 @@ -0,0 +1,2 @@ +#!/usr/bin/env bash +gnome-terminal -e '/usr/local/Nuke11.3v5/Nuke11.3 -nukex' diff --git a/pype/settings/defaults/system_settings/launchers/linux/nukex12.0 b/pype/settings/defaults/system_settings/launchers/linux/nukex12.0 new file mode 100644 index 0000000000..da2721c48b --- /dev/null +++ b/pype/settings/defaults/system_settings/launchers/linux/nukex12.0 @@ -0,0 +1,2 @@ +#!/usr/bin/env bash +gnome-terminal -e '/usr/local/Nuke12.0v1/Nuke12.0 -nukex' diff --git a/pype/settings/defaults/system_settings/launchers/maya_2016.toml b/pype/settings/defaults/system_settings/launchers/maya_2016.toml new file mode 100644 index 0000000000..d69c4effaf --- /dev/null +++ b/pype/settings/defaults/system_settings/launchers/maya_2016.toml @@ -0,0 +1,26 @@ +application_dir = "maya" +default_dirs = [ + "scenes", + "data", + "renderData/shaders", + "images" +] +label = "Autodesk Maya 2016x64" +ftrack_label = "Maya" +schema = "avalon-core:application-1.0" +executable = "maya2016" +description = "" +icon ="maya_icon" +ftrack_icon = '{}/app_icons/maya.png' + +[copy] +"{PYPE_MODULE_ROOT}/pype/resources/maya/workspace.mel" = "workspace.mel" + +[environment] +MAYA_DISABLE_CLIC_IPM = "Yes" # Disable the AdSSO process +MAYA_DISABLE_CIP = "Yes" # Shorten time to boot +MAYA_DISABLE_CER = "Yes" +PYTHONPATH = [ + "{AVALON_CORE}/setup/maya", + "{PYTHONPATH}" +] diff --git a/pype/settings/defaults/system_settings/launchers/maya_2017.toml b/pype/settings/defaults/system_settings/launchers/maya_2017.toml new file mode 100644 index 0000000000..2d1c35b530 --- /dev/null +++ b/pype/settings/defaults/system_settings/launchers/maya_2017.toml @@ -0,0 +1,28 @@ +application_dir = "maya" +default_dirs = [ + "scenes", + "data", + "renderData/shaders", + "images" +] +label = "Autodesk Maya 2017" +ftrack_label = "Maya" +schema = "avalon-core:application-1.0" +executable = "maya2017" +description = "" +icon ="maya_icon" +ftrack_icon = '{}/app_icons/maya.png' + +[copy] +"{PYPE_MODULE_ROOT}/pype/resources/maya/workspace.mel" = "workspace.mel" + +[environment] +MAYA_DISABLE_CLIC_IPM = "Yes" # Disable the AdSSO process +MAYA_DISABLE_CIP = "Yes" # Shorten time to boot +MAYA_DISABLE_CER = "Yes" +PYMEL_SKIP_MEL_INIT = "Yes" +LC_ALL= "C" # Mute color management warnings +PYTHONPATH = [ + "{AVALON_CORE}/setup/maya", + "{PYTHONPATH}" +] diff --git a/pype/settings/defaults/system_settings/launchers/maya_2018.toml b/pype/settings/defaults/system_settings/launchers/maya_2018.toml new file mode 100644 index 0000000000..f180263fa2 --- /dev/null +++ b/pype/settings/defaults/system_settings/launchers/maya_2018.toml @@ -0,0 +1,14 @@ +application_dir = "maya" +default_dirs = [ + "renders" +] +label = "Autodesk Maya 2018" +ftrack_label = "Maya" +schema = "avalon-core:application-1.0" +executable = "maya2018" +description = "" +icon ="maya_icon" +ftrack_icon = '{}/app_icons/maya.png' + +[copy] +"{PYPE_MODULE_ROOT}/pype/resources/maya/workspace.mel" = "workspace.mel" diff --git a/pype/settings/defaults/system_settings/launchers/maya_2019.toml b/pype/settings/defaults/system_settings/launchers/maya_2019.toml new file mode 100644 index 0000000000..7ec2cbcedd --- /dev/null +++ b/pype/settings/defaults/system_settings/launchers/maya_2019.toml @@ -0,0 +1,14 @@ +application_dir = "maya" +default_dirs = [ + "renders" +] +label = "Autodesk Maya 2019" +ftrack_label = "Maya" +schema = "avalon-core:application-1.0" +executable = "maya2019" +description = "" +icon ="maya_icon" +ftrack_icon = '{}/app_icons/maya.png' + +[copy] +"{PYPE_MODULE_ROOT}/pype/resources/maya/workspace.mel" = "workspace.mel" diff --git a/pype/settings/defaults/system_settings/launchers/maya_2020.toml b/pype/settings/defaults/system_settings/launchers/maya_2020.toml new file mode 100644 index 0000000000..49d84ef9a0 --- /dev/null +++ b/pype/settings/defaults/system_settings/launchers/maya_2020.toml @@ -0,0 +1,14 @@ +application_dir = "maya" +default_dirs = [ + "renders" +] +label = "Autodesk Maya 2020" +ftrack_label = "Maya" +schema = "avalon-core:application-1.0" +executable = "maya2020" +description = "" +icon ="maya_icon" +ftrack_icon = '{}/app_icons/maya.png' + +[copy] +"{PYPE_MODULE_ROOT}/pype/resources/maya/workspace.mel" = "workspace.mel" diff --git a/pype/settings/defaults/system_settings/launchers/mayabatch_2019.toml b/pype/settings/defaults/system_settings/launchers/mayabatch_2019.toml new file mode 100644 index 0000000000..a928618d2b --- /dev/null +++ b/pype/settings/defaults/system_settings/launchers/mayabatch_2019.toml @@ -0,0 +1,17 @@ +application_dir = "maya" +default_dirs = [ + "scenes", + "data", + "renderData/shaders", + "images" +] +label = "Autodesk Maya 2019x64" +schema = "avalon-core:application-1.0" +executable = "mayabatch2019" +description = "" + +[environment] +PYTHONPATH = [ + "{AVALON_CORE}/setup/maya", + "{PYTHONPATH}" +] diff --git a/pype/settings/defaults/system_settings/launchers/mayabatch_2020.toml b/pype/settings/defaults/system_settings/launchers/mayabatch_2020.toml new file mode 100644 index 0000000000..cd1e1e4474 --- /dev/null +++ b/pype/settings/defaults/system_settings/launchers/mayabatch_2020.toml @@ -0,0 +1,17 @@ +application_dir = "maya" +default_dirs = [ + "scenes", + "data", + "renderData/shaders", + "images" +] +label = "Autodesk Maya 2020x64" +schema = "avalon-core:application-1.0" +executable = "mayabatch2020" +description = "" + +[environment] +PYTHONPATH = [ + "{AVALON_CORE}/setup/maya", + "{PYTHONPATH}" +] diff --git a/pype/settings/defaults/system_settings/launchers/mayapy2016.toml b/pype/settings/defaults/system_settings/launchers/mayapy2016.toml new file mode 100644 index 0000000000..ad1e3dee86 --- /dev/null +++ b/pype/settings/defaults/system_settings/launchers/mayapy2016.toml @@ -0,0 +1,17 @@ +application_dir = "maya" +default_dirs = [ + "scenes", + "data", + "renderData/shaders", + "images" +] +label = "Autodesk Maya 2016x64" +schema = "avalon-core:application-1.0" +executable = "mayapy2016" +description = "" + +[environment] +PYTHONPATH = [ + "{AVALON_CORE}/setup/maya", + "{PYTHONPATH}" +] diff --git a/pype/settings/defaults/system_settings/launchers/mayapy2017.toml b/pype/settings/defaults/system_settings/launchers/mayapy2017.toml new file mode 100644 index 0000000000..8d2095ff47 --- /dev/null +++ b/pype/settings/defaults/system_settings/launchers/mayapy2017.toml @@ -0,0 +1,17 @@ +application_dir = "maya" +default_dirs = [ + "scenes", + "data", + "renderData/shaders", + "images" +] +label = "Autodesk Maya 2017x64" +schema = "avalon-core:application-1.0" +executable = "mayapy2017" +description = "" + +[environment] +PYTHONPATH = [ + "{AVALON_CORE}/setup/maya", + "{PYTHONPATH}" +] diff --git a/pype/settings/defaults/system_settings/launchers/mayapy2018.toml b/pype/settings/defaults/system_settings/launchers/mayapy2018.toml new file mode 100644 index 0000000000..597744fd85 --- /dev/null +++ b/pype/settings/defaults/system_settings/launchers/mayapy2018.toml @@ -0,0 +1,17 @@ +application_dir = "maya" +default_dirs = [ + "scenes", + "data", + "renderData/shaders", + "images" +] +label = "Autodesk Maya 2018x64" +schema = "avalon-core:application-1.0" +executable = "mayapy2017" +description = "" + +[environment] +PYTHONPATH = [ + "{AVALON_CORE}/setup/maya", + "{PYTHONPATH}" +] diff --git a/pype/settings/defaults/system_settings/launchers/mayapy2019.toml b/pype/settings/defaults/system_settings/launchers/mayapy2019.toml new file mode 100644 index 0000000000..3c8a9860f9 --- /dev/null +++ b/pype/settings/defaults/system_settings/launchers/mayapy2019.toml @@ -0,0 +1,17 @@ +application_dir = "maya" +default_dirs = [ + "scenes", + "data", + "renderData/shaders", + "images" +] +label = "Autodesk Maya 2019x64" +schema = "avalon-core:application-1.0" +executable = "mayapy2019" +description = "" + +[environment] +PYTHONPATH = [ + "{AVALON_CORE}/setup/maya", + "{PYTHONPATH}" +] diff --git a/pype/settings/defaults/system_settings/launchers/mayapy2020.toml b/pype/settings/defaults/system_settings/launchers/mayapy2020.toml new file mode 100644 index 0000000000..8f2d2e4a67 --- /dev/null +++ b/pype/settings/defaults/system_settings/launchers/mayapy2020.toml @@ -0,0 +1,17 @@ +application_dir = "maya" +default_dirs = [ + "scenes", + "data", + "renderData/shaders", + "images" +] +label = "Autodesk Maya 2020x64" +schema = "avalon-core:application-1.0" +executable = "mayapy2020" +description = "" + +[environment] +PYTHONPATH = [ + "{AVALON_CORE}/setup/maya", + "{PYTHONPATH}" +] diff --git a/pype/settings/defaults/system_settings/launchers/myapp.toml b/pype/settings/defaults/system_settings/launchers/myapp.toml new file mode 100644 index 0000000000..21da0d52b2 --- /dev/null +++ b/pype/settings/defaults/system_settings/launchers/myapp.toml @@ -0,0 +1,5 @@ +executable = "python" +schema = "avalon-core:application-1.0" +application_dir = "myapp" +label = "My App" +arguments = [ "-c", "import sys; from Qt import QtWidgets; if __name__ == '__main__':;\n app = QtWidgets.QApplication(sys.argv);\n window = QtWidgets.QWidget();\n window.setWindowTitle(\"My App\");\n window.resize(400, 300);\n window.show();\n app.exec_();\n",] \ No newline at end of file diff --git a/pype/settings/defaults/system_settings/launchers/nuke_10.0.toml b/pype/settings/defaults/system_settings/launchers/nuke_10.0.toml new file mode 100644 index 0000000000..2195fd3e82 --- /dev/null +++ b/pype/settings/defaults/system_settings/launchers/nuke_10.0.toml @@ -0,0 +1,7 @@ +executable = "nuke10.0" +schema = "avalon-core:application-1.0" +application_dir = "nuke" +label = "Nuke 10.0v4" +ftrack_label = "Nuke" +icon ="nuke_icon" +ftrack_icon = '{}/app_icons/nuke.png' diff --git a/pype/settings/defaults/system_settings/launchers/nuke_11.0.toml b/pype/settings/defaults/system_settings/launchers/nuke_11.0.toml new file mode 100644 index 0000000000..0c981b479a --- /dev/null +++ b/pype/settings/defaults/system_settings/launchers/nuke_11.0.toml @@ -0,0 +1,7 @@ +executable = "nuke11.0" +schema = "avalon-core:application-1.0" +application_dir = "nuke" +label = "Nuke 11.0" +ftrack_label = "Nuke" +icon ="nuke_icon" +ftrack_icon = '{}/app_icons/nuke.png' diff --git a/pype/settings/defaults/system_settings/launchers/nuke_11.2.toml b/pype/settings/defaults/system_settings/launchers/nuke_11.2.toml new file mode 100644 index 0000000000..57c962d126 --- /dev/null +++ b/pype/settings/defaults/system_settings/launchers/nuke_11.2.toml @@ -0,0 +1,7 @@ +executable = "nuke11.2" +schema = "avalon-core:application-1.0" +application_dir = "nuke" +label = "Nuke 11.2" +ftrack_label = "Nuke" +icon ="nuke_icon" +ftrack_icon = '{}/app_icons/nuke.png' diff --git a/pype/settings/defaults/system_settings/launchers/nuke_11.3.toml b/pype/settings/defaults/system_settings/launchers/nuke_11.3.toml new file mode 100644 index 0000000000..87f769c23b --- /dev/null +++ b/pype/settings/defaults/system_settings/launchers/nuke_11.3.toml @@ -0,0 +1,7 @@ +executable = "nuke11.3" +schema = "avalon-core:application-1.0" +application_dir = "nuke" +label = "Nuke 11.3" +ftrack_label = "Nuke" +icon ="nuke_icon" +ftrack_icon = '{}/app_icons/nuke.png' diff --git a/pype/settings/defaults/system_settings/launchers/nuke_12.0.toml b/pype/settings/defaults/system_settings/launchers/nuke_12.0.toml new file mode 100644 index 0000000000..62936b4cdb --- /dev/null +++ b/pype/settings/defaults/system_settings/launchers/nuke_12.0.toml @@ -0,0 +1,7 @@ +executable = "nuke12.0" +schema = "avalon-core:application-1.0" +application_dir = "nuke" +label = "Nuke 12.0" +ftrack_label = "Nuke" +icon ="nuke_icon" +ftrack_icon = '{}/app_icons/nuke.png' diff --git a/pype/settings/defaults/system_settings/launchers/nukestudio_10.0.toml b/pype/settings/defaults/system_settings/launchers/nukestudio_10.0.toml new file mode 100644 index 0000000000..41601e4d40 --- /dev/null +++ b/pype/settings/defaults/system_settings/launchers/nukestudio_10.0.toml @@ -0,0 +1,7 @@ +executable = "nukestudio10.0" +schema = "avalon-core:application-1.0" +application_dir = "nukestudio" +label = "NukeStudio 10.0" +ftrack_label = "NukeStudio" +icon ="nuke_icon" +ftrack_icon = '{}/app_icons/nuke.png' diff --git a/pype/settings/defaults/system_settings/launchers/nukestudio_11.0.toml b/pype/settings/defaults/system_settings/launchers/nukestudio_11.0.toml new file mode 100644 index 0000000000..7a9d84707a --- /dev/null +++ b/pype/settings/defaults/system_settings/launchers/nukestudio_11.0.toml @@ -0,0 +1,7 @@ +executable = "nukestudio11.0" +schema = "avalon-core:application-1.0" +application_dir = "nukestudio" +label = "NukeStudio 11.0" +ftrack_label = "NukeStudio" +icon ="nuke_icon" +ftrack_icon = '{}/app_icons/nuke.png' diff --git a/pype/settings/defaults/system_settings/launchers/nukestudio_11.2.toml b/pype/settings/defaults/system_settings/launchers/nukestudio_11.2.toml new file mode 100644 index 0000000000..21557033ca --- /dev/null +++ b/pype/settings/defaults/system_settings/launchers/nukestudio_11.2.toml @@ -0,0 +1,7 @@ +executable = "nukestudio11.2" +schema = "avalon-core:application-1.0" +application_dir = "nukestudio" +label = "NukeStudio 11.2" +ftrack_label = "NukeStudio" +icon ="nuke_icon" +ftrack_icon = '{}/app_icons/nuke.png' diff --git a/pype/settings/defaults/system_settings/launchers/nukestudio_11.3.toml b/pype/settings/defaults/system_settings/launchers/nukestudio_11.3.toml new file mode 100644 index 0000000000..1946ad6c3b --- /dev/null +++ b/pype/settings/defaults/system_settings/launchers/nukestudio_11.3.toml @@ -0,0 +1,7 @@ +executable = "nukestudio11.3" +schema = "avalon-core:application-1.0" +application_dir = "nukestudio" +label = "NukeStudio 11.3" +ftrack_label = "NukeStudio" +icon ="nuke_icon" +ftrack_icon = '{}/app_icons/nuke.png' diff --git a/pype/settings/defaults/system_settings/launchers/nukestudio_12.0.toml b/pype/settings/defaults/system_settings/launchers/nukestudio_12.0.toml new file mode 100644 index 0000000000..4ce7f9b538 --- /dev/null +++ b/pype/settings/defaults/system_settings/launchers/nukestudio_12.0.toml @@ -0,0 +1,7 @@ +executable = "nukestudio12.0" +schema = "avalon-core:application-1.0" +application_dir = "nukestudio" +label = "NukeStudio 12.0" +ftrack_label = "NukeStudio" +icon ="nuke_icon" +ftrack_icon = '{}/app_icons/nuke.png' diff --git a/pype/settings/defaults/system_settings/launchers/nukex_10.0.toml b/pype/settings/defaults/system_settings/launchers/nukex_10.0.toml new file mode 100644 index 0000000000..7dee22996d --- /dev/null +++ b/pype/settings/defaults/system_settings/launchers/nukex_10.0.toml @@ -0,0 +1,7 @@ +executable = "nukex10.0" +schema = "avalon-core:application-1.0" +application_dir = "nuke" +label = "NukeX 10.0" +ftrack_label = "NukeX" +icon ="nuke_icon" +ftrack_icon = '{}/app_icons/nukex.png' diff --git a/pype/settings/defaults/system_settings/launchers/nukex_11.0.toml b/pype/settings/defaults/system_settings/launchers/nukex_11.0.toml new file mode 100644 index 0000000000..c2b4970a26 --- /dev/null +++ b/pype/settings/defaults/system_settings/launchers/nukex_11.0.toml @@ -0,0 +1,7 @@ +executable = "nukex11.0" +schema = "avalon-core:application-1.0" +application_dir = "nuke" +label = "NukeX 11.2" +ftrack_label = "NukeX" +icon ="nuke_icon" +ftrack_icon = '{}/app_icons/nukex.png' diff --git a/pype/settings/defaults/system_settings/launchers/nukex_11.2.toml b/pype/settings/defaults/system_settings/launchers/nukex_11.2.toml new file mode 100644 index 0000000000..3857b9995c --- /dev/null +++ b/pype/settings/defaults/system_settings/launchers/nukex_11.2.toml @@ -0,0 +1,7 @@ +executable = "nukex11.2" +schema = "avalon-core:application-1.0" +application_dir = "nuke" +label = "NukeX 11.2" +ftrack_label = "NukeX" +icon ="nuke_icon" +ftrack_icon = '{}/app_icons/nukex.png' diff --git a/pype/settings/defaults/system_settings/launchers/nukex_11.3.toml b/pype/settings/defaults/system_settings/launchers/nukex_11.3.toml new file mode 100644 index 0000000000..56428470eb --- /dev/null +++ b/pype/settings/defaults/system_settings/launchers/nukex_11.3.toml @@ -0,0 +1,7 @@ +executable = "nukex11.3" +schema = "avalon-core:application-1.0" +application_dir = "nuke" +label = "NukeX 11.3" +ftrack_label = "NukeX" +icon ="nuke_icon" +ftrack_icon = '{}/app_icons/nukex.png' diff --git a/pype/settings/defaults/system_settings/launchers/nukex_12.0.toml b/pype/settings/defaults/system_settings/launchers/nukex_12.0.toml new file mode 100644 index 0000000000..33d7fddb88 --- /dev/null +++ b/pype/settings/defaults/system_settings/launchers/nukex_12.0.toml @@ -0,0 +1,7 @@ +executable = "nukex12.0" +schema = "avalon-core:application-1.0" +application_dir = "nuke" +label = "NukeX 12.0" +ftrack_label = "NukeX" +icon ="nuke_icon" +ftrack_icon = '{}/app_icons/nukex.png' diff --git a/pype/settings/defaults/system_settings/launchers/photoshop_2020.toml b/pype/settings/defaults/system_settings/launchers/photoshop_2020.toml new file mode 100644 index 0000000000..117b668232 --- /dev/null +++ b/pype/settings/defaults/system_settings/launchers/photoshop_2020.toml @@ -0,0 +1,8 @@ +executable = "photoshop_2020" +schema = "avalon-core:application-1.0" +application_dir = "photoshop" +label = "Adobe Photoshop 2020" +icon ="photoshop_icon" +ftrack_label = "Photoshop" +ftrack_icon = '{}/app_icons/photoshop.png' +launch_hook = "pype/hooks/photoshop/prelaunch.py/PhotoshopPrelaunch" diff --git a/pype/settings/defaults/system_settings/launchers/premiere_2019.toml b/pype/settings/defaults/system_settings/launchers/premiere_2019.toml new file mode 100644 index 0000000000..f4c19c62cb --- /dev/null +++ b/pype/settings/defaults/system_settings/launchers/premiere_2019.toml @@ -0,0 +1,8 @@ +executable = "premiere_pro_2019" +schema = "avalon-core:application-1.0" +application_dir = "premiere" +label = "Adobe Premiere Pro CC 2019" +icon ="premiere_icon" + +ftrack_label = "Premiere" +ftrack_icon = '{}/app_icons/premiere.png' diff --git a/pype/settings/defaults/system_settings/launchers/premiere_2020.toml b/pype/settings/defaults/system_settings/launchers/premiere_2020.toml new file mode 100644 index 0000000000..4d721c898f --- /dev/null +++ b/pype/settings/defaults/system_settings/launchers/premiere_2020.toml @@ -0,0 +1,9 @@ +executable = "premiere_pro_2020" +schema = "avalon-core:application-1.0" +application_dir = "premiere" +label = "Adobe Premiere Pro CC 2020" +launch_hook = "pype/hooks/premiere/prelaunch.py/PremierePrelaunch" +icon ="premiere_icon" + +ftrack_label = "Premiere" +ftrack_icon = '{}/app_icons/premiere.png' diff --git a/pype/settings/defaults/system_settings/launchers/python_2.toml b/pype/settings/defaults/system_settings/launchers/python_2.toml new file mode 100644 index 0000000000..e9e8dd7899 --- /dev/null +++ b/pype/settings/defaults/system_settings/launchers/python_2.toml @@ -0,0 +1,10 @@ +schema = "avalon-core:application-1.0" +application_dir = "python" +executable = "python" +label = "Python 2" +ftrack_label = "Python" +icon ="python_icon" +ftrack_icon = '{}/app_icons/python.png' + +[environment] +CREATE_NEW_CONSOLE = "Yes" diff --git a/pype/settings/defaults/system_settings/launchers/python_3.toml b/pype/settings/defaults/system_settings/launchers/python_3.toml new file mode 100644 index 0000000000..5cbd8b2943 --- /dev/null +++ b/pype/settings/defaults/system_settings/launchers/python_3.toml @@ -0,0 +1,10 @@ +schema = "avalon-core:application-1.0" +application_dir = "python" +executable = "python3" +label = "Python 3" +ftrack_label = "Python" +icon ="python_icon" +ftrack_icon = '{}/app_icons/python.png' + +[environment] +CREATE_NEW_CONSOLE = "Yes" diff --git a/pype/settings/defaults/system_settings/launchers/resolve_16.toml b/pype/settings/defaults/system_settings/launchers/resolve_16.toml new file mode 100644 index 0000000000..430fd1a638 --- /dev/null +++ b/pype/settings/defaults/system_settings/launchers/resolve_16.toml @@ -0,0 +1,9 @@ +executable = "resolve_16" +schema = "avalon-core:application-1.0" +application_dir = "resolve" +label = "BM DaVinci Resolve 16" +launch_hook = "pype/hooks/resolve/prelaunch.py/ResolvePrelaunch" +icon ="resolve" + +ftrack_label = "BM DaVinci Resolve" +ftrack_icon = '{}/app_icons/resolve.png' diff --git a/pype/settings/defaults/system_settings/launchers/shell.toml b/pype/settings/defaults/system_settings/launchers/shell.toml new file mode 100644 index 0000000000..959ad392ea --- /dev/null +++ b/pype/settings/defaults/system_settings/launchers/shell.toml @@ -0,0 +1,7 @@ +schema = "avalon-core:application-1.0" +application_dir = "shell" +executable = "shell" +label = "Shell" + +[environment] +CREATE_NEW_CONSOLE = "Yes" \ No newline at end of file diff --git a/pype/settings/defaults/system_settings/launchers/storyboardpro_7.toml b/pype/settings/defaults/system_settings/launchers/storyboardpro_7.toml new file mode 100644 index 0000000000..ce8e96a49d --- /dev/null +++ b/pype/settings/defaults/system_settings/launchers/storyboardpro_7.toml @@ -0,0 +1,8 @@ +application_dir = "storyboardpro" +label = "Storyboard Pro 7" +ftrack_label = "Storyboard Pro" +schema = "avalon-core:application-1.0" +executable = "storyboardpro_7" +description = "" +icon ="storyboardpro_icon" +ftrack_icon = '{}/app_icons/storyboardpro.png' diff --git a/pype/settings/defaults/system_settings/launchers/unreal_4.24.toml b/pype/settings/defaults/system_settings/launchers/unreal_4.24.toml new file mode 100644 index 0000000000..0a799e5dcb --- /dev/null +++ b/pype/settings/defaults/system_settings/launchers/unreal_4.24.toml @@ -0,0 +1,8 @@ +executable = "unreal" +schema = "avalon-core:application-1.0" +application_dir = "unreal" +label = "Unreal Editor 4.24" +ftrack_label = "UnrealEditor" +icon ="ue4_icon" +launch_hook = "pype/hooks/unreal/unreal_prelaunch.py/UnrealPrelaunch" +ftrack_icon = '{}/app_icons/ue4.png' diff --git a/pype/settings/defaults/system_settings/launchers/windows/blender_2.80.bat b/pype/settings/defaults/system_settings/launchers/windows/blender_2.80.bat new file mode 100644 index 0000000000..5b8a37356b --- /dev/null +++ b/pype/settings/defaults/system_settings/launchers/windows/blender_2.80.bat @@ -0,0 +1,11 @@ +set __app__="Blender" +set __exe__="C:\Program Files\Blender Foundation\Blender 2.80\blender.exe" +if not exist %__exe__% goto :missing_app + +start %__app__% %__exe__% %* + +goto :eof + +:missing_app + echo ERROR: %__app__% not found in %__exe__% + exit /B 1 diff --git a/pype/settings/defaults/system_settings/launchers/windows/blender_2.81.bat b/pype/settings/defaults/system_settings/launchers/windows/blender_2.81.bat new file mode 100644 index 0000000000..a900b18eda --- /dev/null +++ b/pype/settings/defaults/system_settings/launchers/windows/blender_2.81.bat @@ -0,0 +1,11 @@ +set __app__="Blender" +set __exe__="C:\Program Files\Blender Foundation\Blender 2.81\blender.exe" +if not exist %__exe__% goto :missing_app + +start %__app__% %__exe__% %* + +goto :eof + +:missing_app + echo ERROR: %__app__% not found in %__exe__% + exit /B 1 diff --git a/pype/settings/defaults/system_settings/launchers/windows/blender_2.82.bat b/pype/settings/defaults/system_settings/launchers/windows/blender_2.82.bat new file mode 100644 index 0000000000..7105c1efe1 --- /dev/null +++ b/pype/settings/defaults/system_settings/launchers/windows/blender_2.82.bat @@ -0,0 +1,11 @@ +set __app__="Blender" +set __exe__="C:\Program Files\Blender Foundation\Blender 2.82\blender.exe" --python-use-system-env +if not exist %__exe__% goto :missing_app + +start %__app__% %__exe__% %* + +goto :eof + +:missing_app + echo ERROR: %__app__% not found in %__exe__% + exit /B 1 diff --git a/pype/settings/defaults/system_settings/launchers/windows/blender_2.83.bat b/pype/settings/defaults/system_settings/launchers/windows/blender_2.83.bat new file mode 100644 index 0000000000..671952f0d7 --- /dev/null +++ b/pype/settings/defaults/system_settings/launchers/windows/blender_2.83.bat @@ -0,0 +1,11 @@ +set __app__="Blender" +set __exe__="C:\Program Files\Blender Foundation\Blender 2.83\blender.exe" --python-use-system-env +if not exist %__exe__% goto :missing_app + +start %__app__% %__exe__% %* + +goto :eof + +:missing_app + echo ERROR: %__app__% not found in %__exe__% + exit /B 1 diff --git a/pype/settings/defaults/system_settings/launchers/windows/celaction_local.bat b/pype/settings/defaults/system_settings/launchers/windows/celaction_local.bat new file mode 100644 index 0000000000..8f2171617e --- /dev/null +++ b/pype/settings/defaults/system_settings/launchers/windows/celaction_local.bat @@ -0,0 +1,19 @@ +set __app__="CelAction2D" +set __app_dir__="C:\Program Files (x86)\CelAction\" +set __exe__="C:\Program Files (x86)\CelAction\CelAction2D.exe" + +if not exist %__exe__% goto :missing_app + +pushd %__app_dir__% + +if "%PYPE_CELACTION_PROJECT_FILE%"=="" ( + start %__app__% %__exe__% %* +) else ( + start %__app__% %__exe__% "%PYPE_CELACTION_PROJECT_FILE%" %* +) + +goto :eof + +:missing_app + echo ERROR: %__app__% not found in %__exe__% + exit /B 1 diff --git a/pype/settings/defaults/system_settings/launchers/windows/celaction_publish.bat b/pype/settings/defaults/system_settings/launchers/windows/celaction_publish.bat new file mode 100644 index 0000000000..77ec2ac24e --- /dev/null +++ b/pype/settings/defaults/system_settings/launchers/windows/celaction_publish.bat @@ -0,0 +1,3 @@ +echo %* + +%PYPE_PYTHON_EXE% "%PYPE_MODULE_ROOT%\pype\hosts\celaction\cli.py" %* diff --git a/pype/settings/defaults/system_settings/launchers/windows/harmony_17.bat b/pype/settings/defaults/system_settings/launchers/windows/harmony_17.bat new file mode 100644 index 0000000000..0822650875 --- /dev/null +++ b/pype/settings/defaults/system_settings/launchers/windows/harmony_17.bat @@ -0,0 +1,13 @@ +@echo off + +set __app__="Harmony 17" +set __exe__="C:/Program Files (x86)/Toon Boom Animation/Toon Boom Harmony 17 Premium/win64/bin/HarmonyPremium.exe" +if not exist %__exe__% goto :missing_app + +start %__app__% cmd.exe /k "python -c ^"import avalon.harmony;avalon.harmony.launch("%__exe__%")^"" + +goto :eof + +:missing_app + echo ERROR: %__app__% not found in %__exe__% + exit /B 1 diff --git a/pype/settings/defaults/system_settings/launchers/windows/houdini_16.bat b/pype/settings/defaults/system_settings/launchers/windows/houdini_16.bat new file mode 100644 index 0000000000..018ba08b4c --- /dev/null +++ b/pype/settings/defaults/system_settings/launchers/windows/houdini_16.bat @@ -0,0 +1,13 @@ +@echo off + +set __app__="Houdini 16.0" +set __exe__="C:\Program Files\Side Effects Software\Houdini 16.0.621\bin\houdini.exe" +if not exist %__exe__% goto :missing_app + +start %__app__% %__exe__% %* + +goto :eof + +:missing_app + echo ERROR: %__app__% not found in %__exe__% + exit /B 1 diff --git a/pype/settings/defaults/system_settings/launchers/windows/houdini_17.bat b/pype/settings/defaults/system_settings/launchers/windows/houdini_17.bat new file mode 100644 index 0000000000..950a599623 --- /dev/null +++ b/pype/settings/defaults/system_settings/launchers/windows/houdini_17.bat @@ -0,0 +1,13 @@ +@echo off + +set __app__="Houdini 17.0" +set __exe__="C:\Program Files\Side Effects Software\Houdini 17.0.459\bin\houdini.exe" +if not exist %__exe__% goto :missing_app + +start %__app__% %__exe__% %* + +goto :eof + +:missing_app + echo ERROR: %__app__% not found in %__exe__% + exit /B 1 diff --git a/pype/settings/defaults/system_settings/launchers/windows/houdini_18.bat b/pype/settings/defaults/system_settings/launchers/windows/houdini_18.bat new file mode 100644 index 0000000000..3d6b1ae258 --- /dev/null +++ b/pype/settings/defaults/system_settings/launchers/windows/houdini_18.bat @@ -0,0 +1,13 @@ +@echo off + +set __app__="Houdini 18.0" +set __exe__="C:\Program Files\Side Effects Software\Houdini 18.0.287\bin\houdini.exe" +if not exist %__exe__% goto :missing_app + +start %__app__% %__exe__% %* + +goto :eof + +:missing_app + echo ERROR: %__app__% not found in %__exe__% + exit /B 1 diff --git a/pype/settings/defaults/system_settings/launchers/windows/maya2016.bat b/pype/settings/defaults/system_settings/launchers/windows/maya2016.bat new file mode 100644 index 0000000000..54f15cf269 --- /dev/null +++ b/pype/settings/defaults/system_settings/launchers/windows/maya2016.bat @@ -0,0 +1,17 @@ +@echo off + +set __app__="Maya 2016" +set __exe__="C:\Program Files\Autodesk\Maya2016\bin\maya.exe" +if not exist %__exe__% goto :missing_app + +if "%AVALON_LAST_WORKFILE%"=="" ( + start %__app__% %__exe__% %* +) else ( + start %__app__% %__exe__% -file "%AVALON_LAST_WORKFILE%" %* +) + +goto :eof + +:missing_app + echo ERROR: %__app__% not found in %__exe__% + exit /B 1 diff --git a/pype/settings/defaults/system_settings/launchers/windows/maya2017.bat b/pype/settings/defaults/system_settings/launchers/windows/maya2017.bat new file mode 100644 index 0000000000..5c2aeb495c --- /dev/null +++ b/pype/settings/defaults/system_settings/launchers/windows/maya2017.bat @@ -0,0 +1,17 @@ +@echo off + +set __app__="Maya 2017" +set __exe__="C:\Program Files\Autodesk\Maya2017\bin\maya.exe" +if not exist %__exe__% goto :missing_app + +if "%AVALON_LAST_WORKFILE%"=="" ( + start %__app__% %__exe__% %* +) else ( + start %__app__% %__exe__% -file "%AVALON_LAST_WORKFILE%" %* +) + +goto :eof + +:missing_app + echo ERROR: %__app__% not found in %__exe__% + exit /B 1 diff --git a/pype/settings/defaults/system_settings/launchers/windows/maya2018.bat b/pype/settings/defaults/system_settings/launchers/windows/maya2018.bat new file mode 100644 index 0000000000..28cf776c77 --- /dev/null +++ b/pype/settings/defaults/system_settings/launchers/windows/maya2018.bat @@ -0,0 +1,17 @@ +@echo off + +set __app__="Maya 2018" +set __exe__="C:\Program Files\Autodesk\Maya2018\bin\maya.exe" +if not exist %__exe__% goto :missing_app + +if "%AVALON_LAST_WORKFILE%"=="" ( + start %__app__% %__exe__% %* +) else ( + start %__app__% %__exe__% -file "%AVALON_LAST_WORKFILE%" %* +) + +goto :eof + +:missing_app + echo ERROR: %__app__% not found in %__exe__% + exit /B 1 diff --git a/pype/settings/defaults/system_settings/launchers/windows/maya2019.bat b/pype/settings/defaults/system_settings/launchers/windows/maya2019.bat new file mode 100644 index 0000000000..7e80dd2557 --- /dev/null +++ b/pype/settings/defaults/system_settings/launchers/windows/maya2019.bat @@ -0,0 +1,17 @@ +@echo off + +set __app__="Maya 2019" +set __exe__="C:\Program Files\Autodesk\Maya2019\bin\maya.exe" +if not exist %__exe__% goto :missing_app + +if "%AVALON_LAST_WORKFILE%"=="" ( + start %__app__% %__exe__% %* +) else ( + start %__app__% %__exe__% -file "%AVALON_LAST_WORKFILE%" %* +) + +goto :eof + +:missing_app + echo ERROR: %__app__% not found in %__exe__% + exit /B 1 diff --git a/pype/settings/defaults/system_settings/launchers/windows/maya2020.bat b/pype/settings/defaults/system_settings/launchers/windows/maya2020.bat new file mode 100644 index 0000000000..b2acb5df5a --- /dev/null +++ b/pype/settings/defaults/system_settings/launchers/windows/maya2020.bat @@ -0,0 +1,17 @@ +@echo off + +set __app__="Maya 2020" +set __exe__="C:\Program Files\Autodesk\maya2020\bin\maya.exe" +if not exist %__exe__% goto :missing_app + +if "%AVALON_LAST_WORKFILE%"=="" ( + start %__app__% %__exe__% %* +) else ( + start %__app__% %__exe__% -file "%AVALON_LAST_WORKFILE%" %* +) + +goto :eof + +:missing_app + echo ERROR: %__app__% not found in %__exe__% + exit /B 1 diff --git a/pype/settings/defaults/system_settings/launchers/windows/mayabatch2019.bat b/pype/settings/defaults/system_settings/launchers/windows/mayabatch2019.bat new file mode 100644 index 0000000000..ddd9b9b956 --- /dev/null +++ b/pype/settings/defaults/system_settings/launchers/windows/mayabatch2019.bat @@ -0,0 +1,14 @@ +@echo off + +set __app__="Maya Batch 2019" +set __exe__="C:\Program Files\Autodesk\Maya2019\bin\mayabatch.exe" +if not exist %__exe__% goto :missing_app + +echo "running maya : %*" +%__exe__% %* +echo "done." +goto :eof + +:missing_app + echo ERROR: %__app__% not found in %__exe__% + exit /B 1 diff --git a/pype/settings/defaults/system_settings/launchers/windows/mayabatch2020.bat b/pype/settings/defaults/system_settings/launchers/windows/mayabatch2020.bat new file mode 100644 index 0000000000..b1cbc6dbb6 --- /dev/null +++ b/pype/settings/defaults/system_settings/launchers/windows/mayabatch2020.bat @@ -0,0 +1,14 @@ +@echo off + +set __app__="Maya Batch 2020" +set __exe__="C:\Program Files\Autodesk\Maya2020\bin\mayabatch.exe" +if not exist %__exe__% goto :missing_app + +echo "running maya : %*" +%__exe__% %* +echo "done." +goto :eof + +:missing_app + echo ERROR: %__app__% not found in %__exe__% + exit /B 1 diff --git a/pype/settings/defaults/system_settings/launchers/windows/mayapy2016.bat b/pype/settings/defaults/system_settings/launchers/windows/mayapy2016.bat new file mode 100644 index 0000000000..205991fd3d --- /dev/null +++ b/pype/settings/defaults/system_settings/launchers/windows/mayapy2016.bat @@ -0,0 +1,13 @@ +@echo off + +set __app__="Mayapy 2016" +set __exe__="C:\Program Files\Autodesk\Maya2016\bin\mayapy.exe" +if not exist %__exe__% goto :missing_app + +call %__exe__% %* + +goto :eof + +:missing_app + echo ERROR: %__app__% not found at %__exe__% + exit /B 1 diff --git a/pype/settings/defaults/system_settings/launchers/windows/mayapy2017.bat b/pype/settings/defaults/system_settings/launchers/windows/mayapy2017.bat new file mode 100644 index 0000000000..14aacc5a7f --- /dev/null +++ b/pype/settings/defaults/system_settings/launchers/windows/mayapy2017.bat @@ -0,0 +1,13 @@ +@echo off + +set __app__="Mayapy 2017" +set __exe__="C:\Program Files\Autodesk\Maya2017\bin\mayapy.exe" +if not exist %__exe__% goto :missing_app + +call %__exe__% %* + +goto :eof + +:missing_app + echo ERROR: %__app__% not found at %__exe__% + exit /B 1 diff --git a/pype/settings/defaults/system_settings/launchers/windows/mayapy2018.bat b/pype/settings/defaults/system_settings/launchers/windows/mayapy2018.bat new file mode 100644 index 0000000000..c47c472f46 --- /dev/null +++ b/pype/settings/defaults/system_settings/launchers/windows/mayapy2018.bat @@ -0,0 +1,13 @@ +@echo off + +set __app__="Mayapy 2018" +set __exe__="C:\Program Files\Autodesk\Maya2018\bin\mayapy.exe" +if not exist %__exe__% goto :missing_app + +call %__exe__% %* + +goto :eof + +:missing_app + echo ERROR: %__app__% not found at %__exe__% + exit /B 1 diff --git a/pype/settings/defaults/system_settings/launchers/windows/mayapy2019.bat b/pype/settings/defaults/system_settings/launchers/windows/mayapy2019.bat new file mode 100644 index 0000000000..73ca5b2d40 --- /dev/null +++ b/pype/settings/defaults/system_settings/launchers/windows/mayapy2019.bat @@ -0,0 +1,13 @@ +@echo off + +set __app__="Mayapy 2019" +set __exe__="C:\Program Files\Autodesk\Maya2019\bin\mayapy.exe" +if not exist %__exe__% goto :missing_app + +call %__exe__% %* + +goto :eof + +:missing_app + echo ERROR: %__app__% not found at %__exe__% + exit /B 1 diff --git a/pype/settings/defaults/system_settings/launchers/windows/mayapy2020.bat b/pype/settings/defaults/system_settings/launchers/windows/mayapy2020.bat new file mode 100644 index 0000000000..770a03dcf5 --- /dev/null +++ b/pype/settings/defaults/system_settings/launchers/windows/mayapy2020.bat @@ -0,0 +1,13 @@ +@echo off + +set __app__="Mayapy 2020" +set __exe__="C:\Program Files\Autodesk\Maya2020\bin\mayapy.exe" +if not exist %__exe__% goto :missing_app + +call %__exe__% %* + +goto :eofS + +:missing_app + echo ERROR: %__app__% not found at %__exe__% + exit /B 1 diff --git a/pype/settings/defaults/system_settings/launchers/windows/nuke10.0.bat b/pype/settings/defaults/system_settings/launchers/windows/nuke10.0.bat new file mode 100644 index 0000000000..a47cbdfb20 --- /dev/null +++ b/pype/settings/defaults/system_settings/launchers/windows/nuke10.0.bat @@ -0,0 +1,13 @@ +@echo off + +set __app__="Nuke10.0v4" +set __exe__="C:\Program Files\Nuke10.0v4\Nuke10.0.exe" +if not exist %__exe__% goto :missing_app + +start %__app__% %__exe__% %* + +goto :eof + +:missing_app + echo ERROR: %__app__% not found in %__exe__% + exit /B 1 diff --git a/pype/settings/defaults/system_settings/launchers/windows/nuke11.0.bat b/pype/settings/defaults/system_settings/launchers/windows/nuke11.0.bat new file mode 100644 index 0000000000..a374c5cf5b --- /dev/null +++ b/pype/settings/defaults/system_settings/launchers/windows/nuke11.0.bat @@ -0,0 +1,13 @@ +@echo off + +set __app__="Nuke11.0v4" +set __exe__="C:\Program Files\Nuke11.0v4\Nuke11.0.exe" +if not exist %__exe__% goto :missing_app + +start %__app__% %__exe__% %* + +goto :eof + +:missing_app + echo ERROR: %__app__% not found in %__exe__% + exit /B 1 diff --git a/pype/settings/defaults/system_settings/launchers/windows/nuke11.2.bat b/pype/settings/defaults/system_settings/launchers/windows/nuke11.2.bat new file mode 100644 index 0000000000..4c777ac28c --- /dev/null +++ b/pype/settings/defaults/system_settings/launchers/windows/nuke11.2.bat @@ -0,0 +1,13 @@ +@echo off + +set __app__="Nuke11.2v3" +set __exe__="C:\Program Files\Nuke11.2v3\Nuke11.2.exe" +if not exist %__exe__% goto :missing_app + +start %__app__% %__exe__% %* + +goto :eof + +:missing_app + echo ERROR: %__app__% not found in %__exe__% + exit /B 1 diff --git a/pype/settings/defaults/system_settings/launchers/windows/nuke11.3.bat b/pype/settings/defaults/system_settings/launchers/windows/nuke11.3.bat new file mode 100644 index 0000000000..a023f5f46f --- /dev/null +++ b/pype/settings/defaults/system_settings/launchers/windows/nuke11.3.bat @@ -0,0 +1,13 @@ +@echo off + +set __app__="Nuke11.3v1" +set __exe__="C:\Program Files\Nuke11.3v1\Nuke11.3.exe" +if not exist %__exe__% goto :missing_app + +start %__app__% %__exe__% %* + +goto :eof + +:missing_app + echo ERROR: %__app__% not found in %__exe__% + exit /B 1 diff --git a/pype/settings/defaults/system_settings/launchers/windows/nuke12.0.bat b/pype/settings/defaults/system_settings/launchers/windows/nuke12.0.bat new file mode 100644 index 0000000000..d8fb5772bb --- /dev/null +++ b/pype/settings/defaults/system_settings/launchers/windows/nuke12.0.bat @@ -0,0 +1,13 @@ +@echo off + +set __app__="Nuke12.0v1" +set __exe__="C:\Program Files\Nuke12.0v1\Nuke12.0.exe" +if not exist %__exe__% goto :missing_app + +start %__app__% %__exe__% %* + +goto :eof + +:missing_app + echo ERROR: %__app__% not found in %__exe__% + exit /B 1 diff --git a/pype/settings/defaults/system_settings/launchers/windows/nukestudio10.0.bat b/pype/settings/defaults/system_settings/launchers/windows/nukestudio10.0.bat new file mode 100644 index 0000000000..82f833667c --- /dev/null +++ b/pype/settings/defaults/system_settings/launchers/windows/nukestudio10.0.bat @@ -0,0 +1,13 @@ +@echo off + +set __app__="NukeStudio10.0v4" +set __exe__="C:\Program Files\Nuke10.0v4\Nuke10.0.exe" --studio +if not exist %__exe__% goto :missing_app + +start %__app__% %__exe__% %* + +goto :eof + +:missing_app + echo ERROR: %__app__% not found in %__exe__% + exit /B 1 diff --git a/pype/settings/defaults/system_settings/launchers/windows/nukestudio11.0.bat b/pype/settings/defaults/system_settings/launchers/windows/nukestudio11.0.bat new file mode 100644 index 0000000000..b66797727e --- /dev/null +++ b/pype/settings/defaults/system_settings/launchers/windows/nukestudio11.0.bat @@ -0,0 +1,13 @@ +@echo off + +set __app__="NukeStudio11.0v4" +set __exe__="C:\Program Files\Nuke11.0v4\Nuke11.0.exe" -studio +if not exist %__exe__% goto :missing_app + +start %__app__% %__exe__% %* + +goto :eof + +:missing_app + echo ERROR: %__app__% not found in %__exe__% + exit /B 1 diff --git a/pype/settings/defaults/system_settings/launchers/windows/nukestudio11.2.bat b/pype/settings/defaults/system_settings/launchers/windows/nukestudio11.2.bat new file mode 100644 index 0000000000..a653d816b4 --- /dev/null +++ b/pype/settings/defaults/system_settings/launchers/windows/nukestudio11.2.bat @@ -0,0 +1,13 @@ +@echo off + +set __app__="NukeStudio11.2v3" +set __exe__="C:\Program Files\Nuke11.2v3\Nuke11.2.exe" -studio +if not exist %__exe__% goto :missing_app + +start %__app__% %__exe__% %* + +goto :eof + +:missing_app + echo ERROR: %__app__% not found in %__exe__% + exit /B 1 diff --git a/pype/settings/defaults/system_settings/launchers/windows/nukestudio11.3.bat b/pype/settings/defaults/system_settings/launchers/windows/nukestudio11.3.bat new file mode 100644 index 0000000000..62c8718873 --- /dev/null +++ b/pype/settings/defaults/system_settings/launchers/windows/nukestudio11.3.bat @@ -0,0 +1,13 @@ +@echo off + +set __app__="NukeStudio11.3v1" +set __exe__="C:\Program Files\Nuke11.3v1\Nuke11.3.exe" --studio +if not exist %__exe__% goto :missing_app + +start %__app__% %__exe__% %* + +goto :eof + +:missing_app + echo ERROR: %__app__% not found in %__exe__% + exit /B 1 diff --git a/pype/settings/defaults/system_settings/launchers/windows/nukestudio12.0.bat b/pype/settings/defaults/system_settings/launchers/windows/nukestudio12.0.bat new file mode 100644 index 0000000000..488232bcbf --- /dev/null +++ b/pype/settings/defaults/system_settings/launchers/windows/nukestudio12.0.bat @@ -0,0 +1,13 @@ +@echo off + +set __app__="NukeStudio12.0v1" +set __exe__="C:\Program Files\Nuke12.0v1\Nuke12.0.exe" --studio +if not exist %__exe__% goto :missing_app + +start %__app__% %__exe__% %* + +goto :eof + +:missing_app + echo ERROR: %__app__% not found in %__exe__% + exit /B 1 diff --git a/pype/settings/defaults/system_settings/launchers/windows/nukex10.0.bat b/pype/settings/defaults/system_settings/launchers/windows/nukex10.0.bat new file mode 100644 index 0000000000..1759706a7b --- /dev/null +++ b/pype/settings/defaults/system_settings/launchers/windows/nukex10.0.bat @@ -0,0 +1,13 @@ +@echo off + +set __app__="NukeX10.0v4" +set __exe__="C:\Program Files\Nuke10.0v4\Nuke10.0.exe" -nukex +if not exist %__exe__% goto :missing_app + +start %__app__% %__exe__% %* + +goto :eof + +:missing_app + echo ERROR: %__app__% not found in %__exe__% + exit /B 1 diff --git a/pype/settings/defaults/system_settings/launchers/windows/nukex11.0.bat b/pype/settings/defaults/system_settings/launchers/windows/nukex11.0.bat new file mode 100644 index 0000000000..b554a7b6fa --- /dev/null +++ b/pype/settings/defaults/system_settings/launchers/windows/nukex11.0.bat @@ -0,0 +1,13 @@ +@echo off + +set __app__="NukeX11.0v4" +set __exe__="C:\Program Files\Nuke11.0v4\Nuke11.0.exe" --nukex +if not exist %__exe__% goto :missing_app + +start %__app__% %__exe__% %* + +goto :eof + +:missing_app + echo ERROR: %__app__% not found in %__exe__% + exit /B 1 diff --git a/pype/settings/defaults/system_settings/launchers/windows/nukex11.2.bat b/pype/settings/defaults/system_settings/launchers/windows/nukex11.2.bat new file mode 100644 index 0000000000..a4cb5dec5c --- /dev/null +++ b/pype/settings/defaults/system_settings/launchers/windows/nukex11.2.bat @@ -0,0 +1,13 @@ +@echo off + +set __app__="NukeX11.2v3" +set __exe__="C:\Program Files\Nuke11.2v3\Nuke11.2.exe" --nukex +if not exist %__exe__% goto :missing_app + +start %__app__% %__exe__% %* + +goto :eof + +:missing_app + echo ERROR: %__app__% not found in %__exe__% + exit /B 1 diff --git a/pype/settings/defaults/system_settings/launchers/windows/nukex11.3.bat b/pype/settings/defaults/system_settings/launchers/windows/nukex11.3.bat new file mode 100644 index 0000000000..490b55cf4c --- /dev/null +++ b/pype/settings/defaults/system_settings/launchers/windows/nukex11.3.bat @@ -0,0 +1,13 @@ +@echo off + +set __app__="NukeX11.3v1" +set __exe__="C:\Program Files\Nuke11.3v1\Nuke11.3.exe" --nukex +if not exist %__exe__% goto :missing_app + +start %__app__% %__exe__% %* + +goto :eof + +:missing_app + echo ERROR: %__app__% not found in %__exe__% + exit /B 1 diff --git a/pype/settings/defaults/system_settings/launchers/windows/nukex12.0.bat b/pype/settings/defaults/system_settings/launchers/windows/nukex12.0.bat new file mode 100644 index 0000000000..26adf0d3f1 --- /dev/null +++ b/pype/settings/defaults/system_settings/launchers/windows/nukex12.0.bat @@ -0,0 +1,13 @@ +@echo off + +set __app__="NukeX12.0v1" +set __exe__="C:\Program Files\Nuke12.0v1\Nuke12.0.exe" --nukex +if not exist %__exe__% goto :missing_app + +start %__app__% %__exe__% %* + +goto :eof + +:missing_app + echo ERROR: %__app__% not found in %__exe__% + exit /B 1 diff --git a/pype/settings/defaults/system_settings/launchers/windows/photoshop_2020.bat b/pype/settings/defaults/system_settings/launchers/windows/photoshop_2020.bat new file mode 100644 index 0000000000..6b90922ef6 --- /dev/null +++ b/pype/settings/defaults/system_settings/launchers/windows/photoshop_2020.bat @@ -0,0 +1,15 @@ +@echo off + +set __app__="Photoshop 2020" +set __exe__="C:\Program Files\Adobe\Adobe Photoshop 2020\Photoshop.exe" +if not exist %__exe__% goto :missing_app + +start %__app__% cmd.exe /k "%PYPE_PYTHON_EXE% -c ^"import avalon.photoshop;avalon.photoshop.launch("%__exe__%")^"" + +goto :eof + +pause + +:missing_app + echo ERROR: %__app__% not found in %__exe__% + exit /B 1 diff --git a/pype/settings/defaults/system_settings/launchers/windows/premiere_pro_2019.bat b/pype/settings/defaults/system_settings/launchers/windows/premiere_pro_2019.bat new file mode 100644 index 0000000000..4886737d2f --- /dev/null +++ b/pype/settings/defaults/system_settings/launchers/windows/premiere_pro_2019.bat @@ -0,0 +1,14 @@ +@echo off + +set __app__="Adobe Premiere Pro" +set __exe__="C:\Program Files\Adobe\Adobe Premiere Pro CC 2019\Adobe Premiere Pro.exe" +if not exist %__exe__% goto :missing_app + +python -u %PREMIERA_PATH%\init.py +start %__app__% %__exe__% %* + +goto :eof + +:missing_app + echo ERROR: %__app__% not found in %__exe__% + exit /B 1 diff --git a/pype/settings/defaults/system_settings/launchers/windows/premiere_pro_2020.bat b/pype/settings/defaults/system_settings/launchers/windows/premiere_pro_2020.bat new file mode 100644 index 0000000000..14662d3be3 --- /dev/null +++ b/pype/settings/defaults/system_settings/launchers/windows/premiere_pro_2020.bat @@ -0,0 +1,13 @@ +@echo off + +set __app__="Adobe Premiere Pro" +set __exe__="C:\Program Files\Adobe\Adobe Premiere Pro 2020\Adobe Premiere Pro.exe" +if not exist %__exe__% goto :missing_app + +start %__app__% %__exe__% %* + +goto :eof + +:missing_app + echo ERROR: %__app__% not found in %__exe__% + exit /B 1 diff --git a/pype/settings/defaults/system_settings/launchers/windows/python3.bat b/pype/settings/defaults/system_settings/launchers/windows/python3.bat new file mode 100644 index 0000000000..c7c116fe72 --- /dev/null +++ b/pype/settings/defaults/system_settings/launchers/windows/python3.bat @@ -0,0 +1,13 @@ +@echo off + +set __app__="Python36" +set __exe__="C:\Python36\python.exe" +if not exist %__exe__% goto :missing_app + +start %__app__% %__exe__% %* + +goto :eof + +:missing_app + echo ERROR: %__app__% not found in %__exe__% + exit /B 1 diff --git a/pype/settings/defaults/system_settings/launchers/windows/resolve_16.bat b/pype/settings/defaults/system_settings/launchers/windows/resolve_16.bat new file mode 100644 index 0000000000..1a5d964e6b --- /dev/null +++ b/pype/settings/defaults/system_settings/launchers/windows/resolve_16.bat @@ -0,0 +1,17 @@ +@echo off + +set __app__="Resolve" +set __appy__="Resolve Python Console" +set __exe__="C:/Program Files/Blackmagic Design/DaVinci Resolve/Resolve.exe" +set __py__="%PYTHON36_RESOLVE%/python.exe" + +if not exist %__exe__% goto :missing_app + +start %__app__% %__exe__% %* +IF "%RESOLVE_DEV%"=="True" (start %__appy__% %__py__% -i %PRE_PYTHON_SCRIPT%) + +goto :eof + +:missing_app + echo ERROR: %__app__% not found in %__exe__% + exit /B 1 diff --git a/pype/settings/defaults/system_settings/launchers/windows/shell.bat b/pype/settings/defaults/system_settings/launchers/windows/shell.bat new file mode 100644 index 0000000000..eb0895364f --- /dev/null +++ b/pype/settings/defaults/system_settings/launchers/windows/shell.bat @@ -0,0 +1,2 @@ +@echo off +start cmd diff --git a/pype/settings/defaults/system_settings/launchers/windows/storyboardpro_7.bat b/pype/settings/defaults/system_settings/launchers/windows/storyboardpro_7.bat new file mode 100644 index 0000000000..122edac572 --- /dev/null +++ b/pype/settings/defaults/system_settings/launchers/windows/storyboardpro_7.bat @@ -0,0 +1,13 @@ +@echo off + +set __app__="Storyboard Pro 7" +set __exe__="C:/Program Files (x86)/Toon Boom Animation/Toon Boom Storyboard Pro 7/win64/bin/StoryboardPro.exe" +if not exist %__exe__% goto :missing_app + +start %__app__% cmd.exe /k "python -c ^"import avalon.storyboardpro;avalon.storyboardpro.launch("%__exe__%")^"" + +goto :eof + +:missing_app + echo ERROR: %__app__% not found in %__exe__% + exit /B 1 diff --git a/pype/settings/defaults/system_settings/launchers/windows/unreal.bat b/pype/settings/defaults/system_settings/launchers/windows/unreal.bat new file mode 100644 index 0000000000..7771aaa5a5 --- /dev/null +++ b/pype/settings/defaults/system_settings/launchers/windows/unreal.bat @@ -0,0 +1,11 @@ +set __app__="Unreal Editor" +set __exe__="%AVALON_CURRENT_UNREAL_ENGINE%\Engine\Binaries\Win64\UE4Editor.exe" +if not exist %__exe__% goto :missing_app + +start %__app__% %__exe__% %PYPE_UNREAL_PROJECT_FILE% %* + +goto :eof + +:missing_app + echo ERROR: %__app__% not found in %__exe__% + exit /B 1 diff --git a/pype/settings/defaults/system_settings/muster/templates_mapping.json b/pype/settings/defaults/system_settings/muster/templates_mapping.json new file mode 100644 index 0000000000..0c09113515 --- /dev/null +++ b/pype/settings/defaults/system_settings/muster/templates_mapping.json @@ -0,0 +1,19 @@ +{ + "3delight": 41, + "arnold": 46, + "arnold_sf": 57, + "gelato": 30, + "harware": 3, + "krakatoa": 51, + "file_layers": 7, + "mentalray": 2, + "mentalray_sf": 6, + "redshift": 55, + "renderman": 29, + "software": 1, + "software_sf": 5, + "turtle": 10, + "vector": 4, + "vray": 37, + "ffmpeg": 48 +} \ No newline at end of file diff --git a/pype/settings/defaults/system_settings/standalone_publish/families.json b/pype/settings/defaults/system_settings/standalone_publish/families.json new file mode 100644 index 0000000000..d05941cc26 --- /dev/null +++ b/pype/settings/defaults/system_settings/standalone_publish/families.json @@ -0,0 +1,90 @@ +{ + "create_look": { + "name": "look", + "label": "Look", + "family": "look", + "icon": "paint-brush", + "defaults": ["Main"], + "help": "Shader connections defining shape look" + }, + "create_model": { + "name": "model", + "label": "Model", + "family": "model", + "icon": "cube", + "defaults": ["Main", "Proxy", "Sculpt"], + "help": "Polygonal static geometry" + }, + "create_workfile": { + "name": "workfile", + "label": "Workfile", + "family": "workfile", + "icon": "cube", + "defaults": ["Main"], + "help": "Working scene backup" + }, + "create_camera": { + "name": "camera", + "label": "Camera", + "family": "camera", + "icon": "video-camera", + "defaults": ["Main"], + "help": "Single baked camera" + }, + "create_pointcache": { + "name": "pointcache", + "label": "Pointcache", + "family": "pointcache", + "icon": "gears", + "defaults": ["Main"], + "help": "Alembic pointcache for animated data" + }, + "create_rig": { + "name": "rig", + "label": "Rig", + "family": "rig", + "icon": "wheelchair", + "defaults": ["Main"], + "help": "Artist-friendly rig with controls" + }, + "create_layout": { + "name": "layout", + "label": "Layout", + "family": "layout", + "icon": "cubes", + "defaults": ["Main"], + "help": "Simple scene for animators with camera" + }, + "create_plate": { + "name": "plate", + "label": "Plate", + "family": "plate", + "icon": "camera", + "defaults": ["Main", "BG", "Reference"], + "help": "Plates for compositors" + }, + "create_matchmove": { + "name": "matchmove", + "label": "Matchmove script", + "family": "matchmove", + "icon": "empire", + "defaults": ["Camera", "Object", "Mocap"], + "help": "Script exported from matchmoving application" + }, + "create_images": { + "name": "image", + "label": "Image file", + "family": "image", + "icon": "image", + "defaults": ["ConceptArt", "Reference", "Texture", "MattePaint"], + "help": "Holder for all kinds of image data" + }, + "create_editorial": { + "name": "editorial", + "label": "Editorial", + "family": "editorial", + "icon": "image", + "defaults": ["Main"], + "help": "Editorial files to generate shots." + } +} diff --git a/pype/settings/lib.py b/pype/settings/lib.py new file mode 100644 index 0000000000..388557ca9b --- /dev/null +++ b/pype/settings/lib.py @@ -0,0 +1,258 @@ +import os +import json +import logging +import copy + +log = logging.getLogger(__name__) + +# Metadata keys for work with studio and project overrides +OVERRIDEN_KEY = "__overriden_keys__" +# NOTE key popping not implemented yet +POP_KEY = "__pop_key__" + +# Folder where studio overrides are stored +STUDIO_OVERRIDES_PATH = os.environ["PYPE_PROJECT_CONFIGS"] + +# File where studio's system overrides are stored +SYSTEM_SETTINGS_KEY = "system_settings" +SYSTEM_SETTINGS_PATH = os.path.join( + STUDIO_OVERRIDES_PATH, SYSTEM_SETTINGS_KEY + ".json" +) + +# File where studio's default project overrides are stored +PROJECT_SETTINGS_KEY = "project_settings" +PROJECT_SETTINGS_FILENAME = PROJECT_SETTINGS_KEY + ".json" +PROJECT_SETTINGS_PATH = os.path.join( + STUDIO_OVERRIDES_PATH, PROJECT_SETTINGS_FILENAME +) + +PROJECT_ANATOMY_KEY = "project_anatomy" +PROJECT_ANATOMY_FILENAME = PROJECT_ANATOMY_KEY + ".json" +PROJECT_ANATOMY_PATH = os.path.join( + STUDIO_OVERRIDES_PATH, PROJECT_ANATOMY_FILENAME +) + +# Path to default settings +DEFAULTS_DIR = os.path.join(os.path.dirname(__file__), "defaults") + +# Variable where cache of default settings are stored +_DEFAULT_SETTINGS = None + + +def reset_default_settings(): + global _DEFAULT_SETTINGS + _DEFAULT_SETTINGS = None + + +def default_settings(): + global _DEFAULT_SETTINGS + if _DEFAULT_SETTINGS is None: + _DEFAULT_SETTINGS = load_jsons_from_dir(DEFAULTS_DIR) + return _DEFAULT_SETTINGS + + +def load_json(fpath): + # Load json data + with open(fpath, "r") as opened_file: + lines = opened_file.read().splitlines() + + # prepare json string + standard_json = "" + for line in lines: + # Remove all whitespace on both sides + line = line.strip() + + # Skip blank lines + if len(line) == 0: + continue + + standard_json += line + + # Check if has extra commas + extra_comma = False + if ",]" in standard_json or ",}" in standard_json: + extra_comma = True + standard_json = standard_json.replace(",]", "]") + standard_json = standard_json.replace(",}", "}") + + if extra_comma: + log.error("Extra comma in json file: \"{}\"".format(fpath)) + + # return empty dict if file is empty + if standard_json == "": + return {} + + # Try to parse string + try: + return json.loads(standard_json) + + except json.decoder.JSONDecodeError: + # Return empty dict if it is first time that decode error happened + return {} + + # Repreduce the exact same exception but traceback contains better + # information about position of error in the loaded json + try: + with open(fpath, "r") as opened_file: + json.load(opened_file) + + except json.decoder.JSONDecodeError: + log.warning( + "File has invalid json format \"{}\"".format(fpath), + exc_info=True + ) + + return {} + + +def subkey_merge(_dict, value, keys): + key = keys.pop(0) + if not keys: + _dict[key] = value + return _dict + + if key not in _dict: + _dict[key] = {} + _dict[key] = subkey_merge(_dict[key], value, keys) + + return _dict + + +def load_jsons_from_dir(path, *args, **kwargs): + output = {} + + path = os.path.normpath(path) + if not os.path.exists(path): + # TODO warning + return output + + sub_keys = list(kwargs.pop("subkeys", args)) + for sub_key in tuple(sub_keys): + _path = os.path.join(path, sub_key) + if not os.path.exists(_path): + break + + path = _path + sub_keys.pop(0) + + base_len = len(path) + 1 + for base, _directories, filenames in os.walk(path): + base_items_str = base[base_len:] + if not base_items_str: + base_items = [] + else: + base_items = base_items_str.split(os.path.sep) + + for filename in filenames: + basename, ext = os.path.splitext(filename) + if ext == ".json": + full_path = os.path.join(base, filename) + value = load_json(full_path) + dict_keys = base_items + [basename] + output = subkey_merge(output, value, dict_keys) + + for sub_key in sub_keys: + output = output[sub_key] + return output + + +def studio_system_settings(): + if os.path.exists(SYSTEM_SETTINGS_PATH): + return load_json(SYSTEM_SETTINGS_PATH) + return {} + + +def studio_project_settings(): + if os.path.exists(PROJECT_SETTINGS_PATH): + return load_json(PROJECT_SETTINGS_PATH) + return {} + + +def studio_project_anatomy(): + if os.path.exists(PROJECT_ANATOMY_PATH): + return load_json(PROJECT_ANATOMY_PATH) + return {} + + +def path_to_project_overrides(project_name): + return os.path.join( + STUDIO_OVERRIDES_PATH, + project_name, + PROJECT_SETTINGS_FILENAME + ) + + +def path_to_project_anatomy(project_name): + return os.path.join( + STUDIO_OVERRIDES_PATH, + project_name, + PROJECT_ANATOMY_FILENAME + ) + + +def project_settings_overrides(project_name): + if not project_name: + return {} + + path_to_json = path_to_project_overrides(project_name) + if not os.path.exists(path_to_json): + return {} + return load_json(path_to_json) + + +def project_anatomy_overrides(project_name): + if not project_name: + return {} + + path_to_json = path_to_project_anatomy(project_name) + if not os.path.exists(path_to_json): + return {} + return load_json(path_to_json) + + +def merge_overrides(global_dict, override_dict): + if OVERRIDEN_KEY in override_dict: + overriden_keys = set(override_dict.pop(OVERRIDEN_KEY)) + else: + overriden_keys = set() + + for key, value in override_dict.items(): + if value == POP_KEY: + global_dict.pop(key) + + elif ( + key in overriden_keys + or key not in global_dict + ): + global_dict[key] = value + + elif isinstance(value, dict) and isinstance(global_dict[key], dict): + global_dict[key] = merge_overrides(global_dict[key], value) + + else: + global_dict[key] = value + return global_dict + + +def apply_overrides(source_data, override_data): + if not override_data: + return source_data + _source_data = copy.deepcopy(source_data) + return merge_overrides(_source_data, override_data) + + +def system_settings(): + default_values = default_settings()[SYSTEM_SETTINGS_KEY] + studio_values = studio_system_settings() + return apply_overrides(default_values, studio_values) + + +def project_settings(project_name): + default_values = default_settings()[PROJECT_SETTINGS_KEY] + studio_values = studio_project_settings() + + studio_overrides = apply_overrides(default_values, studio_values) + + project_overrides = project_settings_overrides(project_name) + + return apply_overrides(studio_overrides, project_overrides) diff --git a/pype/tools/launcher/window.py b/pype/tools/launcher/window.py index 13b4abee6e..7c680a927b 100644 --- a/pype/tools/launcher/window.py +++ b/pype/tools/launcher/window.py @@ -4,7 +4,7 @@ import logging from Qt import QtWidgets, QtCore, QtGui from avalon import style -from pype.modules.ftrack.lib.io_nonsingleton import DbConnector +from avalon.api import AvalonMongoDB from pype.api import resources from avalon.tools import lib as tools_lib @@ -251,7 +251,7 @@ class LauncherWindow(QtWidgets.QDialog): self.log = logging.getLogger( ".".join([__name__, self.__class__.__name__]) ) - self.dbcon = DbConnector() + self.dbcon = AvalonMongoDB() self.setWindowTitle("Launcher") self.setFocusPolicy(QtCore.Qt.StrongFocus) diff --git a/pype/tools/pyblish_pype/app.css b/pype/tools/pyblish_pype/app.css index 3a2c05c1f3..c51126e89f 100644 --- a/pype/tools/pyblish_pype/app.css +++ b/pype/tools/pyblish_pype/app.css @@ -459,7 +459,7 @@ QToolButton { color: #fff; } -#TerminalFilerBtn { +#TerminalFilterWidget QPushButton { /* font: %(font_size_pt)spt; */ font-family: "FontAwesome"; text-align: center; @@ -468,29 +468,58 @@ QToolButton { border-color: #777777; border-style: none; padding: 0px; - border-radius: 3px; + border-radius: 8px; +} +#TerminalFilterWidget QPushButton:hover { + background: #5f5f5f; + border-style: none; +} +#TerminalFilterWidget QPushButton:pressed { + background: #606060; + border-style: none; +} +#TerminalFilterWidget QPushButton:pressed:hover { + background: #626262; + border-style: none; } #TerminalFilerBtn[type="info"]:checked {color: rgb(255, 255, 255);} +#TerminalFilerBtn[type="info"]:hover:pressed {color: rgba(255, 255, 255, 163);} #TerminalFilerBtn[type="info"] {color: rgba(255, 255, 255, 63);} #TerminalFilerBtn[type="error"]:checked {color: rgb(255, 74, 74);} +#TerminalFilerBtn[type="error"]:hover:pressed {color: rgba(255, 74, 74, 163);} #TerminalFilerBtn[type="error"] {color: rgba(255, 74, 74, 63);} #TerminalFilerBtn[type="log_debug"]:checked {color: rgb(255, 102, 232);} #TerminalFilerBtn[type="log_debug"] {color: rgba(255, 102, 232, 63);} +#TerminalFilerBtn[type="log_debug"]:hover:pressed { + color: rgba(255, 102, 232, 163); +} #TerminalFilerBtn[type="log_info"]:checked {color: rgb(102, 171, 255);} #TerminalFilerBtn[type="log_info"] {color: rgba(102, 171, 255, 63);} +#TerminalFilerBtn[type="log_info"]:hover:pressed { + color: rgba(102, 171, 255, 163); +} #TerminalFilerBtn[type="log_warning"]:checked {color: rgb(255, 186, 102);} #TerminalFilerBtn[type="log_warning"] {color: rgba(255, 186, 102, 63);} +#TerminalFilerBtn[type="log_warning"]:hover:pressed { + color: rgba(255, 186, 102, 163); +} #TerminalFilerBtn[type="log_error"]:checked {color: rgb(255, 77, 88);} #TerminalFilerBtn[type="log_error"] {color: rgba(255, 77, 88, 63);} +#TerminalFilerBtn[type="log_error"]:hover:pressed { + color: rgba(255, 77, 88, 163); +} #TerminalFilerBtn[type="log_critical"]:checked {color: rgb(255, 79, 117);} #TerminalFilerBtn[type="log_critical"] {color: rgba(255, 79, 117, 63);} +#TerminalFilerBtn[type="log_critical"]:hover:pressed { + color: rgba(255, 79, 117, 163); +} #SuspendLogsBtn { background: #444; diff --git a/pype/tools/pyblish_pype/app.py b/pype/tools/pyblish_pype/app.py index 0f662d5b3e..9879c63030 100644 --- a/pype/tools/pyblish_pype/app.py +++ b/pype/tools/pyblish_pype/app.py @@ -92,7 +92,6 @@ def show(parent=None): self._window.show() self._window.activateWindow() - self._window.resize(*settings.WindowSize) self._window.setWindowTitle(settings.WindowTitle) font = QtGui.QFont("Open Sans", 8, QtGui.QFont.Normal) @@ -100,5 +99,6 @@ def show(parent=None): self._window.setStyleSheet(css) self._window.reset() + self._window.resize(*settings.WindowSize) return self._window diff --git a/pype/tools/pyblish_pype/model.py b/pype/tools/pyblish_pype/model.py index fdcdffd33f..1482ff85b0 100644 --- a/pype/tools/pyblish_pype/model.py +++ b/pype/tools/pyblish_pype/model.py @@ -870,13 +870,18 @@ class ArtistProxy(QtCore.QAbstractProxyModel): self.rowsInserted.emit(self.parent(), new_from, new_to + 1) def _remove_rows(self, parent_row, from_row, to_row): - removed_rows = [] increment_num = self.mapping_from[parent_row][from_row] + + to_end_index = len(self.mapping_from[parent_row]) - 1 + for _idx in range(0, parent_row): + to_end_index += len(self.mapping_from[_idx]) + + removed_rows = 0 _emit_last = None for row_num in reversed(range(from_row, to_row + 1)): row = self.mapping_from[parent_row].pop(row_num) _emit_last = row - removed_rows.append(row) + removed_rows += 1 _emit_first = int(increment_num) mapping_from_len = len(self.mapping_from) @@ -896,11 +901,8 @@ class ArtistProxy(QtCore.QAbstractProxyModel): self.mapping_from[idx_i][idx_j] = increment_num increment_num += 1 - first_to_row = None - for row in removed_rows: - if first_to_row is None: - first_to_row = row - self.mapping_to.pop(row) + for idx in range(removed_rows): + self.mapping_to.pop(to_end_index - idx) return (_emit_first, _emit_last) @@ -1145,48 +1147,52 @@ class TerminalModel(QtGui.QStandardItemModel): return prepared_records - def append(self, record_item): - record_type = record_item["type"] + def append(self, record_items): + all_record_items = [] + for record_item in record_items: + record_type = record_item["type"] - terminal_item_type = None - if record_type == "record": - for level, _type in self.level_to_record: - if level > record_item["levelno"]: - break - terminal_item_type = _type + terminal_item_type = None + if record_type == "record": + for level, _type in self.level_to_record: + if level > record_item["levelno"]: + break + terminal_item_type = _type - else: - terminal_item_type = record_type + else: + terminal_item_type = record_type - icon_color = self.item_icon_colors.get(terminal_item_type) - icon_name = self.item_icon_name.get(record_type) + icon_color = self.item_icon_colors.get(terminal_item_type) + icon_name = self.item_icon_name.get(record_type) - top_item_icon = None - if icon_color and icon_name: - top_item_icon = QAwesomeIconFactory.icon(icon_name, icon_color) + top_item_icon = None + if icon_color and icon_name: + top_item_icon = QAwesomeIconFactory.icon(icon_name, icon_color) - label = record_item["label"].split("\n")[0] + label = record_item["label"].split("\n")[0] - top_item = QtGui.QStandardItem() - top_item.setData(TerminalLabelType, Roles.TypeRole) - top_item.setData(terminal_item_type, Roles.TerminalItemTypeRole) - top_item.setData(label, QtCore.Qt.DisplayRole) - top_item.setFlags( - QtCore.Qt.ItemIsSelectable | QtCore.Qt.ItemIsEnabled - ) + top_item = QtGui.QStandardItem() + all_record_items.append(top_item) - if top_item_icon: - top_item.setData(top_item_icon, QtCore.Qt.DecorationRole) + detail_item = TerminalDetailItem(record_item) + top_item.appendRow(detail_item) - self.appendRow(top_item) + top_item.setData(TerminalLabelType, Roles.TypeRole) + top_item.setData(terminal_item_type, Roles.TerminalItemTypeRole) + top_item.setData(label, QtCore.Qt.DisplayRole) + top_item.setFlags( + QtCore.Qt.ItemIsSelectable | QtCore.Qt.ItemIsEnabled + ) - detail_item = TerminalDetailItem(record_item) - detail_item.setData(TerminalDetailType, Roles.TypeRole) - top_item.appendRow(detail_item) + if top_item_icon: + top_item.setData(top_item_icon, QtCore.Qt.DecorationRole) + + detail_item.setData(TerminalDetailType, Roles.TypeRole) + + self.invisibleRootItem().appendRows(all_record_items) def update_with_result(self, result): - for record in result["records"]: - self.append(record) + self.append(result["records"]) class TerminalProxy(QtCore.QSortFilterProxyModel): diff --git a/pype/tools/pyblish_pype/view.py b/pype/tools/pyblish_pype/view.py index 477303eae8..248c1fbbf9 100644 --- a/pype/tools/pyblish_pype/view.py +++ b/pype/tools/pyblish_pype/view.py @@ -83,6 +83,7 @@ class OverviewView(QtWidgets.QTreeView): self.setHeaderHidden(True) self.setRootIsDecorated(False) self.setIndentation(0) + self.setAnimated(True) def event(self, event): if not event.type() == QtCore.QEvent.KeyPress: @@ -159,6 +160,8 @@ class InstanceView(OverviewView): def __init__(self, parent=None): super(InstanceView, self).__init__(parent) self.viewport().setMouseTracking(True) + self._pressed_group_index = None + self._pressed_expander = None def mouseMoveEvent(self, event): index = self.indexAt(event.pos()) @@ -176,6 +179,8 @@ class InstanceView(OverviewView): self.collapse(index) def group_toggle(self, index): + if not index.isValid(): + return model = index.model() chilren_indexes_checked = [] @@ -201,25 +206,90 @@ class InstanceView(OverviewView): model.setData(index, new_state, QtCore.Qt.CheckStateRole) self.toggled.emit(index, new_state) - def mouseReleaseEvent(self, event): - if event.button() == QtCore.Qt.LeftButton: + def _mouse_press(self, event): + if event.button() != QtCore.Qt.LeftButton: + return + + self._pressed_group_index = None + self._pressed_expander = None + + pos_index = self.indexAt(event.pos()) + if not pos_index.isValid(): + return + + if pos_index.data(Roles.TypeRole) != model.InstanceType: + self._pressed_group_index = pos_index + if event.pos().x() < 20: + self._pressed_expander = True + else: + self._pressed_expander = False + + elif event.pos().x() < 20: indexes = self.selectionModel().selectedIndexes() - if len(indexes) == 1: - index = indexes[0] - pos_index = self.indexAt(event.pos()) - if index == pos_index: - # If instance or Plugin - if index.data(Roles.TypeRole) == model.InstanceType: - if event.pos().x() < 20: - self.toggled.emit(index, None) - elif event.pos().x() > self.width() - 20: - self.show_perspective.emit(index) - else: - if event.pos().x() < EXPANDER_WIDTH: - self.item_expand(index) - else: - self.group_toggle(index) - self.item_expand(index, True) + any_checked = False + if len(indexes) <= 1: + return + + if pos_index in indexes: + for index in indexes: + if index.data(QtCore.Qt.CheckStateRole): + any_checked = True + break + + for index in indexes: + self.toggled.emit(index, not any_checked) + return True + self.toggled.emit(pos_index, not any_checked) + + def mousePressEvent(self, event): + if self._mouse_press(event): + return + return super(InstanceView, self).mousePressEvent(event) + + def _mouse_release(self, event, pressed_expander, pressed_index): + if event.button() != QtCore.Qt.LeftButton: + return + + pos_index = self.indexAt(event.pos()) + if not pos_index.isValid(): + return + + if pos_index.data(Roles.TypeRole) == model.InstanceType: + indexes = self.selectionModel().selectedIndexes() + if len(indexes) == 1 and indexes[0] == pos_index: + if event.pos().x() < 20: + self.toggled.emit(indexes[0], None) + elif event.pos().x() > self.width() - 20: + self.show_perspective.emit(indexes[0]) + return True + return + + if pressed_index != pos_index: + return + + if self.state() == QtWidgets.QTreeView.State.DragSelectingState: + indexes = self.selectionModel().selectedIndexes() + if len(indexes) != 1 or indexes[0] != pos_index: + return + + if event.pos().x() < EXPANDER_WIDTH: + if pressed_expander is True: + self.item_expand(pos_index) + return True + else: + if pressed_expander is False: + self.group_toggle(pos_index) + self.item_expand(pos_index, True) + return True + + def mouseReleaseEvent(self, event): + pressed_index = self._pressed_group_index + pressed_expander = self._pressed_expander is True + self._pressed_group_index = None + self._pressed_expander = None + result = self._mouse_release(event, pressed_expander, pressed_index) + if result: + return return super(InstanceView, self).mouseReleaseEvent(event) diff --git a/pype/tools/pyblish_pype/widgets.py b/pype/tools/pyblish_pype/widgets.py index 880d4755ad..4da759899e 100644 --- a/pype/tools/pyblish_pype/widgets.py +++ b/pype/tools/pyblish_pype/widgets.py @@ -6,7 +6,7 @@ from .constants import PluginStates, InstanceStates, Roles class EllidableLabel(QtWidgets.QLabel): def __init__(self, *args, **kwargs): - super(self.__class__, self).__init__(*args, **kwargs) + super(EllidableLabel, self).__init__(*args, **kwargs) self.setObjectName("EllidableLabel") def paintEvent(self, event): @@ -21,7 +21,7 @@ class EllidableLabel(QtWidgets.QLabel): class PerspectiveLabel(QtWidgets.QTextEdit): def __init__(self, parent=None): - super(self.__class__, self).__init__(parent) + super(PerspectiveLabel, self).__init__(parent) self.setObjectName("PerspectiveLabel") size_policy = self.sizePolicy() @@ -50,7 +50,7 @@ class PerspectiveLabel(QtWidgets.QTextEdit): return margins.top() + document.size().height() + margins.bottom() def sizeHint(self): - width = super(self.__class__, self).sizeHint().width() + width = super(PerspectiveLabel, self).sizeHint().width() return QtCore.QSize(width, self.heightForWidth(width)) @@ -328,7 +328,7 @@ class PerspectiveWidget(QtWidgets.QWidget): self.records.toggle_content(len_records > 0) def toggle_me(self): - self.parent_widget.toggle_perspective_widget() + self.parent_widget.parent().toggle_perspective_widget() class ClickableWidget(QtWidgets.QLabel): @@ -407,7 +407,7 @@ class ExpandableWidget(QtWidgets.QWidget): self.content_widget.setVisible(checked) def resizeEvent(self, event): - super(self.__class__, self).resizeEvent(event) + super(ExpandableWidget, self).resizeEvent(event) self.content.updateGeometry() def set_content(self, in_widget): @@ -481,7 +481,7 @@ class CommentBox(QtWidgets.QLineEdit): class TerminalDetail(QtWidgets.QTextEdit): def __init__(self, text, *args, **kwargs): - super(self.__class__, self).__init__(*args, **kwargs) + super(TerminalDetail, self).__init__(*args, **kwargs) self.setReadOnly(True) self.setHtml(text) @@ -504,7 +504,7 @@ class FilterButton(QtWidgets.QPushButton): def __init__(self, name, *args, **kwargs): self.filter_name = name - super(self.__class__, self).__init__(*args, **kwargs) + super(FilterButton, self).__init__(*args, **kwargs) self.toggled.connect(self.on_toggle) @@ -522,8 +522,8 @@ class FilterButton(QtWidgets.QPushButton): class TerminalFilterWidget(QtWidgets.QWidget): # timer.timeout.connect(lambda: self._update(self.parent_widget)) def __init__(self, *args, **kwargs): - super(self.__class__, self).__init__(*args, **kwargs) - + super(TerminalFilterWidget, self).__init__(*args, **kwargs) + self.setObjectName("TerminalFilterWidget") self.filter_changed = QtCore.Signal() info_icon = awesome.tags["info"] @@ -531,16 +531,16 @@ class TerminalFilterWidget(QtWidgets.QWidget): error_icon = awesome.tags["exclamation-triangle"] filter_buttons = ( - FilterButton("info", info_icon), - FilterButton("log_debug", log_icon), - FilterButton("log_info", log_icon), - FilterButton("log_warning", log_icon), - FilterButton("log_error", log_icon), - FilterButton("log_critical", log_icon), - FilterButton("error", error_icon) + FilterButton("info", info_icon, self), + FilterButton("log_debug", log_icon, self), + FilterButton("log_info", log_icon, self), + FilterButton("log_warning", log_icon, self), + FilterButton("log_error", log_icon, self), + FilterButton("log_critical", log_icon, self), + FilterButton("error", error_icon, self) ) - layout = QtWidgets.QHBoxLayout() + layout = QtWidgets.QHBoxLayout(self) layout.setContentsMargins(0, 0, 0, 0) # Add spacers layout.addWidget(QtWidgets.QWidget(), 1) diff --git a/pype/tools/pyblish_pype/window.py b/pype/tools/pyblish_pype/window.py index 76f31e2442..ab59f9f6a1 100644 --- a/pype/tools/pyblish_pype/window.py +++ b/pype/tools/pyblish_pype/window.py @@ -39,6 +39,7 @@ Todo: the first time to understand how to actually to it! """ +import sys from functools import partial from . import delegate, model, settings, util, view, widgets @@ -48,6 +49,10 @@ from Qt import QtCore, QtGui, QtWidgets from .constants import ( PluginStates, PluginActionStates, InstanceStates, GroupStates, Roles ) +if sys.version_info[0] == 3: + from queue import Queue +else: + from Queue import Queue class Window(QtWidgets.QDialog): @@ -225,7 +230,6 @@ class Window(QtWidgets.QDialog): intent_model = model.IntentModel() intent_box.setModel(intent_model) - intent_box.currentIndexChanged.connect(self.on_intent_changed) comment_intent_widget = QtWidgets.QWidget() comment_intent_layout = QtWidgets.QHBoxLayout(comment_intent_widget) @@ -267,6 +271,7 @@ class Window(QtWidgets.QDialog): layout.addWidget(footer_button_play, 0) footer_layout = QtWidgets.QVBoxLayout(footer_widget) + footer_layout.addWidget(terminal_filters_widget) footer_layout.addWidget(comment_intent_widget) footer_layout.addLayout(layout) @@ -281,16 +286,21 @@ class Window(QtWidgets.QDialog): ) closing_placeholder.hide() - perspective_widget = widgets.PerspectiveWidget(self) + perspective_widget = widgets.PerspectiveWidget(main_widget) perspective_widget.hide() + pages_widget = QtWidgets.QWidget(main_widget) + layout = QtWidgets.QVBoxLayout(pages_widget) + layout.setContentsMargins(0, 0, 0, 0) + layout.setSpacing(0) + layout.addWidget(header_widget, 0) + layout.addWidget(body_widget, 1) + # Main layout layout = QtWidgets.QVBoxLayout(main_widget) - layout.addWidget(header_widget, 0) - layout.addWidget(body_widget, 3) + layout.addWidget(pages_widget, 3) layout.addWidget(perspective_widget, 3) layout.addWidget(closing_placeholder, 1) - layout.addWidget(terminal_filters_widget, 0) layout.addWidget(footer_widget, 0) layout.setContentsMargins(0, 0, 0, 0) layout.setSpacing(0) @@ -382,6 +392,7 @@ class Window(QtWidgets.QDialog): # Enable CSS on plain QWidget objects for _widget in ( + pages_widget, header_widget, body_widget, artist_page, @@ -457,6 +468,7 @@ class Window(QtWidgets.QDialog): self.main_widget = main_widget + self.pages_widget = pages_widget self.header_widget = header_widget self.body_widget = body_widget @@ -498,13 +510,20 @@ class Window(QtWidgets.QDialog): "overview": header_tab_overview, "terminal": header_tab_terminal } - self.pages = { - "artist": artist_page, - "overview": overview_page, - "terminal": terminal_page - } + self.pages = ( + ("artist", artist_page), + ("overview", overview_page), + ("terminal", terminal_page) + ) current_page = settings.InitialTab or "artist" + self.comment_main_widget.setVisible( + not current_page == "terminal" + ) + self.terminal_filters_widget.setVisible( + current_page == "terminal" + ) + self.state = { "is_closing": False, "current_page": current_page @@ -548,11 +567,9 @@ class Window(QtWidgets.QDialog): show = True self.perspective_widget.set_context(index) - self.body_widget.setVisible(not show) - self.header_widget.setVisible(not show) - + self.pages_widget.setVisible(not show) self.perspective_widget.setVisible(show) - self.terminal_filters_widget.setVisible(show) + self.footer_items_visibility() def change_toggleability(self, enable_value): for plugin_item in self.plugin_model.plugin_items.values(): @@ -563,6 +580,20 @@ class Window(QtWidgets.QDialog): ): instance_item.setData(enable_value, Roles.IsEnabledRole) + def _add_intent_to_context(self): + if ( + self.intent_model.has_items + and "intent" not in self.controller.context.data + ): + idx = self.intent_model.index(self.intent_box.currentIndex(), 0) + intent_value = self.intent_model.data(idx, Roles.IntentItemValue) + intent_label = self.intent_model.data(idx, QtCore.Qt.DisplayRole) + + self.controller.context.data["intent"] = { + "value": intent_value, + "label": intent_label + } + def on_instance_toggle(self, index, state=None): """An item is requesting to be toggled""" if not index.data(Roles.IsOptionalRole): @@ -603,27 +634,245 @@ class Window(QtWidgets.QDialog): self.update_compatibility() def on_tab_changed(self, target): - self.comment_main_widget.setVisible(not target == "terminal") - self.terminal_filters_widget.setVisible(target == "terminal") - - for name, page in self.pages.items(): - if name != target: - page.hide() - - self.pages[target].show() + previous_page = None + target_page = None + direction = None + for name, page in self.pages: + if name == target: + target_page = page + if direction is None: + direction = -1 + elif name == self.state["current_page"]: + previous_page = page + if direction is None: + direction = 1 + else: + page.setVisible(False) self.state["current_page"] = target + self.slide_page(previous_page, target_page, direction) + + def slide_page(self, previous_page, target_page, direction): + if previous_page is None: + for name, page in self.pages: + for _name, _page in self.pages: + if name != _name: + _page.hide() + page.show() + page.hide() + + if ( + previous_page == target_page + or previous_page is None + ): + if not target_page.isVisible(): + target_page.show() + return + + width = previous_page.frameGeometry().width() + offset = QtCore.QPoint(direction * width, 0) + + previous_rect = ( + previous_page.frameGeometry().x(), + previous_page.frameGeometry().y(), + width, + previous_page.frameGeometry().height() + ) + curr_pos = previous_page.pos() + + previous_page.hide() + target_page.show() + target_page.update() + target_rect = ( + target_page.frameGeometry().x(), + target_page.frameGeometry().y(), + target_page.frameGeometry().width(), + target_page.frameGeometry().height() + ) + previous_page.show() + + target_page.raise_() + previous_page.setGeometry(*previous_rect) + target_page.setGeometry(*target_rect) + + target_page.move(curr_pos + offset) + + duration = 250 + + anim_old = QtCore.QPropertyAnimation( + previous_page, b"pos", self + ) + anim_old.setDuration(duration) + anim_old.setStartValue(curr_pos) + anim_old.setEndValue(curr_pos - offset) + anim_old.setEasingCurve(QtCore.QEasingCurve.OutQuad) + + anim_new = QtCore.QPropertyAnimation( + target_page, b"pos", self + ) + anim_new.setDuration(duration) + anim_new.setStartValue(curr_pos + offset) + anim_new.setEndValue(curr_pos) + anim_new.setEasingCurve(QtCore.QEasingCurve.OutQuad) + + anim_group = QtCore.QParallelAnimationGroup(self) + anim_group.addAnimation(anim_old) + anim_group.addAnimation(anim_new) + + def slide_finished(): + previous_page.hide() + self.footer_items_visibility() + + anim_group.finished.connect(slide_finished) + anim_group.start() + + def footer_items_visibility( + self, + comment_visible=None, + terminal_filters_visibile=None + ): + target = self.state["current_page"] + comment_visibility = ( + not self.perspective_widget.isVisible() + and not target == "terminal" + and self.comment_box.isEnabled() + ) + terminal_filters_visibility = ( + target == "terminal" + or self.perspective_widget.isVisible() + ) + + if comment_visible is not None and comment_visibility: + comment_visibility = comment_visible + + if ( + terminal_filters_visibile is not None + and terminal_filters_visibility + ): + terminal_filters_visibility = terminal_filters_visibile + + duration = 150 + + hiding_widgets = [] + showing_widgets = [] + if (comment_visibility != ( + self.comment_main_widget.isVisible() + )): + if self.comment_main_widget.isVisible(): + hiding_widgets.append(self.comment_main_widget) + else: + showing_widgets.append(self.comment_main_widget) + + if (terminal_filters_visibility != ( + self.terminal_filters_widget.isVisible() + )): + if self.terminal_filters_widget.isVisible(): + hiding_widgets.append(self.terminal_filters_widget) + else: + showing_widgets.append(self.terminal_filters_widget) + + if not hiding_widgets and not showing_widgets: + return + + hiding_widgets_queue = Queue() + showing_widgets_queue = Queue() + widgets_by_pos_y = {} + for widget in hiding_widgets: + key = widget.mapToGlobal(widget.rect().topLeft()).x() + widgets_by_pos_y[key] = widget + + for key in sorted(widgets_by_pos_y.keys()): + widget = widgets_by_pos_y[key] + hiding_widgets_queue.put((widget, )) + + for widget in hiding_widgets: + widget.hide() + + for widget in showing_widgets: + widget.show() + + self.footer_widget.updateGeometry() + widgets_by_pos_y = {} + for widget in showing_widgets: + key = widget.mapToGlobal(widget.rect().topLeft()).x() + widgets_by_pos_y[key] = widget + + for key in reversed(sorted(widgets_by_pos_y.keys())): + widget = widgets_by_pos_y[key] + showing_widgets_queue.put(widget) + + for widget in showing_widgets: + widget.hide() + + for widget in hiding_widgets: + widget.show() + + def process_showing(): + if showing_widgets_queue.empty(): + return + + widget = showing_widgets_queue.get() + widget.show() + + widget_rect = widget.frameGeometry() + second_rect = QtCore.QRect(widget_rect) + second_rect.setTopLeft(second_rect.bottomLeft()) + + animation = QtCore.QPropertyAnimation( + widget, b"geometry", self + ) + animation.setDuration(duration) + animation.setStartValue(second_rect) + animation.setEndValue(widget_rect) + animation.setEasingCurve(QtCore.QEasingCurve.OutQuad) + + animation.finished.connect(process_showing) + animation.start() + + def process_hiding(): + if hiding_widgets_queue.empty(): + return process_showing() + + item = hiding_widgets_queue.get() + if isinstance(item, tuple): + widget = item[0] + hiding_widgets_queue.put(widget) + widget_rect = widget.frameGeometry() + second_rect = QtCore.QRect(widget_rect) + second_rect.setTopLeft(second_rect.bottomLeft()) + + anim = QtCore.QPropertyAnimation( + widget, b"geometry", self + ) + anim.setDuration(duration) + anim.setStartValue(widget_rect) + anim.setEndValue(second_rect) + anim.setEasingCurve(QtCore.QEasingCurve.OutQuad) + + anim.finished.connect(process_hiding) + anim.start() + else: + item.hide() + return process_hiding() + + process_hiding() def on_validate_clicked(self): self.comment_box.setEnabled(False) + self.footer_items_visibility() self.intent_box.setEnabled(False) + self._add_intent_to_context() + self.validate() def on_play_clicked(self): self.comment_box.setEnabled(False) + self.footer_items_visibility() self.intent_box.setEnabled(False) + self._add_intent_to_context() + self.publish() def on_reset_clicked(self): @@ -644,7 +893,7 @@ class Window(QtWidgets.QDialog): def apply_log_suspend_value(self, value): self._suspend_logs = value if self.state["current_page"] == "terminal": - self.on_tab_changed("overview") + self.tabs["overview"].setChecked(True) self.tabs["terminal"].setVisible(not self._suspend_logs) @@ -652,18 +901,6 @@ class Window(QtWidgets.QDialog): """The user has typed a comment.""" self.controller.context.data["comment"] = self.comment_box.text() - def on_intent_changed(self): - idx = self.intent_model.index(self.intent_box.currentIndex(), 0) - intent_value = self.intent_model.data(idx, Roles.IntentItemValue) - intent_label = self.intent_model.data(idx, QtCore.Qt.DisplayRole) - - # TODO move to play - if self.controller.context: - self.controller.context.data["intent"] = { - "value": intent_value, - "label": intent_label - } - def on_about_to_process(self, plugin, instance): """Reflect currently running pair in GUI""" if instance is None: @@ -753,9 +990,8 @@ class Window(QtWidgets.QDialog): comment = self.controller.context.data.get("comment") self.comment_box.setText(comment or None) self.comment_box.setEnabled(True) + self.footer_items_visibility() - if self.intent_model.has_items: - self.on_intent_changed() self.intent_box.setEnabled(True) # Refresh tab @@ -1087,10 +1323,10 @@ class Window(QtWidgets.QDialog): info.setText(message) # Include message in terminal - self.terminal_model.append({ + self.terminal_model.append([{ "label": message, "type": "info" - }) + }]) self.animation_info_msg.stop() self.animation_info_msg.start() diff --git a/pype/tools/settings/__init__.py b/pype/tools/settings/__init__.py new file mode 100644 index 0000000000..7df121f06e --- /dev/null +++ b/pype/tools/settings/__init__.py @@ -0,0 +1,7 @@ +from settings import style, MainWidget + + +__all__ = ( + "style", + "MainWidget" +) diff --git a/pype/tools/settings/__main__.py b/pype/tools/settings/__main__.py new file mode 100644 index 0000000000..55a38b3604 --- /dev/null +++ b/pype/tools/settings/__main__.py @@ -0,0 +1,18 @@ +import sys + +import settings +from Qt import QtWidgets, QtGui + + +if __name__ == "__main__": + app = QtWidgets.QApplication(sys.argv) + + stylesheet = settings.style.load_stylesheet() + app.setStyleSheet(stylesheet) + app.setWindowIcon(QtGui.QIcon(settings.style.app_icon_path())) + + develop = "-d" in sys.argv or "--develop" in sys.argv + widget = settings.MainWidget(develop) + widget.show() + + sys.exit(app.exec_()) diff --git a/pype/tools/settings/settings/README.md b/pype/tools/settings/settings/README.md new file mode 100644 index 0000000000..47bbf28ba5 --- /dev/null +++ b/pype/tools/settings/settings/README.md @@ -0,0 +1,336 @@ +# Creating GUI schemas + +## Basic rules +- configurations does not define GUI, but GUI defines configurations! +- output is always json (yaml is not needed for anatomy templates anymore) +- GUI schema has multiple input types, all inputs are represented by a dictionary +- each input may have "input modifiers" (keys in dictionary) that are required or optional + - only required modifier for all input items is key `"type"` which says what type of item it is +- there are special keys across all inputs + - `"is_file"` - this key is for storing pype defaults in `pype` repo + - reasons of existence: developing new schemas does not require to create defaults manually + - key is validated, must be once in hierarchy else it won't be possible to store pype defaults + - `"is_group"` - define that all values under key in hierarchy will be overriden if any value is modified, this information is also stored to overrides + - this keys is not allowed for all inputs as they may have not reason for that + - key is validated, can be only once in hierarchy but is not required +- currently there are `system configurations` and `project configurations` + +## Inner schema +- GUI schemas are huge json files, to be able to split whole configuration into multiple schema there's type `schema` +- system configuration schemas are stored in `~/tools/settings/settings/gui_schemas/system_schema/` and project configurations in `~/tools/settings/settings/gui_schemas/projects_schema/` +- each schema name is filename of json file except extension (without ".json") + +### schema +- can have only key `"children"` which is list of strings, each string should represent another schema (order matters) string represebts name of the schema +- will just paste schemas from other schema file in order of "children" list + +``` +{ + "type": "schema", + "name": "my_schema_name" +} +``` + +## Basic Dictionary inputs +- these inputs wraps another inputs into {key: value} relation + +### dict-invisible +- this input gives ability to wrap another inputs but keep them in same widget without visible divider + - this is for example used as first input widget +- has required keys `"key"` and `"children"` + - "children" says what children inputs are underneath + - "key" is key under which will be stored value from it's children +- output is dictionary `{the "key": children values}` +- can't have `"is_group"` key set to True as it breaks visual override showing +``` +{ + "type": "dict-invisible", + "key": "global", + "children": [ + ...ITEMS... + ] +} +``` + +## dict +- this is another dictionary input wrapping more inputs but visually makes them different +- item may be used as widget (in `list` or `dict-modifiable`) + - in that case the only key modifier is `children` which is list of it's keys + - USAGE: e.g. List of dictionaries where each dictionary have same structure. +- item options if is not used as widget + - required keys are `"key"` under which will be stored and `"label"` which will be shown in GUI + - this input can be expandable + - that can be set with key `"expandable"` as `True`/`False` (Default: `True`) + - with key `"expanded"` as `True`/`False` can be set that is expanded when GUI is opened (Default: `False`) + - it is possible to add darker background with `"highlight_content"` (Default: `False`) + - darker background has limits of maximum applies after 3-4 nested highlighted items there is not difference in the color +``` +# Example +{ + "key": "applications", + "type": "dict", + "label": "Applications", + "expandable": true, + "highlight_content": true, + "is_group": true, + "is_file": true, + "children": [ + ...ITEMS... + ] +} + +# When used as widget +{ + "type": "list", + "key": "profiles", + "label": "Profiles", + "object_type": "dict-item", + "input_modifiers": { + "children": [ + { + "key": "families", + "label": "Families", + "type": "list", + "object_type": "text" + }, { + "key": "hosts", + "label": "Hosts", + "type": "list", + "object_type": "text" + } + ... + ] + } +} +``` + +## Inputs for setting any kind of value (`Pure` inputs) +- all these input must have defined `"key"` under which will be stored and `"label"` which will be shown next to input + - unless they are used in different types of inputs (later) "as widgets" in that case `"key"` and `"label"` are not required as there is not place where to set them + +### boolean +- simple checkbox, nothing more to set +``` +{ + "type": "boolean", + "key": "my_boolean_key", + "label": "Do you want to use Pype?" +} +``` + +### number +- number input, can be used for both integer and float + - key `"decimal"` defines how many decimal places will be used, 0 is for integer input (Default: `0`) + - key `"minimum"` as minimum allowed number to enter (Default: `-99999`) + - key `"maxium"` as maximum allowed number to enter (Default: `99999`) +``` +{ + "type": "number", + "key": "fps", + "label": "Frame rate (FPS)" + "decimal": 2, + "minimum": 1, + "maximum": 300000 +} +``` + +### text +- simple text input + - key `"multiline"` allows to enter multiple lines of text (Default: `False`) + - key `"placeholder"` allows to show text inside input when is empty (Default: `None`) + +``` +{ + "type": "text", + "key": "deadline_pool", + "label": "Deadline pool" +} +``` + +### path-input +- enhanced text input + - does not allow to enter backslash, is auto-converted to forward slash + - may be added another validations, like do not allow end path with slash +- this input is implemented to add additional features to text input +- this is meant to be used in proxy input `path-widget` + - DO NOT USE this input in schema please + +### raw-json +- a little bit enhanced text input for raw json +- has validations of json format + - empty value is invalid value, always must be at least `{}` of `[]` + +``` +{ + "type": "raw-json", + "key": "profiles", + "label": "Extract Review profiles" +} +``` + +## Inputs for setting value using Pure inputs +- these inputs also have required `"key"` and `"label"` +- they use Pure inputs "as widgets" + +### list +- output is list +- items can be added and removed +- items in list must be the same type + - type of items is defined with key `"object_type"` where Pure input name is entered (e.g. `number`) + - because Pure inputs may have modifiers (`number` input has `minimum`, `maximum` and `decimals`) you can set these in key `"input_modifiers"` + +``` +{ + "type": "list", + "object_type": "number", + "key": "exclude_ports", + "label": "Exclude ports", + "input_modifiers": { + "minimum": 1, + "maximum": 65535 + } +} +``` + +### dict-modifiable +- one of dictionary inputs, this is only used as value input +- items in this input can be removed and added same way as in `list` input +- value items in dictionary must be the same type + - type of items is defined with key `"object_type"` where Pure input name is entered (e.g. `number`) + - because Pure inputs may have modifiers (`number` input has `minimum`, `maximum` and `decimals`) you can set these in key `"input_modifiers"` +- this input can be expandable + - that can be set with key `"expandable"` as `True`/`False` (Default: `True`) + - with key `"expanded"` as `True`/`False` can be set that is expanded when GUI is opened (Default: `False`) + +``` +{ + "type": "dict-modifiable", + "object_type": "number", + "input_modifiers": { + "minimum": 0, + "maximum": 300 + }, + "is_group": true, + "key": "templates_mapping", + "label": "Muster - Templates mapping", + "is_file": true +} +``` + +### path-widget +- input for paths, use `path-input` internally +- has 2 input modifiers `"multiplatform"` and `"multipath"` + - `"multiplatform"` - adds `"windows"`, `"linux"` and `"darwin"` path inputs result is dictionary + - `"multipath"` - it is possible to enter multiple paths + - if both are enabled result is dictionary with lists + +``` +{ + "type": "path-widget", + "key": "ffmpeg_path", + "label": "FFmpeg path", + "multiplatform": true, + "multipath": true +} +``` + +### list-strict +- input for strict number of items in list +- each child item can be different type with different possible modifiers +- it is possible to display them in horizontal or vertical layout + - key `"horizontal"` as `True`/`False` (Default: `True`) +- each child may have defined `"label"` which is shown next to input + - label does not reflect modifications or overrides (TODO) +- children item are defined under key `"object_types"` which is list of dictionaries + - key `"children"` is not used because is used for hierarchy validations in schema +- USAGE: For colors, transformations, etc. Custom number and different modifiers + give ability to define if color is HUE or RGB, 0-255, 0-1, 0-100 etc. + +``` +{ + "type": "list-strict", + "key": "color", + "label": "Color", + "object_types": [ + { + "label": "Red", + "type": "number", + "minimum": 0, + "maximum": 255, + "decimal": 0 + }, { + "label": "Green", + "type": "number", + "minimum": 0, + "maximum": 255, + "decimal": 0 + }, { + "label": "Blue", + "type": "number", + "minimum": 0, + "maximum": 255, + "decimal": 0 + }, { + "label": "Alpha", + "type": "number", + "minimum": 0, + "maximum": 1, + "decimal": 6 + } + ] +} +``` + + +## Noninteractive widgets +- have nothing to do with data + +### label +- add label with note or explanations +- it is possible to use html tags inside the label + +``` +{ + "type": "label", + "label": "RED LABEL: Normal label" +} +``` + +### splitter +- visual splitter of items (more divider than splitter) + +``` +{ + "type": "splitter" +} +``` + +## Proxy wrappers +- should wraps multiple inputs only visually +- these does not have `"key"` key and do not allow to have `"is_file"` or `"is_group"` modifiers enabled + +### form +- DEPRECATED + - may be used only in `dict` and `dict-invisible` where is currently used grid layout so form is not needed + - item is kept as still may be used in specific cases +- wraps inputs into form look layout +- should be used only for Pure inputs + +``` +{ + "type": "dict-form", + "children": [ + { + "type": "text", + "key": "deadline_department", + "label": "Deadline apartment" + }, { + "type": "number", + "key": "deadline_priority", + "label": "Deadline priority" + }, { + ... + } + ] +} +``` diff --git a/pype/tools/settings/settings/__init__.py b/pype/tools/settings/settings/__init__.py new file mode 100644 index 0000000000..0c2fd6d4bb --- /dev/null +++ b/pype/tools/settings/settings/__init__.py @@ -0,0 +1,8 @@ +from . import style +from .widgets import MainWidget + + +__all__ = ( + "style", + "MainWidget" +) diff --git a/pype/tools/settings/settings/gui_schemas/projects_schema/0_project_gui_schema.json b/pype/tools/settings/settings/gui_schemas/projects_schema/0_project_gui_schema.json new file mode 100644 index 0000000000..cf95bf4c45 --- /dev/null +++ b/pype/tools/settings/settings/gui_schemas/projects_schema/0_project_gui_schema.json @@ -0,0 +1,30 @@ +{ + "key": "project", + "type": "dict-invisible", + "children": [ + { + "type": "anatomy", + "key": "project_anatomy", + "children": [ + { + "type": "anatomy_roots", + "key": "roots", + "is_file": true + }, { + "type": "anatomy_templates", + "key": "templates", + "is_file": true + } + ] + }, { + "type": "dict-invisible", + "key": "project_settings", + "children": [ + { + "type": "schema", + "name": "1_plugins_gui_schema" + } + ] + } + ] +} diff --git a/pype/tools/settings/settings/gui_schemas/projects_schema/1_plugins_gui_schema.json b/pype/tools/settings/settings/gui_schemas/projects_schema/1_plugins_gui_schema.json new file mode 100644 index 0000000000..f357b51dc5 --- /dev/null +++ b/pype/tools/settings/settings/gui_schemas/projects_schema/1_plugins_gui_schema.json @@ -0,0 +1,745 @@ +{ + "type": "dict", + "collapsable": true, + "key": "plugins", + "label": "Plugins", + "children": [ + { + "type": "dict", + "collapsable": true, + "key": "celaction", + "label": "CelAction", + "children": [ + { + "type": "dict", + "collapsable": true, + "key": "publish", + "label": "Publish plugins", + "is_file": true, + "children": [ + { + "type": "dict", + "collapsable": true, + "checkbox_key": "enabled", + "key": "ExtractCelactionDeadline", + "label": "ExtractCelactionDeadline", + "is_group": true, + "children": [ + { + "type": "boolean", + "key": "enabled", + "label": "Enabled" + }, { + "type": "text", + "key": "deadline_department", + "label": "Deadline apartment" + }, { + "type": "number", + "key": "deadline_priority", + "label": "Deadline priority" + }, { + "type": "text", + "key": "deadline_pool", + "label": "Deadline pool" + }, { + "type": "text", + "key": "deadline_pool_secondary", + "label": "Deadline pool (secondary)" + }, { + "type": "text", + "key": "deadline_group", + "label": "Deadline Group" + }, { + "type": "number", + "key": "deadline_chunk_size", + "label": "Deadline Chunk size" + } + ] + } + ] + } + ] + }, { + "type": "dict", + "collapsable": true, + "key": "ftrack", + "label": "Ftrack", + "children": [ + { + "type": "dict", + "collapsable": true, + "key": "publish", + "label": "Publish plugins", + "is_file": true, + "children": [ + { + "type": "dict", + "collapsable": true, + "checkbox_key": "enabled", + "key": "IntegrateFtrackNote", + "label": "IntegrateFtrackNote", + "is_group": true, + "children": [ + { + "type": "boolean", + "key": "enabled", + "label": "Enabled" + }, { + "type": "text", + "key": "note_with_intent_template", + "label": "Note with intent template" + }, { + "type": "list", + "object_type": "text", + "key": "note_labels", + "label": "Note labels" + } + ] + } + ] + } + ] + }, { + "type": "dict", + "collapsable": true, + "key": "global", + "label": "Global", + "children": [ + { + "type": "dict", + "collapsable": true, + "key": "publish", + "label": "Publish plugins", + "is_file": true, + "children": [ + { + "type": "dict", + "collapsable": true, + "checkbox_key": "enabled", + "key": "IntegrateMasterVersion", + "label": "IntegrateMasterVersion", + "is_group": true, + "children": [ + { + "type": "boolean", + "key": "enabled", + "label": "Enabled" + } + ] + }, { + "type": "dict", + "collapsable": true, + "checkbox_key": "enabled", + "key": "ExtractJpegEXR", + "label": "ExtractJpegEXR", + "is_group": true, + "children": [ + { + "type": "boolean", + "key": "enabled", + "label": "Enabled" + }, { + "type": "dict-invisible", + "key": "ffmpeg_args", + "children": [ + { + "type": "list", + "object_type": "text", + "key": "input", + "label": "FFmpeg input arguments" + }, { + "type": "list", + "object_type": "text", + "key": "output", + "label": "FFmpeg output arguments" + } + ] + } + ] + }, { + "type": "dict", + "collapsable": true, + "key": "ExtractReview", + "label": "ExtractReview", + "checkbox_key": "enabled", + "is_group": true, + "children": [ + { + "type": "boolean", + "key": "enabled", + "label": "Enabled" + }, { + "type": "list", + "key": "profiles", + "label": "Profiles", + "object_type": "dict", + "input_modifiers": { + "children": [ + { + "key": "families", + "label": "Families", + "type": "list", + "object_type": "text" + }, { + "key": "hosts", + "label": "Hosts", + "type": "list", + "object_type": "text" + }, { + "type": "splitter" + }, { + "key": "outputs", + "label": "Output Definitions", + "type": "dict-modifiable", + "highlight_content": true, + "object_type": "dict", + "input_modifiers": { + "children": [ + { + "key": "ext", + "label": "Output extension", + "type": "text" + }, { + "key": "tags", + "label": "Tags", + "type": "list", + "object_type": "text" + }, { + "key": "ffmpeg_args", + "label": "FFmpeg arguments", + "type": "dict", + "highlight_content": true, + "children": [ + { + "key": "video_filters", + "label": "Video filters", + "type": "list", + "object_type": "text" + }, { + "type": "splitter" + }, { + "key": "audio_filters", + "label": "Audio filters", + "type": "list", + "object_type": "text" + }, { + "type": "splitter" + }, { + "key": "input", + "label": "Input arguments", + "type": "list", + "object_type": "text" + }, { + "type": "splitter" + }, { + "key": "output", + "label": "Output arguments", + "type": "list", + "object_type": "text" + } + ] + }, { + "key": "filter", + "label": "Additional output filtering", + "type": "dict", + "highlight_content": true, + "children": [ + { + "key": "families", + "label": "Families", + "type": "list", + "object_type": "text" + } + ] + } + ] + } + } + ] + } + } + ] + }, { + "type": "dict", + "collapsable": true, + "key": "ExtractBurnin", + "label": "ExtractBurnin", + "checkbox_key": "enabled", + "is_group": true, + "children": [ + { + "type": "boolean", + "key": "enabled", + "label": "Enabled" + }, { + "type": "dict", + "collapsable": true, + "key": "options", + "label": "Burnin formating options", + "children": [ + { + "type": "number", + "key": "font_size", + "label": "Font size" + }, { + "type": "number", + "key": "opacity", + "label": "Font opacity" + }, { + "type": "number", + "key": "bg_opacity", + "label": "Background opacity" + }, { + "type": "number", + "key": "x_offset", + "label": "X Offset" + }, { + "type": "number", + "key": "y_offset", + "label": "Y Offset" + }, { + "type": "number", + "key": "bg_padding", + "label": "Padding aroung text" + } + ] + }, { + "type": "raw-json", + "key": "profiles", + "label": "Burnin profiles" + } + ] + }, { + "type": "dict", + "collapsable": true, + "key": "IntegrateAssetNew", + "label": "IntegrateAssetNew", + "is_group": true, + "children": [ + { + "type": "raw-json", + "key": "template_name_profiles", + "label": "template_name_profiles" + } + ] + }, { + "type": "dict", + "collapsable": true, + "key": "ProcessSubmittedJobOnFarm", + "label": "ProcessSubmittedJobOnFarm", + "checkbox_key": "enabled", + "is_group": true, + "children": [ + { + "type": "boolean", + "key": "enabled", + "label": "Enabled" + }, { + "type": "text", + "key": "deadline_department", + "label": "Deadline department" + }, { + "type": "text", + "key": "deadline_pool", + "label": "Deadline Pool" + }, { + "type": "text", + "key": "deadline_group", + "label": "Deadline Group" + } + ] + } + ] + } + ] + }, { + "type": "dict", + "collapsable": true, + "key": "maya", + "label": "Maya", + "children": [ + { + "type": "dict", + "collapsable": true, + "key": "publish", + "label": "Publish plugins", + "is_file": true, + "children": [ + { + "type": "dict", + "collapsable": true, + "key": "ValidateModelName", + "label": "Validate Model Name", + "checkbox_key": "enabled", + "is_group": true, + "children": [ + { + "type": "boolean", + "key": "enabled", + "label": "Enabled" + }, { + "type": "text", + "key": "material_file", + "label": "Material File" + }, { + "type": "text", + "key": "regex", + "label": "Validation regex" + } + ] + }, { + "type": "dict", + "collapsable": true, + "key": "ValidateAssemblyName", + "label": "Validate Assembly Name", + "checkbox_key": "enabled", + "is_group": true, + "children": [ + { + "type": "boolean", + "key": "enabled", + "label": "Enabled" + } + ] + }, { + "type": "dict", + "collapsable": true, + "key": "ValidateShaderName", + "label": "ValidateShaderName", + "checkbox_key": "enabled", + "is_group": true, + "children": [ + { + "type": "boolean", + "key": "enabled", + "label": "Enabled" + }, { + "type": "text", + "key": "regex", + "label": "Validation regex" + } + ] + }, { + "type": "dict", + "collapsable": true, + "key": "ValidateMeshHasOverlappingUVs", + "label": "ValidateMeshHasOverlappingUVs", + "checkbox_key": "enabled", + "is_group": true, + "children": [ + { + "type": "boolean", + "key": "enabled", + "label": "Enabled" + } + ] + } + ] + }, { + "type": "raw-json", + "key": "workfile_build", + "label": "Workfile Build logic", + "is_file": true + } + ] + }, { + "type": "dict", + "collapsable": true, + "key": "nuke", + "label": "Nuke", + "children": [ + { + "type": "dict", + "collapsable": true, + "key": "create", + "label": "Create plugins", + "is_file": true, + "children": [ + { + "type": "dict", + "collapsable": false, + "key": "CreateWriteRender", + "label": "CreateWriteRender", + "is_group": true, + "children": [ + { + "type": "text", + "key": "fpath_template", + "label": "Path template" + } + ] + }, { + "type": "dict", + "collapsable": false, + "key": "CreateWritePrerender", + "label": "CreateWritePrerender", + "is_group": true, + "children": [ + { + "type": "text", + "key": "fpath_template", + "label": "Path template" + } + ] + } + ] + }, { + "type": "dict", + "collapsable": true, + "key": "publish", + "label": "Publish plugins", + "is_file": true, + "children": [ + { + "type": "dict", + "collapsable": true, + "checkbox_key": "enabled", + "key": "ExtractThumbnail", + "label": "ExtractThumbnail", + "is_group": true, + "children": [ + { + "type": "boolean", + "key": "enabled", + "label": "Enabled" + }, { + "type": "raw-json", + "key": "nodes", + "label": "Nodes" + } + ] + }, { + "type": "dict", + "collapsable": true, + "checkbox_key": "enabled", + "key": "ValidateNukeWriteKnobs", + "label": "ValidateNukeWriteKnobs", + "is_group": true, + "children": [ + { + "type": "boolean", + "key": "enabled", + "label": "Enabled" + }, { + "type": "raw-json", + "key": "knobs", + "label": "Knobs" + } + ] + }, { + "type": "dict", + "collapsable": true, + "checkbox_key": "enabled", + "key": "ExtractReviewDataLut", + "label": "ExtractReviewDataLut", + "is_group": true, + "children": [ + { + "type": "boolean", + "key": "enabled", + "label": "Enabled" + } + ] + }, { + "type": "dict", + "collapsable": true, + "checkbox_key": "enabled", + "key": "ExtractReviewDataMov", + "label": "ExtractReviewDataMov", + "is_group": true, + "children": [ + { + "type": "boolean", + "key": "enabled", + "label": "Enabled" + }, { + "type": "boolean", + "key": "viewer_lut_raw", + "label": "Viewer LUT raw" + } + ] + }, { + "type": "dict", + "collapsable": true, + "key": "ExtractSlateFrame", + "label": "ExtractSlateFrame", + "is_group": true, + "children": [ + { + "type": "boolean", + "key": "viewer_lut_raw", + "label": "Viewer LUT raw" + } + ] + }, { + "type": "dict", + "collapsable": true, + "key": "NukeSubmitDeadline", + "label": "NukeSubmitDeadline", + "is_group": true, + "children": [ + { + "type": "number", + "key": "deadline_priority", + "label": "deadline_priority" + }, { + "type": "text", + "key": "deadline_pool", + "label": "deadline_pool" + }, { + "type": "text", + "key": "deadline_pool_secondary", + "label": "deadline_pool_secondary" + }, { + "type": "number", + "key": "deadline_chunk_size", + "label": "deadline_chunk_size" + } + ] + } + ] + }, { + "type": "raw-json", + "key": "workfile_build", + "label": "Workfile Build logic", + "is_file": true + } + ] + }, { + "type": "dict", + "collapsable": true, + "key": "nukestudio", + "label": "NukeStudio", + "children": [ + { + "type": "dict", + "collapsable": true, + "key": "publish", + "label": "Publish plugins", + "is_file": true, + "children": [ + { + "type": "dict", + "collapsable": true, + "checkbox_key": "enabled", + "key": "CollectInstanceVersion", + "label": "Collect Instance Version", + "is_group": true, + "children": [ + { + "type": "boolean", + "key": "enabled", + "label": "Enabled" + } + ] + }, { + "type": "dict", + "collapsable": true, + "checkbox_key": "enabled", + "key": "ExtractReviewCutUpVideo", + "label": "Extract Review Cut Up Video", + "is_group": true, + "children": [ + { + "type": "boolean", + "key": "enabled", + "label": "Enabled" + }, { + "type": "list", + "object_type": "text", + "key": "tags_addition", + "label": "Tags addition" + } + ] + } + ] + } + ] + }, { + "type": "dict", + "collapsable": true, + "key": "resolve", + "label": "DaVinci Resolve", + "children": [ + { + "type": "dict", + "collapsable": true, + "key": "create", + "label": "Creator plugins", + "is_file": true, + "children": [ + { + "type": "dict", + "collapsable": true, + "key": "CreateShotClip", + "label": "Create Shot Clip", + "is_group": true, + "children": [ + { + "type": "text", + "key": "clipName", + "label": "Clip name template" + }, { + "type": "text", + "key": "folder", + "label": "Folder" + }, { + "type": "number", + "key": "steps", + "label": "Steps" + } + ] + } + + ] + } + ] + }, + { + "type": "dict", + "collapsable": true, + "key": "standalonepublisher", + "label": "Standalone Publisher", + "children": [ + { + "type": "dict", + "collapsable": true, + "key": "publish", + "label": "Publish plugins", + "is_file": true, + "children": [ + { + "type": "dict", + "collapsable": true, + "key": "ExtractThumbnailSP", + "label": "ExtractThumbnailSP", + "is_group": true, + "children": [ + { + "type": "dict", + "collapsable": false, + "key": "ffmpeg_args", + "label": "ffmpeg_args", + "children": [ + { + "type": "list", + "object_type": "text", + "key": "input", + "label": "input" + }, + { + "type": "list", + "object_type": "text", + "key": "output", + "label": "output" + } + ] + } + ] + } + ] + } + ] + } + ] +} diff --git a/pype/tools/settings/settings/gui_schemas/system_schema/0_system_gui_schema.json b/pype/tools/settings/settings/gui_schemas/system_schema/0_system_gui_schema.json new file mode 100644 index 0000000000..c5f229fc2f --- /dev/null +++ b/pype/tools/settings/settings/gui_schemas/system_schema/0_system_gui_schema.json @@ -0,0 +1,23 @@ +{ + "key": "system", + "type": "dict-invisible", + "children": [ + { + "type": "dict-invisible", + "key": "global", + "children": [{ + "type": "schema", + "name": "1_intents_gui_schema" + },{ + "type": "schema", + "name": "1_modules_gui_schema" + }, { + "type": "schema", + "name": "1_applications_gui_schema" + }, { + "type": "schema", + "name": "1_tools_gui_schema" + }] + } + ] +} diff --git a/pype/tools/settings/settings/gui_schemas/system_schema/1_applications_gui_schema.json b/pype/tools/settings/settings/gui_schemas/system_schema/1_applications_gui_schema.json new file mode 100644 index 0000000000..3427f98253 --- /dev/null +++ b/pype/tools/settings/settings/gui_schemas/system_schema/1_applications_gui_schema.json @@ -0,0 +1,139 @@ +{ + "key": "applications", + "type": "dict", + "label": "Applications", + "collapsable": true, + "is_group": true, + "is_file": true, + "children": [ + { + "type": "boolean", + "key": "blender_2.80", + "label": "Blender 2.80" + }, { + "type": "boolean", + "key": "blender_2.81", + "label": "Blender 2.81" + }, { + "type": "boolean", + "key": "blender_2.82", + "label": "Blender 2.82" + }, { + "type": "boolean", + "key": "blender_2.83", + "label": "Blender 2.83" + }, { + "type": "boolean", + "key": "celaction_local", + "label": "Celaction Local" + }, { + "type": "boolean", + "key": "celaction_remote", + "label": "Celaction Remote" + }, { + "type": "boolean", + "key": "harmony_17", + "label": "Harmony 17" + }, { + "type": "boolean", + "key": "maya_2017", + "label": "Autodest Maya 2017" + }, { + "type": "boolean", + "key": "maya_2018", + "label": "Autodest Maya 2018" + }, { + "type": "boolean", + "key": "maya_2019", + "label": "Autodest Maya 2019" + }, { + "type": "boolean", + "key": "maya_2020", + "label": "Autodest Maya 2020" + }, { + "key": "nuke_10.0", + "type": "boolean", + "label": "Nuke 10.0" + }, { + "type": "boolean", + "key": "nuke_11.2", + "label": "Nuke 11.2" + }, { + "type": "boolean", + "key": "nuke_11.3", + "label": "Nuke 11.3" + }, { + "type": "boolean", + "key": "nuke_12.0", + "label": "Nuke 12.0" + }, { + "type": "boolean", + "key": "nukex_10.0", + "label": "NukeX 10.0" + }, { + "type": "boolean", + "key": "nukex_11.2", + "label": "NukeX 11.2" + }, { + "type": "boolean", + "key": "nukex_11.3", + "label": "NukeX 11.3" + }, { + "type": "boolean", + "key": "nukex_12.0", + "label": "NukeX 12.0" + }, { + "type": "boolean", + "key": "nukestudio_10.0", + "label": "NukeStudio 10.0" + }, { + "type": "boolean", + "key": "nukestudio_11.2", + "label": "NukeStudio 11.2" + }, { + "type": "boolean", + "key": "nukestudio_11.3", + "label": "NukeStudio 11.3" + }, { + "type": "boolean", + "key": "nukestudio_12.0", + "label": "NukeStudio 12.0" + }, { + "type": "boolean", + "key": "houdini_16", + "label": "Houdini 16" + }, { + "type": "boolean", + "key": "houdini_16.5", + "label": "Houdini 16.5" + }, { + "type": "boolean", + "key": "houdini_17", + "label": "Houdini 17" + }, { + "type": "boolean", + "key": "houdini_18", + "label": "Houdini 18" + }, { + "type": "boolean", + "key": "premiere_2019", + "label": "Premiere 2019" + }, { + "type": "boolean", + "key": "premiere_2020", + "label": "Premiere 2020" + }, { + "type": "boolean", + "key": "resolve_16", + "label": "BM DaVinci Resolve 16" + }, { + "type": "boolean", + "key": "storyboardpro_7", + "label": "Storyboard Pro 7" + }, { + "type": "boolean", + "key": "unreal_4.24", + "label": "Unreal Editor 4.24" + } + ] +} diff --git a/pype/tools/settings/settings/gui_schemas/system_schema/1_examples.json b/pype/tools/settings/settings/gui_schemas/system_schema/1_examples.json new file mode 100644 index 0000000000..0578968508 --- /dev/null +++ b/pype/tools/settings/settings/gui_schemas/system_schema/1_examples.json @@ -0,0 +1,372 @@ +{ + "key": "example_dict", + "label": "Examples", + "type": "dict", + "is_file": true, + "children": [ + { + "key": "dict_wrapper", + "type": "dict-invisible", + "children": [ + { + "type": "boolean", + "key": "bool", + "label": "Boolean checkbox" + }, { + "type": "label", + "label": "NOTE: This is label" + }, { + "type": "splitter" + }, { + "type": "number", + "key": "integer", + "label": "Integer", + "decimal": 0, + "minimum": 0, + "maximum": 10 + }, { + "type": "number", + "key": "float", + "label": "Float (2 decimals)", + "decimal": 2, + "minimum": -10, + "maximum": -5 + }, { + "type": "text", + "key": "singleline_text", + "label": "Singleline text" + }, { + "type": "text", + "key": "multiline_text", + "label": "Multiline text", + "multiline": true + }, { + "type": "raw-json", + "key": "raw_json", + "label": "Raw json input" + }, { + "type": "list", + "key": "list_item_of_multiline_texts", + "label": "List of multiline texts", + "object_type": "text", + "input_modifiers": { + "multiline": true + } + }, { + "type": "list", + "key": "list_item_of_floats", + "label": "List of floats", + "object_type": "number", + "input_modifiers": { + "decimal": 3, + "minimum": 1000, + "maximum": 2000 + } + }, { + "type": "dict-modifiable", + "key": "modifiable_dict_of_integers", + "label": "Modifiable dict of integers", + "object_type": "number", + "input_modifiers": { + "decimal": 0, + "minimum": 10, + "maximum": 100 + } + }, { + "type": "list-strict", + "key": "strict_list_labels_horizontal", + "label": "StrictList-labels-horizontal (color)", + "object_types": [ + { + "label": "Red", + "type": "number", + "minimum": 0, + "maximum": 255, + "decimal": 0 + }, { + "label": "Green", + "type": "number", + "minimum": 0, + "maximum": 255, + "decimal": 0 + }, { + "label": "Blue", + "type": "number", + "minimum": 0, + "maximum": 255, + "decimal": 0 + }, { + "label": "Alpha", + "type": "number", + "minimum": 0, + "maximum": 1, + "decimal": 6 + } + ] + }, { + "type": "list-strict", + "key": "strict_list_labels_vertical", + "label": "StrictList-labels-vertical (color)", + "horizontal": false, + "object_types": [ + { + "label": "Red", + "type": "number", + "minimum": 0, + "maximum": 255, + "decimal": 0 + }, { + "label": "Green", + "type": "number", + "minimum": 0, + "maximum": 255, + "decimal": 0 + }, { + "label": "Blue", + "type": "number", + "minimum": 0, + "maximum": 255, + "decimal": 0 + }, { + "label": "Alpha", + "type": "number", + "minimum": 0, + "maximum": 1, + "decimal": 6 + } + ] + }, { + "type": "list-strict", + "key": "strict_list_nolabels_horizontal", + "label": "StrictList-nolabels-horizontal (color)", + "object_types": [ + { + "type": "number", + "minimum": 0, + "maximum": 255, + "decimal": 0 + }, { + "type": "number", + "minimum": 0, + "maximum": 255, + "decimal": 0 + }, { + "type": "number", + "minimum": 0, + "maximum": 255, + "decimal": 0 + }, { + "type": "number", + "minimum": 0, + "maximum": 1, + "decimal": 6 + } + ] + }, { + "type": "list-strict", + "key": "strict_list_nolabels_vertical", + "label": "StrictList-nolabels-vertical (color)", + "horizontal": false, + "object_types": [ + { + "type": "number", + "minimum": 0, + "maximum": 255, + "decimal": 0 + }, { + "type": "number", + "minimum": 0, + "maximum": 255, + "decimal": 0 + }, { + "type": "number", + "minimum": 0, + "maximum": 255, + "decimal": 0 + }, { + "type": "number", + "minimum": 0, + "maximum": 1, + "decimal": 6 + } + ] + }, { + "type": "list", + "key": "dict_item", + "label": "DictItem in List", + "object_type": "dict-item", + "input_modifiers": { + "children": [ + { + "key": "families", + "label": "Families", + "type": "list", + "object_type": "text" + }, { + "key": "hosts", + "label": "Hosts", + "type": "list", + "object_type": "text" + } + ] + } + }, { + "type": "path-widget", + "key": "single_path_input", + "label": "Single path input", + "multiplatform": false, + "multipath": false + }, { + "type": "path-widget", + "key": "multi_path_input", + "label": "Multi path input", + "multiplatform": false, + "multipath": true + }, { + "type": "path-widget", + "key": "single_os_specific_path_input", + "label": "Single OS specific path input", + "multiplatform": true, + "multipath": false + }, { + "type": "path-widget", + "key": "multi_os_specific_path_input", + "label": "Multi OS specific path input", + "multiplatform": true, + "multipath": true + }, { + "key": "collapsable", + "type": "dict", + "label": "collapsable dictionary", + "collapsable": true, + "is_group": true, + "children": [ + { + "type": "boolean", + "key": "_nothing", + "label": "Exmaple input" + } + ] + }, { + "key": "collapsable_expanded", + "type": "dict", + "label": "collapsable dictionary, expanded on creation", + "collapsable": true, + "collapsed": false, + "is_group": true, + "children": [ + { + "type": "boolean", + "key": "_nothing", + "label": "Exmaple input" + } + ] + }, { + "key": "not_collapsable", + "type": "dict", + "label": "Not collapsable", + "collapsable": false, + "is_group": true, + "children": [ + { + "type": "boolean", + "key": "_nothing", + "label": "Exmaple input" + } + ] + }, { + "key": "nested_dict_lvl1", + "type": "dict", + "label": "Nested dictionary (level 1)", + "children": [ + { + "key": "nested_dict_lvl2", + "type": "dict", + "label": "Nested dictionary (level 2)", + "is_group": true, + "children": [ + { + "key": "nested_dict_lvl3", + "type": "dict", + "label": "Nested dictionary (level 3)", + "children": [ + { + "type": "boolean", + "key": "_nothing", + "label": "Exmaple input" + } + ] + }, { + "key": "nested_dict_lvl3_2", + "type": "dict", + "label": "Nested dictionary (level 3) (2)", + "children": [ + { + "type": "text", + "key": "_nothing", + "label": "Exmaple input" + }, { + "type": "text", + "key": "_nothing2", + "label": "Exmaple input 2" + } + ] + } + ] + } + ] + }, { + "key": "form_examples", + "type": "dict", + "label": "Form examples", + "children": [ + { + "key": "inputs_without_form_example", + "type": "dict", + "label": "Inputs without form", + "children": [ + { + "type": "text", + "key": "_nothing_1", + "label": "Example label" + }, { + "type": "text", + "key": "_nothing_2", + "label": "Example label ####" + }, { + "type": "text", + "key": "_nothing_3", + "label": "Example label ########" + } + ] + }, { + "key": "inputs_with_form_example", + "type": "dict", + "label": "Inputs with form", + "children": [ + { + "type": "form", + "children": [ + { + "type": "text", + "key": "_nothing_1", + "label": "Example label" + }, { + "type": "text", + "key": "_nothing_2", + "label": "Example label ####" + }, { + "type": "text", + "key": "_nothing_3", + "label": "Example label ########" + } + ] + } + ] + } + ] + } + ] + } + ] +} diff --git a/pype/tools/settings/settings/gui_schemas/system_schema/1_intents_gui_schema.json b/pype/tools/settings/settings/gui_schemas/system_schema/1_intents_gui_schema.json new file mode 100644 index 0000000000..7f71da26cd --- /dev/null +++ b/pype/tools/settings/settings/gui_schemas/system_schema/1_intents_gui_schema.json @@ -0,0 +1,16 @@ +{ + "key": "general", + "type": "dict", + "label": "General", + "collapsable": true, + "is_file": true, + "children": [{ + "key": "studio_name", + "type": "text", + "label": "Studio Name" + },{ + "key": "studio_code", + "type": "text", + "label": "Studio Short Code" + } +]} diff --git a/pype/tools/settings/settings/gui_schemas/system_schema/1_modules_gui_schema.json b/pype/tools/settings/settings/gui_schemas/system_schema/1_modules_gui_schema.json new file mode 100644 index 0000000000..7b3d8cdd13 --- /dev/null +++ b/pype/tools/settings/settings/gui_schemas/system_schema/1_modules_gui_schema.json @@ -0,0 +1,283 @@ +{ + "key": "modules", + "type": "dict", + "label": "Modules", + "collapsable": true, + "is_file": true, + "children": [{ + "type": "dict", + "key": "Avalon", + "label": "Avalon", + "collapsable": true, + "children": [ + { + "type": "text", + "key": "AVALON_MONGO", + "label": "Avalon Mongo URL" + }, + { + "type": "text", + "key": "AVALON_DB_DATA", + "label": "Avalon Mongo Data Location" + }, + { + "type": "text", + "key": "AVALON_THUMBNAIL_ROOT", + "label": "Thumbnail Storage Location" + } + ] + },{ + "type": "dict", + "key": "Ftrack", + "label": "Ftrack", + "collapsable": true, + "checkbox_key": "enabled", + "children": [{ + "type": "boolean", + "key": "enabled", + "label": "Enabled" + }, + { + "type": "text", + "key": "ftrack_server", + "label": "Server" + }, + { + "type": "label", + "label": "Additional Ftrack paths" + }, + { + "type": "list", + "key": "ftrack_actions_path", + "label": "Action paths", + "object_type": "text" + }, + { + "type": "list", + "key": "ftrack_events_path", + "label": "Event paths", + "object_type": "text" + }, + { + "type": "label", + "label": "Ftrack event server advanced settings" + }, + { + "type": "text", + "key": "FTRACK_EVENTS_MONGO_DB", + "label": "Event Mongo DB" + }, + { + "type": "text", + "key": "FTRACK_EVENTS_MONGO_COL", + "label": "Events Mongo Collection" + }, + { + "type": "dict", + "key": "sync_to_avalon", + "label": "Sync to avalon", + "children": [{ + "type": "list", + "key": "statuses_name_change", + "label": "Status name change", + "object_type": "text", + "input_modifiers": { + "multiline": false + } + }] + }, + { + "type": "dict-modifiable", + "key": "status_version_to_task", + "label": "Version to Task status mapping", + "object_type": "text" + }, + { + "type": "dict-modifiable", + "key": "status_update", + "label": "Status Updates", + "object_type": "list", + "input_modifiers": { + "object_type": "text" + } + }, + { + "key": "intent", + "type": "dict-invisible", + "children": [ + { + "type": "dict-modifiable", + "object_type": "text", + "key": "items", + "label": "Intent Key/Label" + }, + { + "key": "default", + "type": "text", + "label": "Defautl Intent" + } + ] + } + ] + }, { + "type": "dict", + "key": "Rest Api", + "label": "Rest Api", + "collapsable": true, + "children": [{ + "type": "number", + "key": "default_port", + "label": "Default Port", + "minimum": 1, + "maximum": 65535 + }, + { + "type": "list", + "object_type": "number", + "key": "exclude_ports", + "label": "Exclude ports", + "input_modifiers": { + "minimum": 1, + "maximum": 65535 + } + } + ] + }, { + "type": "dict", + "key": "Timers Manager", + "label": "Timers Manager", + "collapsable": true, + "checkbox_key": "enabled", + "children": [{ + "type": "boolean", + "key": "enabled", + "label": "Enabled" + }, + { + "type": "number", + "decimal": 2, + "key": "full_time", + "label": "Max idle time" + }, { + "type": "number", + "decimal": 2, + "key": "message_time", + "label": "When dialog will show" + } + ] + }, { + "type": "dict", + "key": "Clockify", + "label": "Clockify", + "collapsable": true, + "checkbox_key": "enabled", + "children": [{ + "type": "boolean", + "key": "enabled", + "label": "Enabled" + }, + { + "type": "text", + "key": "workspace_name", + "label": "Workspace name" + } + ] + }, { + "type": "dict", + "key": "Deadline", + "label": "Deadline", + "collapsable": true, + "checkbox_key": "enabled", + "children": [{ + "type": "boolean", + "key": "enabled", + "label": "Enabled" + },{ + "type": "text", + "key": "DEADLINE_REST_URL", + "label": "Deadline Resl URL" + }] + }, { + "type": "dict", + "key": "Muster", + "label": "Muster", + "collapsable": true, + "checkbox_key": "enabled", + "children": [{ + "type": "boolean", + "key": "enabled", + "label": "Enabled" + },{ + "type": "text", + "key": "MUSTER_REST_URL", + "label": "Muster Resl URL" + },{ + "type": "dict-modifiable", + "object_type": "number", + "input_modifiers": { + "minimum": 0, + "maximum": 300 + }, + "is_group": true, + "key": "templates_mapping", + "label": "Templates mapping", + "is_file": true + }] + }, { + "type": "dict", + "key": "Logging", + "label": "Logging", + "collapsable": true, + "checkbox_key": "enabled", + "children": [{ + "type": "boolean", + "key": "enabled", + "label": "Enabled" + }] + }, { + "type": "dict", + "key": "Adobe Communicator", + "label": "Adobe Communicator", + "collapsable": true, + "checkbox_key": "enabled", + "children": [{ + "type": "boolean", + "key": "enabled", + "label": "Enabled" + }] + }, { + "type": "dict", + "key": "User setting", + "label": "User setting", + "collapsable": true, + "checkbox_key": "enabled", + "children": [{ + "type": "boolean", + "key": "enabled", + "label": "Enabled" + }] + }, { + "type": "dict", + "key": "Standalone Publish", + "label": "Standalone Publish", + "collapsable": true, + "checkbox_key": "enabled", + "children": [{ + "type": "boolean", + "key": "enabled", + "label": "Enabled" + }] + }, { + "type": "dict", + "key": "Idle Manager", + "label": "Idle Manager", + "collapsable": true, + "checkbox_key": "enabled", + "children": [{ + "type": "boolean", + "key": "enabled", + "label": "Enabled" + }] + } + ] +} diff --git a/pype/tools/settings/settings/gui_schemas/system_schema/1_tools_gui_schema.json b/pype/tools/settings/settings/gui_schemas/system_schema/1_tools_gui_schema.json new file mode 100644 index 0000000000..08b8d13d89 --- /dev/null +++ b/pype/tools/settings/settings/gui_schemas/system_schema/1_tools_gui_schema.json @@ -0,0 +1,27 @@ +{ + "key": "tools", + "type": "dict", + "label": "Tools", + "collapsable": true, + "is_group": true, + "is_file": true, + "children": [ + { + "key": "mtoa_3.0.1", + "type": "boolean", + "label": "Arnold Maya 3.0.1" + }, { + "key": "mtoa_3.1.1", + "type": "boolean", + "label": "Arnold Maya 3.1.1" + }, { + "key": "mtoa_3.2.0", + "type": "boolean", + "label": "Arnold Maya 3.2.0" + }, { + "key": "yeti_2.1.2", + "type": "boolean", + "label": "Yeti 2.1.2" + } + ] +} diff --git a/pype/tools/settings/settings/style/__init__.py b/pype/tools/settings/settings/style/__init__.py new file mode 100644 index 0000000000..a8f202d97b --- /dev/null +++ b/pype/tools/settings/settings/style/__init__.py @@ -0,0 +1,12 @@ +import os + + +def load_stylesheet(): + style_path = os.path.join(os.path.dirname(__file__), "style.css") + with open(style_path, "r") as style_file: + stylesheet = style_file.read() + return stylesheet + + +def app_icon_path(): + return os.path.join(os.path.dirname(__file__), "pype_icon.png") diff --git a/pype/tools/settings/settings/style/pype_icon.png b/pype/tools/settings/settings/style/pype_icon.png new file mode 100644 index 0000000000..bfacf6eeed Binary files /dev/null and b/pype/tools/settings/settings/style/pype_icon.png differ diff --git a/pype/tools/settings/settings/style/style.css b/pype/tools/settings/settings/style/style.css new file mode 100644 index 0000000000..3f648abef8 --- /dev/null +++ b/pype/tools/settings/settings/style/style.css @@ -0,0 +1,321 @@ +QWidget { + color: #bfccd6; + background-color: #293742; + font-size: 12px; + border-radius: 0px; +} + +QMenu { + border: 1px solid #555555; + background-color: #1d272f; +} + +QMenu::item { + padding: 5px 10px 5px 10px; + border-left: 5px solid #313131; +} + +QMenu::item:selected { + border-left-color: #61839e; + background-color: #222d37; +} +QCheckBox { + spacing: 0px; +} +QCheckBox::indicator {} +QCheckBox::indicator:focus {} + +QLineEdit, QSpinBox, QDoubleSpinBox, QPlainTextEdit, QTextEdit { + border: 1px solid #aaaaaa; + border-radius: 3px; + background-color: #1d272f; +} + +QLineEdit:disabled, QSpinBox:disabled, QDoubleSpinBox:disabled, QPlainTextEdit:disabled, QTextEdit:disabled, QPushButton:disabled { + background-color: #4e6474; +} + +QLineEdit:focus, QSpinBox:focus, QDoubleSpinBox:focus, QPlainTextEdit:focus, QTextEdit:focus { + border: 1px solid #ffffff; +} +QToolButton { + background: transparent; +} + +QLabel { + background: transparent; + color: #7390a5; +} +QLabel:hover {color: #839caf;} + +QLabel[state="studio"] {color: #bfccd6;} +QLabel[state="studio"]:hover {color: #ffffff;} +QLabel[state="modified"] {color: #137cbd;} +QLabel[state="modified"]:hover {color: #1798e8;} +QLabel[state="overriden-modified"] {color: #137cbd;} +QLabel[state="overriden-modified"]:hover {color: #1798e8;} +QLabel[state="overriden"] {color: #ff8c1a;} +QLabel[state="overriden"]:hover {color: #ffa64d;} +QLabel[state="invalid"] {color: #ad2e2e;} +QLabel[state="invalid"]:hover {color: #ad2e2e;} + + +QWidget[input-state="studio"] {border-color: #bfccd6;} +QWidget[input-state="modified"] {border-color: #137cbd;} +QWidget[input-state="overriden-modified"] {border-color: #137cbd;} +QWidget[input-state="overriden"] {border-color: #ff8c1a;} +QWidget[input-state="invalid"] {border-color: #ad2e2e;} + +QPushButton { + border: 1px solid #aaaaaa; + border-radius: 3px; + padding: 5px; +} +QPushButton:hover { + background-color: #31424e; +} +QPushButton[btn-type="tool-item"] { + border: 1px solid #bfccd6; + border-radius: 10px; +} + +QPushButton[btn-type="tool-item"]:hover { + border-color: #137cbd; + color: #137cbd; + background-color: transparent; +} + +QPushButton[btn-type="expand-toggle"] { + background: #1d272f; +} + +#GroupWidget { + border-bottom: 1px solid #1d272f; +} + +#ProjectListWidget QListView { + border: 1px solid #aaaaaa; + background: #1d272f; +} +#ProjectListWidget QLabel { + background: transparent; + font-weight: bold; +} + +#DictKey[state="studio"] {border-color: #bfccd6;} +#DictKey[state="modified"] {border-color: #137cbd;} +#DictKey[state="overriden"] {border-color: #00f;} +#DictKey[state="overriden-modified"] {border-color: #0f0;} +#DictKey[state="invalid"] {border-color: #ad2e2e;} + +#DictLabel { + font-weight: bold; +} + +#ContentWidget { + background-color: transparent; +} +#ContentWidget[content_state="hightlighted"] { + background-color: rgba(19, 26, 32, 15%); +} + +#SideLineWidget { + background-color: #31424e; + border-style: solid; + border-color: #3b4f5e; + border-left-width: 3px; + border-bottom-width: 0px; + border-right-width: 0px; + border-top-width: 0px; +} + +#SideLineWidget:hover { + border-color: #58768d; +} + +#SideLineWidget[state="child-studio"] {border-color: #455c6e;} +#SideLineWidget[state="child-studio"]:hover {border-color: #62839d;} + +#SideLineWidget[state="child-modified"] {border-color: #106aa2;} +#SideLineWidget[state="child-modified"]:hover {border-color: #137cbd;} + +#SideLineWidget[state="child-invalid"] {border-color: #ad2e2e;} +#SideLineWidget[state="child-invalid"]:hover {border-color: #c93636;} + +#SideLineWidget[state="child-overriden"] {border-color: #e67300;} +#SideLineWidget[state="child-overriden"]:hover {border-color: #ff8c1a;} + +#SideLineWidget[state="child-overriden-modified"] {border-color: #106aa2;} +#SideLineWidget[state="child-overriden-modified"]:hover {border-color: #137cbd;} + +#MainWidget { + background: #141a1f; +} + +#DictAsWidgetBody{ + background: transparent; + border: 2px solid #cccccc; + border-radius: 5px; +} + +#SplitterItem { + background-color: #1d272f; +} + +QTabWidget::pane { + border-top-style: none; +} + +QTabBar { + background: transparent; +} + +QTabBar::tab { + border-top-left-radius: 4px; + border-top-right-radius: 4px; + padding: 5px; +} + +QTabBar::tab:selected { + background: #293742; + border-color: #9B9B9B; + border-bottom-color: #C2C7CB; +} + +QTabBar::tab:!selected { + margin-top: 2px; + background: #1d272f; +} + +QTabBar::tab:!selected:hover { + background: #3b4f5e; +} + + + +QTabBar::tab:first:selected { + margin-left: 0; +} + +QTabBar::tab:last:selected { + margin-right: 0; +} + +QTabBar::tab:only-one { + margin: 0; +} + +QScrollBar:horizontal { + height: 15px; + margin: 3px 15px 3px 15px; + border: 1px transparent #1d272f; + border-radius: 4px; + background-color: #1d272f; +} + +QScrollBar::handle:horizontal { + background-color: #61839e; + min-width: 5px; + border-radius: 4px; +} + +QScrollBar::add-line:horizontal { + margin: 0px 3px 0px 3px; + border-image: url(:/qss_icons/rc/right_arrow_disabled.png); + width: 10px; + height: 10px; + subcontrol-position: right; + subcontrol-origin: margin; +} + +QScrollBar::sub-line:horizontal { + margin: 0px 3px 0px 3px; + border-image: url(:/qss_icons/rc/left_arrow_disabled.png); + height: 10px; + width: 10px; + subcontrol-position: left; + subcontrol-origin: margin; +} + +QScrollBar::add-line:horizontal:hover,QScrollBar::add-line:horizontal:on { + border-image: url(:/qss_icons/rc/right_arrow.png); + height: 10px; + width: 10px; + subcontrol-position: right; + subcontrol-origin: margin; +} + +QScrollBar::sub-line:horizontal:hover, QScrollBar::sub-line:horizontal:on { + border-image: url(:/qss_icons/rc/left_arrow.png); + height: 10px; + width: 10px; + subcontrol-position: left; + subcontrol-origin: margin; +} + +QScrollBar::up-arrow:horizontal, QScrollBar::down-arrow:horizontal { + background: none; +} + +QScrollBar::add-page:horizontal, QScrollBar::sub-page:horizontal { + background: none; +} + +QScrollBar:vertical { + background-color: #1d272f; + width: 15px; + margin: 15px 3px 15px 3px; + border: 1px transparent #1d272f; + border-radius: 4px; +} + +QScrollBar::handle:vertical { + background-color: #61839e; + min-height: 5px; + border-radius: 4px; +} + +QScrollBar::sub-line:vertical { + margin: 3px 0px 3px 0px; + border-image: url(:/qss_icons/rc/up_arrow_disabled.png); + height: 10px; + width: 10px; + subcontrol-position: top; + subcontrol-origin: margin; +} + +QScrollBar::add-line:vertical { + margin: 3px 0px 3px 0px; + border-image: url(:/qss_icons/rc/down_arrow_disabled.png); + height: 10px; + width: 10px; + subcontrol-position: bottom; + subcontrol-origin: margin; +} + +QScrollBar::sub-line:vertical:hover,QScrollBar::sub-line:vertical:on { + + border-image: url(:/qss_icons/rc/up_arrow.png); + height: 10px; + width: 10px; + subcontrol-position: top; + subcontrol-origin: margin; +} + + +QScrollBar::add-line:vertical:hover, QScrollBar::add-line:vertical:on { + border-image: url(:/qss_icons/rc/down_arrow.png); + height: 10px; + width: 10px; + subcontrol-position: bottom; + subcontrol-origin: margin; +} + +QScrollBar::up-arrow:vertical, QScrollBar::down-arrow:vertical { + background: none; +} + + +QScrollBar::add-page:vertical, QScrollBar::sub-page:vertical { + background: none; +} diff --git a/pype/tools/settings/settings/widgets/__init__.py b/pype/tools/settings/settings/widgets/__init__.py new file mode 100644 index 0000000000..361fd9d23d --- /dev/null +++ b/pype/tools/settings/settings/widgets/__init__.py @@ -0,0 +1,9 @@ +from .window import MainWidget +from . import item_types +from . import anatomy_types + +__all__ = [ + "MainWidget", + "item_types", + "anatomy_types" +] diff --git a/pype/tools/settings/settings/widgets/anatomy_types.py b/pype/tools/settings/settings/widgets/anatomy_types.py new file mode 100644 index 0000000000..6d7b3292ce --- /dev/null +++ b/pype/tools/settings/settings/widgets/anatomy_types.py @@ -0,0 +1,758 @@ +from Qt import QtWidgets, QtCore +from .widgets import ExpandingWidget +from .item_types import ( + SettingObject, ModifiableDict, PathWidget, RawJsonWidget +) +from .lib import NOT_SET, TypeToKlass, CHILD_OFFSET, METADATA_KEY + + +class AnatomyWidget(QtWidgets.QWidget, SettingObject): + value_changed = QtCore.Signal(object) + template_keys = ( + "project[name]", + "project[code]", + "asset", + "task", + "subset", + "family", + "version", + "ext", + "representation" + ) + default_exmaple_data = { + "project": { + "name": "ProjectPype", + "code": "pp", + }, + "asset": "sq01sh0010", + "task": "compositing", + "subset": "renderMain", + "family": "render", + "version": 1, + "ext": ".png", + "representation": "png" + } + + def __init__( + self, input_data, parent, as_widget=False, label_widget=None + ): + if as_widget: + raise TypeError( + "`AnatomyWidget` does not allow to be used as widget." + ) + super(AnatomyWidget, self).__init__(parent) + self.setObjectName("AnatomyWidget") + + self.initial_attributes(input_data, parent, as_widget) + + self.key = input_data["key"] + + children_data = input_data["children"] + roots_input_data = {} + templates_input_data = {} + for child in children_data: + if child["type"] == "anatomy_roots": + roots_input_data = child + elif child["type"] == "anatomy_templates": + templates_input_data = child + + self.root_widget = RootsWidget(roots_input_data, self) + self.templates_widget = TemplatesWidget(templates_input_data, self) + + self.setAttribute(QtCore.Qt.WA_StyledBackground) + + body_widget = ExpandingWidget("Anatomy", self) + + layout = QtWidgets.QVBoxLayout(self) + layout.setContentsMargins(0, 0, 0, 0) + layout.setSpacing(0) + layout.addWidget(body_widget) + + content_widget = QtWidgets.QWidget(body_widget) + content_layout = QtWidgets.QVBoxLayout(content_widget) + content_layout.setContentsMargins(CHILD_OFFSET, 5, 0, 0) + content_layout.setSpacing(5) + + content_layout.addWidget(self.root_widget) + content_layout.addWidget(self.templates_widget) + + body_widget.set_content_widget(content_widget) + + self.body_widget = body_widget + self.label_widget = body_widget.label_widget + + self.root_widget.value_changed.connect(self._on_value_change) + self.templates_widget.value_changed.connect(self._on_value_change) + + def update_default_values(self, parent_values): + self._state = None + self._child_state = None + + if isinstance(parent_values, dict): + value = parent_values.get(self.key, NOT_SET) + else: + value = NOT_SET + + self.root_widget.update_default_values(value) + self.templates_widget.update_default_values(value) + + def update_studio_values(self, parent_values): + self._state = None + self._child_state = None + + if isinstance(parent_values, dict): + value = parent_values.get(self.key, NOT_SET) + else: + value = NOT_SET + + self.root_widget.update_studio_values(value) + self.templates_widget.update_studio_values(value) + + def apply_overrides(self, parent_values): + # Make sure this is set to False + self._state = None + self._child_state = None + + value = NOT_SET + if parent_values is not NOT_SET: + value = parent_values.get(self.key, value) + + self.root_widget.apply_overrides(value) + self.templates_widget.apply_overrides(value) + + def set_value(self, value): + raise TypeError("AnatomyWidget does not allow to use `set_value`") + + def _on_value_change(self, item=None): + if self.ignore_value_changes: + return + + self.hierarchical_style_update() + + self.value_changed.emit(self) + + def update_style(self, is_overriden=None): + child_has_studio_override = self.child_has_studio_override + child_modified = self.child_modified + child_invalid = self.child_invalid + child_state = self.style_state( + child_has_studio_override, + child_invalid, + self.child_overriden, + child_modified + ) + if child_state: + child_state = "child-{}".format(child_state) + + if child_state != self._child_state: + self.body_widget.side_line_widget.setProperty("state", child_state) + self.body_widget.side_line_widget.style().polish( + self.body_widget.side_line_widget + ) + self._child_state = child_state + + def hierarchical_style_update(self): + self.root_widget.hierarchical_style_update() + self.templates_widget.hierarchical_style_update() + self.update_style() + + @property + def child_has_studio_override(self): + return ( + self.root_widget.child_has_studio_override + or self.templates_widget.child_has_studio_override + ) + + @property + def child_modified(self): + return ( + self.root_widget.child_modified + or self.templates_widget.child_modified + ) + + @property + def child_overriden(self): + return ( + self.root_widget.child_overriden + or self.templates_widget.child_overriden + ) + + @property + def child_invalid(self): + return ( + self.root_widget.child_invalid + or self.templates_widget.child_invalid + ) + + def set_as_overriden(self): + self.root_widget.set_as_overriden() + self.templates_widget.set_as_overriden() + + def remove_overrides(self): + self.root_widget.remove_overrides() + self.templates_widget.remove_overrides() + + def reset_to_pype_default(self): + self.root_widget.reset_to_pype_default() + self.templates_widget.reset_to_pype_default() + + def set_studio_default(self): + self.root_widget.set_studio_default() + self.templates_widget.set_studio_default() + + def discard_changes(self): + self.root_widget.discard_changes() + self.templates_widget.discard_changes() + + def overrides(self): + if self.child_overriden: + return self.config_value(), True + return NOT_SET, False + + def item_value(self): + output = {} + output.update(self.root_widget.config_value()) + output.update(self.templates_widget.config_value()) + return output + + def studio_overrides(self): + if ( + self.root_widget.child_has_studio_override + or self.templates_widget.child_has_studio_override + ): + groups = [self.root_widget.key, self.templates_widget.key] + value = self.config_value() + value[self.key][METADATA_KEY] = {"groups": groups} + return value, True + return NOT_SET, False + + def config_value(self): + return {self.key: self.item_value()} + + +class RootsWidget(QtWidgets.QWidget, SettingObject): + value_changed = QtCore.Signal(object) + + def __init__(self, input_data, parent): + super(RootsWidget, self).__init__(parent) + self.setObjectName("RootsWidget") + + input_data["is_group"] = True + self.initial_attributes(input_data, parent, False) + + self.key = input_data["key"] + + self._multiroot_state = None + self.default_is_multiroot = False + self.studio_is_multiroot = False + self.was_multiroot = NOT_SET + + checkbox_widget = QtWidgets.QWidget(self) + multiroot_label = QtWidgets.QLabel( + "Use multiple roots", checkbox_widget + ) + multiroot_checkbox = QtWidgets.QCheckBox(checkbox_widget) + + checkbox_layout = QtWidgets.QHBoxLayout(checkbox_widget) + checkbox_layout.addWidget(multiroot_label, 0) + checkbox_layout.addWidget(multiroot_checkbox, 1) + + body_widget = ExpandingWidget("Roots", self) + content_widget = QtWidgets.QWidget(body_widget) + + path_widget_data = { + "key": self.key, + "multipath": False, + "multiplatform": True + } + singleroot_widget = PathWidget( + path_widget_data, self, + as_widget=True, parent_widget=content_widget + ) + multiroot_data = { + "key": self.key, + "object_type": "path-widget", + "expandable": False, + "input_modifiers": { + "multiplatform": True + } + } + multiroot_widget = ModifiableDict( + multiroot_data, self, + as_widget=True, parent_widget=content_widget + ) + + content_layout = QtWidgets.QVBoxLayout(content_widget) + content_layout.setContentsMargins(0, 0, 0, 0) + content_layout.addWidget(checkbox_widget) + content_layout.addWidget(singleroot_widget) + content_layout.addWidget(multiroot_widget) + + body_widget.set_content_widget(content_widget) + self.label_widget = body_widget.label_widget + + main_layout = QtWidgets.QVBoxLayout(self) + main_layout.setContentsMargins(0, 0, 0, 0) + main_layout.addWidget(body_widget) + + self.body_widget = body_widget + self.multiroot_label = multiroot_label + self.multiroot_checkbox = multiroot_checkbox + self.singleroot_widget = singleroot_widget + self.multiroot_widget = multiroot_widget + + multiroot_checkbox.stateChanged.connect(self._on_multiroot_checkbox) + singleroot_widget.value_changed.connect(self._on_value_change) + multiroot_widget.value_changed.connect(self._on_value_change) + + self._on_multiroot_checkbox() + + @property + def is_multiroot(self): + return self.multiroot_checkbox.isChecked() + + def update_default_values(self, parent_values): + self._state = None + self._multiroot_state = None + self._is_modified = False + + if isinstance(parent_values, dict): + value = parent_values.get(self.key, NOT_SET) + else: + value = NOT_SET + + is_multiroot = False + if isinstance(value, dict): + for _value in value.values(): + if isinstance(_value, dict): + is_multiroot = True + break + + self.default_is_multiroot = is_multiroot + self.was_multiroot = is_multiroot + self.set_multiroot(is_multiroot) + + self._has_studio_override = False + self._had_studio_override = False + if is_multiroot: + for _value in value.values(): + singleroot_value = _value + break + + multiroot_value = value + else: + singleroot_value = value + multiroot_value = {"": value} + + self.singleroot_widget.update_default_values(singleroot_value) + self.multiroot_widget.update_default_values(multiroot_value) + + def update_studio_values(self, parent_values): + self._state = None + self._multiroot_state = None + self._is_modified = False + + if isinstance(parent_values, dict): + value = parent_values.get(self.key, NOT_SET) + else: + value = NOT_SET + + if value is NOT_SET: + is_multiroot = self.default_is_multiroot + self.studio_is_multiroot = NOT_SET + self._has_studio_override = False + self._had_studio_override = False + else: + is_multiroot = False + if isinstance(value, dict): + for _value in value.values(): + if isinstance(_value, dict): + is_multiroot = True + break + self.studio_is_multiroot = is_multiroot + self._has_studio_override = True + self._had_studio_override = True + + self.was_multiroot = is_multiroot + self.set_multiroot(is_multiroot) + + if is_multiroot: + self.multiroot_widget.update_studio_values(value) + else: + self.singleroot_widget.update_studio_values(value) + + def apply_overrides(self, parent_values): + # Make sure this is set to False + self._state = None + self._multiroot_state = None + self._is_modified = False + + value = NOT_SET + if parent_values is not NOT_SET: + value = parent_values.get(self.key, value) + + if value is NOT_SET: + is_multiroot = self.studio_is_multiroot + if is_multiroot is NOT_SET: + is_multiroot = self.default_is_multiroot + else: + is_multiroot = False + if isinstance(value, dict): + for _value in value.values(): + if isinstance(_value, dict): + is_multiroot = True + break + + self.was_multiroot = is_multiroot + self.set_multiroot(is_multiroot) + + if is_multiroot: + self._is_overriden = value is not NOT_SET + self._was_overriden = bool(self._is_overriden) + self.multiroot_widget.apply_overrides(value) + else: + self._is_overriden = value is not NOT_SET + self._was_overriden = bool(self._is_overriden) + self.singleroot_widget.apply_overrides(value) + + def hierarchical_style_update(self): + self.singleroot_widget.hierarchical_style_update() + self.multiroot_widget.hierarchical_style_update() + self.update_style() + + def update_style(self): + multiroot_state = self.style_state( + self.has_studio_override, + False, + False, + self.was_multiroot != self.is_multiroot + ) + if multiroot_state != self._multiroot_state: + self.multiroot_label.setProperty("state", multiroot_state) + self.multiroot_label.style().polish(self.multiroot_label) + self._multiroot_state = multiroot_state + + state = self.style_state( + self.has_studio_override, + self.child_invalid, + self.is_overriden, + self.is_modified + ) + if self._state == state: + return + + if state: + child_state = "child-{}".format(state) + else: + child_state = "" + + self.body_widget.side_line_widget.setProperty("state", child_state) + self.body_widget.side_line_widget.style().polish( + self.body_widget.side_line_widget + ) + + self.label_widget.setProperty("state", state) + self.label_widget.style().polish(self.label_widget) + + self._state = state + + def _on_multiroot_checkbox(self): + self.set_multiroot() + + def _on_value_change(self, item=None): + if self.ignore_value_changes: + return + + if item is not None and ( + (self.is_multiroot and item != self.multiroot_widget) + or (not self.is_multiroot and item != self.singleroot_widget) + ): + return + + if self.is_group and self.is_overidable: + self._is_overriden = True + + self._is_modified = ( + self.was_multiroot != self.is_multiroot + or self.child_modified + ) + + self.update_style() + + self.value_changed.emit(self) + + def _from_single_to_multi(self): + single_value = self.singleroot_widget.item_value() + mutli_value = self.multiroot_widget.item_value() + first_key = None + for key in mutli_value.keys(): + first_key = key + break + + if first_key is None: + first_key = "" + + mutli_value[first_key] = single_value + + self.multiroot_widget.set_value(mutli_value) + + def _from_multi_to_single(self): + mutli_value = self.multiroot_widget.all_item_values() + for value in mutli_value.values(): + single_value = value + break + + self.singleroot_widget.set_value(single_value) + + def set_multiroot(self, is_multiroot=None): + if is_multiroot is None: + is_multiroot = self.is_multiroot + if is_multiroot: + self._from_single_to_multi() + else: + self._from_multi_to_single() + + if is_multiroot != self.is_multiroot: + self.multiroot_checkbox.setChecked(is_multiroot) + + self.singleroot_widget.setVisible(not is_multiroot) + self.multiroot_widget.setVisible(is_multiroot) + + self._on_value_change() + + @property + def child_has_studio_override(self): + if self.is_multiroot: + return self.multiroot_widget.has_studio_override + else: + return self.singleroot_widget.has_studio_override + + @property + def child_modified(self): + if self.is_multiroot: + return self.multiroot_widget.child_modified + else: + return self.singleroot_widget.child_modified + + @property + def child_overriden(self): + if self.is_multiroot: + return ( + self.multiroot_widget.is_overriden + or self.multiroot_widget.child_overriden + ) + else: + return ( + self.singleroot_widget.is_overriden + or self.singleroot_widget.child_overriden + ) + + @property + def child_invalid(self): + if self.is_multiroot: + return self.multiroot_widget.child_invalid + else: + return self.singleroot_widget.child_invalid + + def remove_overrides(self): + self._is_overriden = False + self._is_modified = False + + if self.studio_is_multiroot is NOT_SET: + self.set_multiroot(self.default_is_multiroot) + else: + self.set_multiroot(self.studio_is_multiroot) + + if self.is_multiroot: + self.multiroot_widget.remove_overrides() + else: + self.singleroot_widget.remove_overrides() + + def reset_to_pype_default(self): + self.set_multiroot(self.default_is_multiroot) + if self.is_multiroot: + self.multiroot_widget.reset_to_pype_default() + else: + self.singleroot_widget.reset_to_pype_default() + self._has_studio_override = False + + def set_studio_default(self): + if self.is_multiroot: + self.multiroot_widget.reset_to_pype_default() + else: + self.singleroot_widget.reset_to_pype_default() + self._has_studio_override = True + + def discard_changes(self): + self._is_overriden = self._was_overriden + self._is_modified = False + if self._is_overriden: + self.set_multiroot(self.was_multiroot) + else: + if self.studio_is_multiroot is NOT_SET: + self.set_multiroot(self.default_is_multiroot) + else: + self.set_multiroot(self.studio_is_multiroot) + + if self.is_multiroot: + self.multiroot_widget.discard_changes() + else: + self.singleroot_widget.discard_changes() + + self._is_modified = self.child_modified + self._has_studio_override = self._had_studio_override + + def set_as_overriden(self): + self._is_overriden = True + self.singleroot_widget.set_as_overriden() + self.multiroot_widget.set_as_overriden() + + def item_value(self): + if self.is_multiroot: + return self.multiroot_widget.item_value() + else: + return self.singleroot_widget.item_value() + + def config_value(self): + return {self.key: self.item_value()} + + +class TemplatesWidget(QtWidgets.QWidget, SettingObject): + value_changed = QtCore.Signal(object) + + def __init__(self, input_data, parent): + super(TemplatesWidget, self).__init__(parent) + + input_data["is_group"] = True + self.initial_attributes(input_data, parent, False) + + self.key = input_data["key"] + + body_widget = ExpandingWidget("Templates", self) + content_widget = QtWidgets.QWidget(body_widget) + body_widget.set_content_widget(content_widget) + content_layout = QtWidgets.QVBoxLayout(content_widget) + + template_input_data = { + "key": self.key + } + self.body_widget = body_widget + self.label_widget = body_widget.label_widget + self.value_input = RawJsonWidget( + template_input_data, self, + label_widget=self.label_widget + ) + content_layout.addWidget(self.value_input) + + layout = QtWidgets.QVBoxLayout(self) + layout.setContentsMargins(0, 0, 0, 0) + layout.setSpacing(0) + + layout.addWidget(body_widget) + + self.value_input.value_changed.connect(self._on_value_change) + + def _on_value_change(self, item): + self.update_style() + + self.value_changed.emit(self) + + def update_default_values(self, values): + self._state = None + self.value_input.update_default_values(values) + + def update_studio_values(self, values): + self._state = None + self.value_input.update_studio_values(values) + + def apply_overrides(self, parent_values): + self._state = None + self.value_input.apply_overrides(parent_values) + + def hierarchical_style_update(self): + self.value_input.hierarchical_style_update() + self.update_style() + + def update_style(self): + state = self.style_state( + self.has_studio_override, + self.child_invalid, + self.child_overriden, + self.child_modified + ) + if self._state == state: + return + + if state: + child_state = "child-{}".format(state) + else: + child_state = "" + + self.body_widget.side_line_widget.setProperty("state", child_state) + self.body_widget.side_line_widget.style().polish( + self.body_widget.side_line_widget + ) + + self.label_widget.setProperty("state", state) + self.label_widget.style().polish(self.label_widget) + + self._state = state + + @property + def is_modified(self): + return self.value_input.is_modified + + @property + def is_overriden(self): + return self._is_overriden + + @property + def has_studio_override(self): + return self.value_input._has_studio_override + + @property + def child_has_studio_override(self): + return self.value_input.child_has_studio_override + + @property + def child_modified(self): + return self.value_input.child_modified + + @property + def child_overriden(self): + return self.value_input.child_overriden + + @property + def child_invalid(self): + return self.value_input.child_invalid + + def remove_overrides(self): + self.value_input.remove_overrides() + + def reset_to_pype_default(self): + self.value_input.reset_to_pype_default() + + def set_studio_default(self): + self.value_input.set_studio_default() + + def discard_changes(self): + self.value_input.discard_changes() + + def set_as_overriden(self): + self.value_input.set_as_overriden() + + def overrides(self): + if not self.child_overriden: + return NOT_SET, False + return self.config_value(), True + + def item_value(self): + return self.value_input.item_value() + + def config_value(self): + return self.value_input.config_value() + + +TypeToKlass.types["anatomy"] = AnatomyWidget +TypeToKlass.types["anatomy_roots"] = AnatomyWidget +TypeToKlass.types["anatomy_templates"] = AnatomyWidget diff --git a/pype/tools/settings/settings/widgets/base.py b/pype/tools/settings/settings/widgets/base.py new file mode 100644 index 0000000000..423380d54c --- /dev/null +++ b/pype/tools/settings/settings/widgets/base.py @@ -0,0 +1,739 @@ +import os +import json +from Qt import QtWidgets, QtCore, QtGui +from pype.settings.lib import ( + SYSTEM_SETTINGS_KEY, + SYSTEM_SETTINGS_PATH, + PROJECT_SETTINGS_KEY, + PROJECT_SETTINGS_PATH, + PROJECT_ANATOMY_KEY, + PROJECT_ANATOMY_PATH, + + DEFAULTS_DIR, + + reset_default_settings, + default_settings, + + studio_system_settings, + studio_project_settings, + studio_project_anatomy, + + project_settings_overrides, + project_anatomy_overrides, + + path_to_project_overrides, + path_to_project_anatomy +) +from .widgets import UnsavedChangesDialog +from . import lib +from avalon import io +from avalon.vendor import qtawesome + + +class SystemWidget(QtWidgets.QWidget): + is_overidable = False + has_studio_override = _has_studio_override = False + is_overriden = _is_overriden = False + as_widget = _as_widget = False + any_parent_as_widget = _any_parent_as_widget = False + is_group = _is_group = False + any_parent_is_group = _any_parent_is_group = False + + def __init__(self, develop_mode, parent=None): + super(SystemWidget, self).__init__(parent) + + self.develop_mode = develop_mode + self._hide_studio_overrides = False + self._ignore_value_changes = False + + self.input_fields = [] + + scroll_widget = QtWidgets.QScrollArea(self) + scroll_widget.setObjectName("GroupWidget") + content_widget = QtWidgets.QWidget(scroll_widget) + content_layout = QtWidgets.QVBoxLayout(content_widget) + content_layout.setContentsMargins(3, 3, 3, 3) + content_layout.setSpacing(0) + content_layout.setAlignment(QtCore.Qt.AlignTop) + content_widget.setLayout(content_layout) + + scroll_widget.setWidgetResizable(True) + scroll_widget.setWidget(content_widget) + + self.scroll_widget = scroll_widget + self.content_layout = content_layout + self.content_widget = content_widget + + footer_widget = QtWidgets.QWidget() + footer_layout = QtWidgets.QHBoxLayout(footer_widget) + + if self.develop_mode: + save_as_default_btn = QtWidgets.QPushButton("Save as Default") + save_as_default_btn.clicked.connect(self._save_as_defaults) + + refresh_icon = qtawesome.icon("fa.refresh", color="white") + refresh_button = QtWidgets.QPushButton() + refresh_button.setIcon(refresh_icon) + refresh_button.clicked.connect(self._on_refresh) + + hide_studio_overrides = QtWidgets.QCheckBox() + hide_studio_overrides.setChecked(self._hide_studio_overrides) + hide_studio_overrides.stateChanged.connect( + self._on_hide_studio_overrides + ) + + hide_studio_overrides_widget = QtWidgets.QWidget() + hide_studio_overrides_layout = QtWidgets.QHBoxLayout( + hide_studio_overrides_widget + ) + _label_widget = QtWidgets.QLabel( + "Hide studio overrides", hide_studio_overrides_widget + ) + hide_studio_overrides_layout.addWidget(_label_widget) + hide_studio_overrides_layout.addWidget(hide_studio_overrides) + + footer_layout.addWidget(save_as_default_btn, 0) + footer_layout.addWidget(refresh_button, 0) + footer_layout.addWidget(hide_studio_overrides_widget, 0) + + save_btn = QtWidgets.QPushButton("Save") + spacer_widget = QtWidgets.QWidget() + footer_layout.addWidget(spacer_widget, 1) + footer_layout.addWidget(save_btn, 0) + + layout = QtWidgets.QVBoxLayout(self) + layout.setContentsMargins(0, 0, 0, 0) + layout.setSpacing(0) + self.setLayout(layout) + + layout.addWidget(scroll_widget, 1) + layout.addWidget(footer_widget, 0) + + save_btn.clicked.connect(self._save) + + self.reset() + + def any_parent_overriden(self): + return False + + @property + def ignore_value_changes(self): + return self._ignore_value_changes + + @ignore_value_changes.setter + def ignore_value_changes(self, value): + self._ignore_value_changes = value + if value is False: + self.hierarchical_style_update() + + def hierarchical_style_update(self): + for input_field in self.input_fields: + input_field.hierarchical_style_update() + + def reset(self): + reset_default_settings() + + if self.content_layout.count() != 0: + for widget in self.input_fields: + self.content_layout.removeWidget(widget) + widget.deleteLater() + self.input_fields.clear() + + self.schema = lib.gui_schema("system_schema", "0_system_gui_schema") + self.keys = self.schema.get("keys", []) + self.add_children_gui(self.schema) + self._update_values() + self.hierarchical_style_update() + + def _save(self): + has_invalid = False + for item in self.input_fields: + if item.child_invalid: + has_invalid = True + + if has_invalid: + invalid_items = [] + for item in self.input_fields: + invalid_items.extend(item.get_invalid()) + msg_box = QtWidgets.QMessageBox( + QtWidgets.QMessageBox.Warning, + "Invalid input", + "There is invalid value in one of inputs." + " Please lead red color and fix them." + ) + msg_box.setStandardButtons(QtWidgets.QMessageBox.Ok) + msg_box.exec_() + + first_invalid_item = invalid_items[0] + self.scroll_widget.ensureWidgetVisible(first_invalid_item) + if first_invalid_item.isVisible(): + first_invalid_item.setFocus(True) + return + + _data = {} + for input_field in self.input_fields: + value, is_group = input_field.studio_overrides() + if value is not lib.NOT_SET: + _data.update(value) + + values = lib.convert_gui_data_to_overrides(_data.get("system", {})) + + dirpath = os.path.dirname(SYSTEM_SETTINGS_PATH) + if not os.path.exists(dirpath): + os.makedirs(dirpath) + + print("Saving data to:", SYSTEM_SETTINGS_PATH) + with open(SYSTEM_SETTINGS_PATH, "w") as file_stream: + json.dump(values, file_stream, indent=4) + + self._update_values() + + def _on_refresh(self): + self.reset() + + def _on_hide_studio_overrides(self, state): + self._hide_studio_overrides = (state == QtCore.Qt.Checked) + self._update_values() + self.hierarchical_style_update() + + def _save_as_defaults(self): + output = {} + for item in self.input_fields: + output.update(item.config_value()) + + for key in reversed(self.keys): + _output = {key: output} + output = _output + + all_values = {} + for item in self.input_fields: + all_values.update(item.config_value()) + + for key in reversed(self.keys): + _all_values = {key: all_values} + all_values = _all_values + + # Skip first key + all_values = all_values["system"] + + prject_defaults_dir = os.path.join( + DEFAULTS_DIR, SYSTEM_SETTINGS_KEY + ) + keys_to_file = lib.file_keys_from_schema(self.schema) + for key_sequence in keys_to_file: + # Skip first key + key_sequence = key_sequence[1:] + subpath = "/".join(key_sequence) + ".json" + + new_values = all_values + for key in key_sequence: + new_values = new_values[key] + + output_path = os.path.join(prject_defaults_dir, subpath) + dirpath = os.path.dirname(output_path) + if not os.path.exists(dirpath): + os.makedirs(dirpath) + + print("Saving data to: ", subpath) + with open(output_path, "w") as file_stream: + json.dump(new_values, file_stream, indent=4) + + reset_default_settings() + + self._update_values() + self.hierarchical_style_update() + + def _update_values(self): + self.ignore_value_changes = True + + default_values = { + "system": default_settings()[SYSTEM_SETTINGS_KEY] + } + for input_field in self.input_fields: + input_field.update_default_values(default_values) + + if self._hide_studio_overrides: + system_values = lib.NOT_SET + else: + system_values = {"system": studio_system_settings()} + for input_field in self.input_fields: + input_field.update_studio_values(system_values) + + self.ignore_value_changes = False + + def add_children_gui(self, child_configuration): + item_type = child_configuration["type"] + klass = lib.TypeToKlass.types.get(item_type) + item = klass(child_configuration, self) + self.input_fields.append(item) + self.content_layout.addWidget(item) + + +class ProjectListView(QtWidgets.QListView): + left_mouse_released_at = QtCore.Signal(QtCore.QModelIndex) + + def mouseReleaseEvent(self, event): + if event.button() == QtCore.Qt.LeftButton: + index = self.indexAt(event.pos()) + self.left_mouse_released_at.emit(index) + super(ProjectListView, self).mouseReleaseEvent(event) + + +class ProjectListWidget(QtWidgets.QWidget): + default = "< Default >" + project_changed = QtCore.Signal() + + def __init__(self, parent): + self._parent = parent + + self.current_project = None + + super(ProjectListWidget, self).__init__(parent) + self.setObjectName("ProjectListWidget") + + label_widget = QtWidgets.QLabel("Projects") + label_widget.setProperty("state", "studio") + project_list = ProjectListView(self) + project_list.setModel(QtGui.QStandardItemModel()) + + # Do not allow editing + project_list.setEditTriggers( + QtWidgets.QAbstractItemView.EditTrigger.NoEditTriggers + ) + # Do not automatically handle selection + project_list.setSelectionMode(QtWidgets.QAbstractItemView.NoSelection) + + layout = QtWidgets.QVBoxLayout(self) + layout.setSpacing(3) + layout.addWidget(label_widget, 0) + layout.addWidget(project_list, 1) + + project_list.left_mouse_released_at.connect(self.on_item_clicked) + + self.project_list = project_list + + self.refresh() + + def on_item_clicked(self, new_index): + new_project_name = new_index.data(QtCore.Qt.DisplayRole) + if new_project_name is None: + return + + if self.current_project == new_project_name: + return + + save_changes = False + change_project = False + if self.validate_context_change(): + change_project = True + + else: + dialog = UnsavedChangesDialog(self) + result = dialog.exec_() + if result == 1: + save_changes = True + change_project = True + + elif result == 2: + change_project = True + + if save_changes: + self._parent._save() + + if change_project: + self.select_project(new_project_name) + self.current_project = new_project_name + self.project_changed.emit() + else: + self.select_project(self.current_project) + + def validate_context_change(self): + # TODO add check if project can be changed (is modified) + for item in self._parent.input_fields: + is_modified = item.child_modified + if is_modified: + return False + return True + + def project_name(self): + if self.current_project == self.default: + return None + return self.current_project + + def select_project(self, project_name): + model = self.project_list.model() + found_items = model.findItems(project_name) + if not found_items: + found_items = model.findItems(self.default) + + index = model.indexFromItem(found_items[0]) + self.project_list.selectionModel().clear() + self.project_list.selectionModel().setCurrentIndex( + index, QtCore.QItemSelectionModel.SelectionFlag.SelectCurrent + ) + + def refresh(self): + selected_project = None + for index in self.project_list.selectedIndexes(): + selected_project = index.data(QtCore.Qt.DisplayRole) + break + + model = self.project_list.model() + model.clear() + items = [self.default] + io.install() + for project_doc in tuple(io.projects()): + items.append(project_doc["name"]) + + for item in items: + model.appendRow(QtGui.QStandardItem(item)) + + self.select_project(selected_project) + + self.current_project = self.project_list.currentIndex().data( + QtCore.Qt.DisplayRole + ) + + +class ProjectWidget(QtWidgets.QWidget): + has_studio_override = _has_studio_override = False + is_overriden = _is_overriden = False + as_widget = _as_widget = False + any_parent_as_widget = _any_parent_as_widget = False + is_group = _is_group = False + any_parent_is_group = _any_parent_is_group = False + + def __init__(self, develop_mode, parent=None): + super(ProjectWidget, self).__init__(parent) + + self.develop_mode = develop_mode + self._hide_studio_overrides = False + + self.is_overidable = False + self._ignore_value_changes = False + self.project_name = None + + self.input_fields = [] + + scroll_widget = QtWidgets.QScrollArea(self) + scroll_widget.setObjectName("GroupWidget") + content_widget = QtWidgets.QWidget(scroll_widget) + content_layout = QtWidgets.QVBoxLayout(content_widget) + content_layout.setContentsMargins(3, 3, 3, 3) + content_layout.setSpacing(0) + content_layout.setAlignment(QtCore.Qt.AlignTop) + content_widget.setLayout(content_layout) + + scroll_widget.setWidgetResizable(True) + scroll_widget.setWidget(content_widget) + + project_list_widget = ProjectListWidget(self) + content_layout.addWidget(project_list_widget) + + footer_widget = QtWidgets.QWidget() + footer_layout = QtWidgets.QHBoxLayout(footer_widget) + + if self.develop_mode: + save_as_default_btn = QtWidgets.QPushButton("Save as Default") + save_as_default_btn.clicked.connect(self._save_as_defaults) + + refresh_icon = qtawesome.icon("fa.refresh", color="white") + refresh_button = QtWidgets.QPushButton() + refresh_button.setIcon(refresh_icon) + refresh_button.clicked.connect(self._on_refresh) + + hide_studio_overrides = QtWidgets.QCheckBox() + hide_studio_overrides.setChecked(self._hide_studio_overrides) + hide_studio_overrides.stateChanged.connect( + self._on_hide_studio_overrides + ) + + hide_studio_overrides_widget = QtWidgets.QWidget() + hide_studio_overrides_layout = QtWidgets.QHBoxLayout( + hide_studio_overrides_widget + ) + _label_widget = QtWidgets.QLabel( + "Hide studio overrides", hide_studio_overrides_widget + ) + hide_studio_overrides_layout.addWidget(_label_widget) + hide_studio_overrides_layout.addWidget(hide_studio_overrides) + + footer_layout.addWidget(save_as_default_btn, 0) + footer_layout.addWidget(refresh_button, 0) + footer_layout.addWidget(hide_studio_overrides_widget, 0) + + save_btn = QtWidgets.QPushButton("Save") + spacer_widget = QtWidgets.QWidget() + footer_layout.addWidget(spacer_widget, 1) + footer_layout.addWidget(save_btn, 0) + + configurations_widget = QtWidgets.QWidget() + configurations_layout = QtWidgets.QVBoxLayout(configurations_widget) + configurations_layout.setContentsMargins(0, 0, 0, 0) + configurations_layout.setSpacing(0) + + configurations_layout.addWidget(scroll_widget, 1) + configurations_layout.addWidget(footer_widget, 0) + + layout = QtWidgets.QHBoxLayout(self) + layout.setContentsMargins(0, 0, 0, 0) + layout.setSpacing(0) + self.setLayout(layout) + + layout.addWidget(project_list_widget, 0) + layout.addWidget(configurations_widget, 1) + + save_btn.clicked.connect(self._save) + project_list_widget.project_changed.connect(self._on_project_change) + + self.project_list_widget = project_list_widget + self.scroll_widget = scroll_widget + self.content_layout = content_layout + self.content_widget = content_widget + + self.reset() + + def any_parent_overriden(self): + return False + + @property + def ignore_value_changes(self): + return self._ignore_value_changes + + @ignore_value_changes.setter + def ignore_value_changes(self, value): + self._ignore_value_changes = value + if value is False: + self.hierarchical_style_update() + + def hierarchical_style_update(self): + for input_field in self.input_fields: + input_field.hierarchical_style_update() + + def reset(self): + if self.content_layout.count() != 0: + for widget in self.input_fields: + self.content_layout.removeWidget(widget) + widget.deleteLater() + self.input_fields.clear() + + self.schema = lib.gui_schema("projects_schema", "0_project_gui_schema") + self.keys = self.schema.get("keys", []) + self.add_children_gui(self.schema) + self._update_values() + self.hierarchical_style_update() + + def add_children_gui(self, child_configuration): + item_type = child_configuration["type"] + klass = lib.TypeToKlass.types.get(item_type) + item = klass(child_configuration, self) + self.input_fields.append(item) + self.content_layout.addWidget(item) + + def _on_project_change(self): + project_name = self.project_list_widget.project_name() + if project_name is None: + _project_overrides = lib.NOT_SET + _project_anatomy = lib.NOT_SET + self.is_overidable = False + else: + _project_overrides = project_settings_overrides(project_name) + _project_anatomy = project_anatomy_overrides(project_name) + self.is_overidable = True + + overrides = {"project": { + PROJECT_SETTINGS_KEY: lib.convert_overrides_to_gui_data( + _project_overrides + ), + PROJECT_ANATOMY_KEY: lib.convert_overrides_to_gui_data( + _project_anatomy + ) + }} + self.project_name = project_name + self.ignore_value_changes = True + for item in self.input_fields: + item.apply_overrides(overrides) + self.ignore_value_changes = False + + def _save_as_defaults(self): + output = {} + for item in self.input_fields: + output.update(item.config_value()) + + for key in reversed(self.keys): + _output = {key: output} + output = _output + + all_values = {} + for item in self.input_fields: + all_values.update(item.config_value()) + + for key in reversed(self.keys): + _all_values = {key: all_values} + all_values = _all_values + + # Skip first key + all_values = all_values["project"] + + keys_to_file = lib.file_keys_from_schema(self.schema) + for key_sequence in keys_to_file: + # Skip first key + key_sequence = key_sequence[1:] + subpath = "/".join(key_sequence) + ".json" + + new_values = all_values + for key in key_sequence: + new_values = new_values[key] + + output_path = os.path.join(DEFAULTS_DIR, subpath) + dirpath = os.path.dirname(output_path) + if not os.path.exists(dirpath): + os.makedirs(dirpath) + + print("Saving data to: ", subpath) + with open(output_path, "w") as file_stream: + json.dump(new_values, file_stream, indent=4) + + reset_default_settings() + + self._update_values() + self.hierarchical_style_update() + + def _save(self): + has_invalid = False + for item in self.input_fields: + if item.child_invalid: + has_invalid = True + + if has_invalid: + invalid_items = [] + for item in self.input_fields: + invalid_items.extend(item.get_invalid()) + msg_box = QtWidgets.QMessageBox( + QtWidgets.QMessageBox.Warning, + "Invalid input", + "There is invalid value in one of inputs." + " Please lead red color and fix them." + ) + msg_box.setStandardButtons(QtWidgets.QMessageBox.Ok) + msg_box.exec_() + + first_invalid_item = invalid_items[0] + self.scroll_widget.ensureWidgetVisible(first_invalid_item) + if first_invalid_item.isVisible(): + first_invalid_item.setFocus(True) + return + + if self.project_name is None: + self._save_studio_overrides() + else: + self._save_overrides() + + def _on_refresh(self): + self.reset() + + def _on_hide_studio_overrides(self, state): + self._hide_studio_overrides = (state == QtCore.Qt.Checked) + self._update_values() + self.hierarchical_style_update() + + def _save_overrides(self): + data = {} + for item in self.input_fields: + value, is_group = item.overrides() + if value is not lib.NOT_SET: + data.update(value) + + output_data = lib.convert_gui_data_to_overrides( + data.get("project") or {} + ) + + # Saving overrides data + project_overrides_data = output_data.get( + PROJECT_SETTINGS_KEY, {} + ) + project_overrides_json_path = path_to_project_overrides( + self.project_name + ) + dirpath = os.path.dirname(project_overrides_json_path) + if not os.path.exists(dirpath): + os.makedirs(dirpath) + + print("Saving data to:", project_overrides_json_path) + with open(project_overrides_json_path, "w") as file_stream: + json.dump(project_overrides_data, file_stream, indent=4) + + # Saving anatomy data + project_anatomy_data = output_data.get( + PROJECT_ANATOMY_KEY, {} + ) + project_anatomy_json_path = path_to_project_anatomy( + self.project_name + ) + dirpath = os.path.dirname(project_anatomy_json_path) + if not os.path.exists(dirpath): + os.makedirs(dirpath) + + print("Saving data to:", project_anatomy_json_path) + with open(project_anatomy_json_path, "w") as file_stream: + json.dump(project_anatomy_data, file_stream, indent=4) + + # Refill values with overrides + self._on_project_change() + + def _save_studio_overrides(self): + data = {} + for input_field in self.input_fields: + value, is_group = input_field.studio_overrides() + if value is not lib.NOT_SET: + data.update(value) + + output_data = lib.convert_gui_data_to_overrides( + data.get("project", {}) + ) + + # Project overrides data + project_overrides_data = output_data.get( + PROJECT_SETTINGS_KEY, {} + ) + dirpath = os.path.dirname(PROJECT_SETTINGS_PATH) + if not os.path.exists(dirpath): + os.makedirs(dirpath) + + print("Saving data to:", PROJECT_SETTINGS_PATH) + with open(PROJECT_SETTINGS_PATH, "w") as file_stream: + json.dump(project_overrides_data, file_stream, indent=4) + + # Project Anatomy data + project_anatomy_data = output_data.get( + PROJECT_ANATOMY_KEY, {} + ) + dirpath = os.path.dirname(PROJECT_ANATOMY_PATH) + if not os.path.exists(dirpath): + os.makedirs(dirpath) + + print("Saving data to:", PROJECT_ANATOMY_PATH) + with open(PROJECT_ANATOMY_PATH, "w") as file_stream: + json.dump(project_anatomy_data, file_stream, indent=4) + + # Update saved values + self._update_values() + + def _update_values(self): + self.ignore_value_changes = True + + default_values = {"project": default_settings()} + for input_field in self.input_fields: + input_field.update_default_values(default_values) + + if self._hide_studio_overrides: + studio_values = lib.NOT_SET + else: + studio_values = {"project": { + PROJECT_SETTINGS_KEY: studio_project_settings(), + PROJECT_ANATOMY_KEY: studio_project_anatomy() + }} + for input_field in self.input_fields: + input_field.update_studio_values(studio_values) + + self.ignore_value_changes = False diff --git a/pype/tools/settings/settings/widgets/item_types.py b/pype/tools/settings/settings/widgets/item_types.py new file mode 100644 index 0000000000..e589b89c0f --- /dev/null +++ b/pype/tools/settings/settings/widgets/item_types.py @@ -0,0 +1,3506 @@ +import json +import logging +import collections +from Qt import QtWidgets, QtCore, QtGui +from .widgets import ( + ExpandingWidget, + NumberSpinBox, + PathInput, + GridLabelWidget +) +from .lib import NOT_SET, METADATA_KEY, TypeToKlass, CHILD_OFFSET +from avalon.vendor import qtawesome + + +class SettingObject: + """Partially abstract class for Setting's item type workflow.""" + # `is_input_type` attribute says if has implemented item type methods + is_input_type = True + # Each input must have implemented default value for development + # when defaults are not filled yet. + default_input_value = NOT_SET + # Will allow to show actions for the item type (disabled for proxies) else + # item is skipped and try to trigger actions on it's parent. + allow_actions = True + # All item types must have implemented Qt signal which is emitted when + # it's or it's children value has changed, + value_changed = None + # Item will expand to full width in grid layout + expand_in_grid = False + + def _set_default_attributes(self): + """Create and reset attributes required for all item types. + + They may not be used in the item but are required to be set. + """ + # Default input attributes + self._has_studio_override = False + self._had_studio_override = False + + self._is_overriden = False + self._was_overriden = False + + self._is_modified = False + self._is_invalid = False + + self._is_nullable = False + self._as_widget = False + self._is_group = False + + self._any_parent_as_widget = None + self._any_parent_is_group = None + + # Parent input + self._parent = None + + # States of inputs + self._state = None + self._child_state = None + + # Attributes where values are stored + self.default_value = NOT_SET + self.studio_value = NOT_SET + self.override_value = NOT_SET + + # Log object + self._log = None + + # Only for develop mode + self.defaults_not_set = False + + def initial_attributes(self, input_data, parent, as_widget): + """Prepare attributes based on entered arguments. + + This method should be same for each item type. Few item types + may require to extend with specific attributes for their case. + """ + self._set_default_attributes() + + self._parent = parent + self._as_widget = as_widget + + self._is_group = input_data.get("is_group", False) + # TODO not implemented yet + self._is_nullable = input_data.get("is_nullable", False) + + any_parent_as_widget = parent.as_widget + if not any_parent_as_widget: + any_parent_as_widget = parent.any_parent_as_widget + + self._any_parent_as_widget = any_parent_as_widget + + any_parent_is_group = parent.is_group + if not any_parent_is_group: + any_parent_is_group = parent.any_parent_is_group + + self._any_parent_is_group = any_parent_is_group + + @property + def develop_mode(self): + """Tool is in develop mode or not. + + Returns: + bool + + """ + return self._parent.develop_mode + + @property + def log(self): + """Auto created logger for debugging.""" + if self._log is None: + self._log = logging.getLogger(self.__class__.__name__) + return self._log + + @property + def had_studio_override(self): + """Item had studio overrides on refresh. + + Use attribute `_had_studio_override` which should be changed only + during methods `update_studio_values` and `update_default_values`. + + Returns: + bool + + """ + return self._had_studio_override + + @property + def has_studio_override(self): + """Item has studio override at the moment. + + With combination of `had_studio_override` is possible to know if item + is modified (not value change). + + Returns: + bool + + """ + return self._has_studio_override or self._parent.has_studio_override + + @property + def as_widget(self): + """Item is used as widget in parent item. + + Returns: + bool + + """ + return self._as_widget + + @property + def any_parent_as_widget(self): + """Any parent of item is used as widget. + + Attribute holding this information is set during creation and + stored to `_any_parent_as_widget`. + + Why is this information useful: If any parent is used as widget then + modifications and override are not important for whole part. + + Returns: + bool + + """ + if self._any_parent_as_widget is None: + return super(SettingObject, self).any_parent_as_widget + return self._any_parent_as_widget + + @property + def is_group(self): + """Item represents key that can be overriden. + + Attribute `is_group` can be set to True only once in item hierarchy. + + Returns: + bool + + """ + return self._is_group + + @property + def any_parent_is_group(self): + """Any parent of item is group. + + Attribute holding this information is set during creation and + stored to `_any_parent_is_group`. + + Why is this information useful: If any parent is group and + the parent is set as overriden, this item is overriden too. + + Returns: + bool + + """ + if self._any_parent_is_group is None: + return super(SettingObject, self).any_parent_is_group + return self._any_parent_is_group + + @property + def is_modified(self): + """Has object any changes that require saving.""" + if self.any_parent_as_widget: + return self._is_modified + + if self._is_modified or self.defaults_not_set: + return True + + if self.is_overidable: + return self.was_overriden != self.is_overriden + else: + return self.has_studio_override != self.had_studio_override + + @property + def is_overriden(self): + """Is object overriden so should be saved to overrides.""" + return self._is_overriden or self._parent.is_overriden + + @property + def was_overriden(self): + """Item had set value of project overrides on project change.""" + if self._as_widget: + return self._parent.was_overriden + return self._was_overriden + + @property + def is_invalid(self): + """Value set in is not valid.""" + return self._is_invalid + + @property + def is_nullable(self): + """Value of item can be set to None. + + NOT IMPLEMENTED! + """ + return self._is_nullable + + @property + def is_overidable(self): + """ care about overrides.""" + + return self._parent.is_overidable + + def any_parent_overriden(self): + """Any of parent objects up to top hiearchy item is overriden. + + Returns: + bool + + """ + + if self._parent._is_overriden: + return True + return self._parent.any_parent_overriden() + + @property + def ignore_value_changes(self): + """Most of attribute changes are ignored on value change when True.""" + return self._parent.ignore_value_changes + + @ignore_value_changes.setter + def ignore_value_changes(self, value): + """Setter for global parent item to apply changes for all inputs.""" + self._parent.ignore_value_changes = value + + def config_value(self): + """Output for saving changes or overrides.""" + return {self.key: self.item_value()} + + @classmethod + def style_state( + cls, has_studio_override, is_invalid, is_overriden, is_modified + ): + """Return stylesheet state by intered booleans.""" + items = [] + if is_invalid: + items.append("invalid") + else: + if is_overriden: + items.append("overriden") + if is_modified: + items.append("modified") + + if not items and has_studio_override: + items.append("studio") + + return "-".join(items) or "" + + def show_actions_menu(self, event=None): + if event and event.button() != QtCore.Qt.RightButton: + return + + if not self.allow_actions: + if event: + return self.mouseReleaseEvent(event) + return + + menu = QtWidgets.QMenu() + + actions_mapping = {} + if self.child_modified: + action = QtWidgets.QAction("Discard changes") + actions_mapping[action] = self._discard_changes + menu.addAction(action) + + if ( + self.is_overidable + and not self.is_overriden + and not self.any_parent_is_group + ): + action = QtWidgets.QAction("Set project override") + actions_mapping[action] = self._set_as_overriden + menu.addAction(action) + + if ( + not self.is_overidable + and ( + self.has_studio_override + ) + ): + action = QtWidgets.QAction("Reset to pype default") + actions_mapping[action] = self._reset_to_pype_default + menu.addAction(action) + + if ( + not self.is_overidable + and not self.is_overriden + and not self.any_parent_is_group + and not self._had_studio_override + ): + action = QtWidgets.QAction("Set studio default") + actions_mapping[action] = self._set_studio_default + menu.addAction(action) + + if ( + not self.any_parent_overriden() + and (self.is_overriden or self.child_overriden) + ): + # TODO better label + action = QtWidgets.QAction("Remove project override") + actions_mapping[action] = self._remove_overrides + menu.addAction(action) + + if not actions_mapping: + action = QtWidgets.QAction("< No action >") + actions_mapping[action] = None + menu.addAction(action) + + result = menu.exec_(QtGui.QCursor.pos()) + if result: + to_run = actions_mapping[result] + if to_run: + to_run() + + def mouseReleaseEvent(self, event): + if self.allow_actions and event.button() == QtCore.Qt.RightButton: + return self.show_actions_menu() + + mro = type(self).mro() + index = mro.index(self.__class__) + item = None + for idx in range(index + 1, len(mro)): + _item = mro[idx] + if hasattr(_item, "mouseReleaseEvent"): + item = _item + break + + if item: + return item.mouseReleaseEvent(self, event) + + def _discard_changes(self): + self.ignore_value_changes = True + self.discard_changes() + self.ignore_value_changes = False + + def discard_changes(self): + """Item's implementation to discard all changes made by user. + + Reset all values to same values as had when opened GUI + or when changed project. + + Must not affect `had_studio_override` value or `was_overriden` + value. It must be marked that there are keys/values which are not in + defaults or overrides. + """ + raise NotImplementedError( + "{} Method `discard_changes` not implemented!".format( + repr(self) + ) + ) + + def _set_studio_default(self): + self.ignore_value_changes = True + self.set_studio_default() + self.ignore_value_changes = False + + def set_studio_default(self): + """Item's implementation to set current values as studio's overrides. + + Mark item and it's children as they have studio overrides. + """ + raise NotImplementedError( + "{} Method `set_studio_default` not implemented!".format( + repr(self) + ) + ) + + def _reset_to_pype_default(self): + self.ignore_value_changes = True + self.reset_to_pype_default() + self.ignore_value_changes = False + + def reset_to_pype_default(self): + """Item's implementation to remove studio overrides. + + Mark item as it does not have studio overrides unset studio + override values. + """ + raise NotImplementedError( + "{} Method `reset_to_pype_default` not implemented!".format( + repr(self) + ) + ) + + def _remove_overrides(self): + self.ignore_value_changes = True + self.remove_overrides() + self.ignore_value_changes = False + + def remove_overrides(self): + """Item's implementation to remove project overrides. + + Mark item as does not have project overrides. Must not change + `was_overriden` attribute value. + """ + raise NotImplementedError( + "{} Method `remove_overrides` not implemented!".format( + repr(self) + ) + ) + + def _set_as_overriden(self): + self.ignore_value_changes = True + self.set_as_overriden() + self.ignore_value_changes = False + + def set_as_overriden(self): + """Item's implementation to set values as overriden for project. + + Mark item and all it's children as they're overriden. Must skip + items with children items that has attributes `is_group` + and `any_parent_is_group` set to False. In that case those items + are not meant to be overridable and should trigger the method on it's + children. + + """ + raise NotImplementedError( + "{} Method `set_as_overriden` not implemented!".format(repr(self)) + ) + + def hierarchical_style_update(self): + """Trigger update style method down the hierarchy.""" + raise NotImplementedError( + "{} Method `hierarchical_style_update` not implemented!".format( + repr(self) + ) + ) + + def update_default_values(self, parent_values): + """Fill default values on startup or on refresh. + + Default values stored in `pype` repository should update all items in + schema. Each item should take values for his key and set it's value or + pass values down to children items. + + Args: + parent_values (dict): Values of parent's item. But in case item is + used as widget, `parent_values` contain value for item. + """ + raise NotImplementedError( + "{} does not have implemented `update_default_values`".format(self) + ) + + def update_studio_values(self, parent_values): + """Fill studio override values on startup or on refresh. + + Set studio value if is not set to NOT_SET, in that case studio + overrides are not set yet. + + Args: + parent_values (dict): Values of parent's item. But in case item is + used as widget, `parent_values` contain value for item. + """ + raise NotImplementedError( + "{} does not have implemented `update_studio_values`".format(self) + ) + + def apply_overrides(self, parent_values): + """Fill project override values on startup, refresh or project change. + + Set project value if is not set to NOT_SET, in that case project + overrides are not set yet. + + Args: + parent_values (dict): Values of parent's item. But in case item is + used as widget, `parent_values` contain value for item. + """ + raise NotImplementedError( + "{} does not have implemented `apply_overrides`".format(self) + ) + + @property + def child_has_studio_override(self): + """Any children item has studio overrides.""" + raise NotImplementedError( + "{} does not have implemented `child_has_studio_override`".format( + self + ) + ) + + @property + def child_modified(self): + """Any children item is modified.""" + raise NotImplementedError( + "{} does not have implemented `child_modified`".format(self) + ) + + @property + def child_overriden(self): + """Any children item has project overrides.""" + raise NotImplementedError( + "{} does not have implemented `child_overriden`".format(self) + ) + + @property + def child_invalid(self): + """Any children item does not have valid value.""" + raise NotImplementedError( + "{} does not have implemented `child_invalid`".format(self) + ) + + def get_invalid(self): + """Return invalid item types all down the hierarchy.""" + raise NotImplementedError( + "{} does not have implemented `get_invalid`".format(self) + ) + + def item_value(self): + """Value of an item without key.""" + raise NotImplementedError( + "Method `item_value` not implemented!" + ) + + +class InputObject(SettingObject): + """Class for inputs with pre-implemented methods. + + Class is for item types not creating or using other item types, most + of methods has same code in that case. + """ + + def update_default_values(self, parent_values): + self._state = None + self._is_modified = False + + value = NOT_SET + if self._as_widget: + value = parent_values + elif parent_values is not NOT_SET: + value = parent_values.get(self.key, NOT_SET) + + if value is NOT_SET: + if self.develop_mode: + self.defaults_not_set = True + value = self.default_input_value + if value is NOT_SET: + raise NotImplementedError(( + "{} Does not have implemented" + " attribute `default_input_value`" + ).format(self)) + + else: + raise ValueError( + "Default value is not set. This is implementation BUG." + ) + else: + self.defaults_not_set = False + + self.default_value = value + self._has_studio_override = False + self._had_studio_override = False + self.set_value(value) + + def update_studio_values(self, parent_values): + self._state = None + self._is_modified = False + + value = NOT_SET + if self._as_widget: + value = parent_values + elif parent_values is not NOT_SET: + value = parent_values.get(self.key, NOT_SET) + + self.studio_value = value + if value is not NOT_SET: + self._has_studio_override = True + self._had_studio_override = True + self.set_value(value) + + else: + self._has_studio_override = False + self._had_studio_override = False + self.set_value(self.default_value) + + def apply_overrides(self, parent_values): + self._is_modified = False + self._state = None + self._had_studio_override = bool(self._has_studio_override) + if self._as_widget: + override_value = parent_values + elif parent_values is NOT_SET or self.key not in parent_values: + override_value = NOT_SET + else: + override_value = parent_values[self.key] + + self.override_value = override_value + + if override_value is NOT_SET: + self._is_overriden = False + self._was_overriden = False + if self.has_studio_override: + value = self.studio_value + else: + value = self.default_value + else: + self._is_overriden = True + self._was_overriden = True + value = override_value + + self.set_value(value) + + def _on_value_change(self, item=None): + if self.ignore_value_changes: + return + + if not self.any_parent_as_widget: + if self.is_overidable: + self._is_overriden = True + else: + self._has_studio_override = True + + if self._is_invalid: + self._is_modified = True + elif self._is_overriden: + self._is_modified = self.item_value() != self.override_value + elif self._has_studio_override: + self._is_modified = self.item_value() != self.studio_value + else: + self._is_modified = self.item_value() != self.default_value + + self.update_style() + + self.value_changed.emit(self) + + def studio_overrides(self): + if ( + not (self.as_widget or self.any_parent_as_widget) + and not self.has_studio_override + ): + return NOT_SET, False + return self.config_value(), self.is_group + + def overrides(self): + if ( + not (self.as_widget or self.any_parent_as_widget) + and not self.is_overriden + ): + return NOT_SET, False + return self.config_value(), self.is_group + + def hierarchical_style_update(self): + self.update_style() + + def remove_overrides(self): + self._is_overriden = False + self._is_modified = False + if self.has_studio_override: + self.set_value(self.studio_value) + else: + self.set_value(self.default_value) + self._is_overriden = False + self._is_modified = False + + def reset_to_pype_default(self): + self.set_value(self.default_value) + self._has_studio_override = False + + def set_studio_default(self): + self._has_studio_override = True + + def discard_changes(self): + self._is_overriden = self._was_overriden + self._has_studio_override = self._had_studio_override + if self.is_overidable: + if self._was_overriden and self.override_value is not NOT_SET: + self.set_value(self.override_value) + else: + if self._had_studio_override: + self.set_value(self.studio_value) + else: + self.set_value(self.default_value) + + if not self.is_overidable: + if self.has_studio_override: + self._is_modified = self.studio_value != self.item_value() + else: + self._is_modified = self.default_value != self.item_value() + self._is_overriden = False + return + + self._is_modified = False + self._is_overriden = self._was_overriden + + def set_as_overriden(self): + self._is_overriden = True + + @property + def child_has_studio_override(self): + return self._has_studio_override + + @property + def child_modified(self): + return self.is_modified + + @property + def child_overriden(self): + return self._is_overriden + + @property + def child_invalid(self): + return self.is_invalid + + def get_invalid(self): + output = [] + if self.is_invalid: + output.append(self) + return output + + def reset_children_attributes(self): + return + + +class BooleanWidget(QtWidgets.QWidget, InputObject): + default_input_value = True + value_changed = QtCore.Signal(object) + + def __init__( + self, input_data, parent, + as_widget=False, label_widget=None, parent_widget=None + ): + if parent_widget is None: + parent_widget = parent + super(BooleanWidget, self).__init__(parent_widget) + + self.initial_attributes(input_data, parent, as_widget) + + layout = QtWidgets.QHBoxLayout(self) + layout.setContentsMargins(0, 0, 0, 0) + layout.setSpacing(5) + + if not self._as_widget: + self.key = input_data["key"] + if not label_widget: + label = input_data["label"] + label_widget = QtWidgets.QLabel(label) + label_widget.setAttribute(QtCore.Qt.WA_TranslucentBackground) + layout.addWidget(label_widget, 0) + self.label_widget = label_widget + + self.checkbox = QtWidgets.QCheckBox(self) + spacer = QtWidgets.QWidget(self) + layout.addWidget(self.checkbox, 0) + layout.addWidget(spacer, 1) + + spacer.setAttribute(QtCore.Qt.WA_TranslucentBackground) + + self.setFocusProxy(self.checkbox) + + self.checkbox.stateChanged.connect(self._on_value_change) + + def set_value(self, value): + # Ignore value change because if `self.isChecked()` has same + # value as `value` the `_on_value_change` is not triggered + self.checkbox.setChecked(value) + + def update_style(self): + if self._as_widget: + if not self.isEnabled(): + state = self.style_state(False, False, False, False) + else: + state = self.style_state( + False, + self._is_invalid, + False, + self._is_modified + ) + else: + state = self.style_state( + self.has_studio_override, + self.is_invalid, + self.is_overriden, + self.is_modified + ) + if self._state == state: + return + + if self._as_widget: + property_name = "input-state" + else: + property_name = "state" + + self.label_widget.setProperty(property_name, state) + self.label_widget.style().polish(self.label_widget) + self._state = state + + def item_value(self): + return self.checkbox.isChecked() + + +class NumberWidget(QtWidgets.QWidget, InputObject): + default_input_value = 0 + value_changed = QtCore.Signal(object) + input_modifiers = ("minimum", "maximum", "decimal") + + def __init__( + self, input_data, parent, + as_widget=False, label_widget=None, parent_widget=None + ): + if parent_widget is None: + parent_widget = parent + super(NumberWidget, self).__init__(parent_widget) + + self.initial_attributes(input_data, parent, as_widget) + + layout = QtWidgets.QHBoxLayout(self) + layout.setContentsMargins(0, 0, 0, 0) + layout.setSpacing(5) + + kwargs = { + modifier: input_data.get(modifier) + for modifier in self.input_modifiers + if input_data.get(modifier) + } + self.input_field = NumberSpinBox(self, **kwargs) + + self.setFocusProxy(self.input_field) + + if not self._as_widget: + self.key = input_data["key"] + if not label_widget: + label = input_data["label"] + label_widget = QtWidgets.QLabel(label) + layout.addWidget(label_widget, 0) + self.label_widget = label_widget + + layout.addWidget(self.input_field, 1) + + self.input_field.valueChanged.connect(self._on_value_change) + + def set_value(self, value): + self.input_field.setValue(value) + + def update_style(self): + if self._as_widget: + if not self.isEnabled(): + state = self.style_state(False, False, False, False) + else: + state = self.style_state( + False, + self._is_invalid, + False, + self._is_modified + ) + else: + state = self.style_state( + self.has_studio_override, + self.is_invalid, + self.is_overriden, + self.is_modified + ) + if self._state == state: + return + + if self._as_widget: + property_name = "input-state" + widget = self.input_field + else: + property_name = "state" + widget = self.label_widget + + widget.setProperty(property_name, state) + widget.style().polish(widget) + + def item_value(self): + return self.input_field.value() + + +class TextWidget(QtWidgets.QWidget, InputObject): + default_input_value = "" + value_changed = QtCore.Signal(object) + + def __init__( + self, input_data, parent, + as_widget=False, label_widget=None, parent_widget=None + ): + if parent_widget is None: + parent_widget = parent + super(TextWidget, self).__init__(parent_widget) + + self.initial_attributes(input_data, parent, as_widget) + + self.multiline = input_data.get("multiline", False) + placeholder = input_data.get("placeholder") + + layout = QtWidgets.QHBoxLayout(self) + layout.setContentsMargins(0, 0, 0, 0) + layout.setSpacing(5) + + if self.multiline: + self.text_input = QtWidgets.QPlainTextEdit(self) + else: + self.text_input = QtWidgets.QLineEdit(self) + + if placeholder: + self.text_input.setPlaceholderText(placeholder) + + self.setFocusProxy(self.text_input) + + layout_kwargs = {} + if self.multiline: + layout_kwargs["alignment"] = QtCore.Qt.AlignTop + + if not self._as_widget: + self.key = input_data["key"] + if not label_widget: + label = input_data["label"] + label_widget = QtWidgets.QLabel(label) + layout.addWidget(label_widget, 0, **layout_kwargs) + self.label_widget = label_widget + + layout.addWidget(self.text_input, 1, **layout_kwargs) + + self.text_input.textChanged.connect(self._on_value_change) + + def set_value(self, value): + if self.multiline: + self.text_input.setPlainText(value) + else: + self.text_input.setText(value) + + def update_style(self): + if self._as_widget: + if not self.isEnabled(): + state = self.style_state(False, False, False, False) + else: + state = self.style_state( + False, + self._is_invalid, + False, + self._is_modified + ) + else: + state = self.style_state( + self.has_studio_override, + self.is_invalid, + self.is_overriden, + self.is_modified + ) + + if self._state == state: + return + + if self._as_widget: + property_name = "input-state" + widget = self.text_input + else: + property_name = "state" + widget = self.label_widget + + widget.setProperty(property_name, state) + widget.style().polish(widget) + + def item_value(self): + if self.multiline: + return self.text_input.toPlainText() + else: + return self.text_input.text() + + +class PathInputWidget(QtWidgets.QWidget, InputObject): + default_input_value = "" + value_changed = QtCore.Signal(object) + + def __init__( + self, input_data, parent, + as_widget=False, label_widget=None, parent_widget=None + ): + if parent_widget is None: + parent_widget = parent + super(PathInputWidget, self).__init__(parent_widget) + + self.initial_attributes(input_data, parent, as_widget) + + layout = QtWidgets.QHBoxLayout(self) + layout.setContentsMargins(0, 0, 0, 0) + layout.setSpacing(5) + + if not self._as_widget: + self.key = input_data["key"] + if not label_widget: + label = input_data["label"] + label_widget = QtWidgets.QLabel(label) + layout.addWidget(label_widget, 0) + self.label_widget = label_widget + + self.path_input = PathInput(self) + self.setFocusProxy(self.path_input) + layout.addWidget(self.path_input, 1) + + self.path_input.textChanged.connect(self._on_value_change) + + def set_value(self, value): + self.path_input.setText(value) + + def focusOutEvent(self, event): + self.path_input.clear_end_path() + super(PathInput, self).focusOutEvent(event) + + def update_style(self): + if self._as_widget: + if not self.isEnabled(): + state = self.style_state(False, False, False, False) + else: + state = self.style_state( + False, + self._is_invalid, + False, + self._is_modified + ) + else: + state = self.style_state( + self.has_studio_override, + self.is_invalid, + self.is_overriden, + self.is_modified + ) + + if self._state == state: + return + + if self._as_widget: + property_name = "input-state" + widget = self.path_input + else: + property_name = "state" + widget = self.label_widget + + widget.setProperty(property_name, state) + widget.style().polish(widget) + + def item_value(self): + return self.path_input.text() + + +class RawJsonInput(QtWidgets.QPlainTextEdit): + tab_length = 4 + + def __init__(self, *args, **kwargs): + super(RawJsonInput, self).__init__(*args, **kwargs) + self.setObjectName("RawJsonInput") + self.setTabStopDistance( + QtGui.QFontMetricsF( + self.font() + ).horizontalAdvance(" ") * self.tab_length + ) + + def sizeHint(self): + document = self.document() + layout = document.documentLayout() + + height = document.documentMargin() + 2 * self.frameWidth() + 1 + block = document.begin() + while block != document.end(): + height += layout.blockBoundingRect(block).height() + block = block.next() + + hint = super(RawJsonInput, self).sizeHint() + hint.setHeight(height) + + return hint + + def set_value(self, value): + if value is NOT_SET: + value = "" + elif not isinstance(value, str): + try: + value = json.dumps(value, indent=4) + except Exception: + value = "" + self.setPlainText(value) + + def json_value(self): + return json.loads(self.toPlainText()) + + def has_invalid_value(self): + try: + self.json_value() + return False + except Exception: + return True + + def resizeEvent(self, event): + self.updateGeometry() + super(RawJsonInput, self).resizeEvent(event) + + +class RawJsonWidget(QtWidgets.QWidget, InputObject): + default_input_value = "{}" + value_changed = QtCore.Signal(object) + + def __init__( + self, input_data, parent, + as_widget=False, label_widget=None, parent_widget=None + ): + if parent_widget is None: + parent_widget = parent + super(RawJsonWidget, self).__init__(parent_widget) + + self.initial_attributes(input_data, parent, as_widget) + + layout = QtWidgets.QHBoxLayout(self) + layout.setContentsMargins(0, 0, 0, 0) + layout.setSpacing(5) + + self.text_input = RawJsonInput(self) + self.text_input.setSizePolicy( + QtWidgets.QSizePolicy.Minimum, + QtWidgets.QSizePolicy.MinimumExpanding + ) + + self.setFocusProxy(self.text_input) + + if not self._as_widget: + self.key = input_data["key"] + if not label_widget: + label = input_data["label"] + label_widget = QtWidgets.QLabel(label) + layout.addWidget(label_widget, 0, alignment=QtCore.Qt.AlignTop) + self.label_widget = label_widget + layout.addWidget(self.text_input, 1, alignment=QtCore.Qt.AlignTop) + + self.text_input.textChanged.connect(self._on_value_change) + + def update_studio_values(self, parent_values): + self._is_invalid = self.text_input.has_invalid_value() + return super(RawJsonWidget, self).update_studio_values(parent_values) + + def set_value(self, value): + self.text_input.set_value(value) + + def _on_value_change(self, *args, **kwargs): + self._is_invalid = self.text_input.has_invalid_value() + return super(RawJsonWidget, self)._on_value_change(*args, **kwargs) + + def update_style(self): + if self._as_widget: + if not self.isEnabled(): + state = self.style_state(False, False, False, False) + else: + state = self.style_state( + False, + self._is_invalid, + False, + self._is_modified + ) + else: + state = self.style_state( + self.has_studio_override, + self.is_invalid, + self.is_overriden, + self.is_modified + ) + + if self._state == state: + return + + if self._as_widget: + property_name = "input-state" + widget = self.text_input + else: + property_name = "state" + widget = self.label_widget + + widget.setProperty(property_name, state) + widget.style().polish(widget) + + def item_value(self): + if self.is_invalid: + return NOT_SET + return self.text_input.json_value() + + +class ListItem(QtWidgets.QWidget, SettingObject): + _btn_size = 20 + value_changed = QtCore.Signal(object) + + def __init__( + self, object_type, input_modifiers, config_parent, parent, + is_strict=False + ): + super(ListItem, self).__init__(parent) + + self._set_default_attributes() + + self._is_strict = is_strict + + self._parent = config_parent + self._any_parent_is_group = True + self._is_empty = False + + layout = QtWidgets.QHBoxLayout(self) + layout.setContentsMargins(0, 0, 0, 0) + layout.setSpacing(3) + + char_up = qtawesome.charmap("fa.angle-up") + char_down = qtawesome.charmap("fa.angle-down") + + if not self._is_strict: + self.add_btn = QtWidgets.QPushButton("+") + self.remove_btn = QtWidgets.QPushButton("-") + self.up_btn = QtWidgets.QPushButton(char_up) + self.down_btn = QtWidgets.QPushButton(char_down) + + font_up_down = qtawesome.font("fa", 13) + self.up_btn.setFont(font_up_down) + self.down_btn.setFont(font_up_down) + + self.add_btn.setFocusPolicy(QtCore.Qt.ClickFocus) + self.remove_btn.setFocusPolicy(QtCore.Qt.ClickFocus) + self.up_btn.setFocusPolicy(QtCore.Qt.ClickFocus) + self.down_btn.setFocusPolicy(QtCore.Qt.ClickFocus) + + self.add_btn.setFixedSize(self._btn_size, self._btn_size) + self.remove_btn.setFixedSize(self._btn_size, self._btn_size) + self.up_btn.setFixedSize(self._btn_size, self._btn_size) + self.down_btn.setFixedSize(self._btn_size, self._btn_size) + + self.add_btn.setProperty("btn-type", "tool-item") + self.remove_btn.setProperty("btn-type", "tool-item") + self.up_btn.setProperty("btn-type", "tool-item") + self.down_btn.setProperty("btn-type", "tool-item") + + self.add_btn.clicked.connect(self._on_add_clicked) + self.remove_btn.clicked.connect(self._on_remove_clicked) + self.up_btn.clicked.connect(self._on_up_clicked) + self.down_btn.clicked.connect(self._on_down_clicked) + + layout.addWidget(self.add_btn, 0) + layout.addWidget(self.remove_btn, 0) + + ItemKlass = TypeToKlass.types[object_type] + self.value_input = ItemKlass( + input_modifiers, + self, + as_widget=True, + label_widget=None + ) + + layout.addWidget(self.value_input, 1) + + if not self._is_strict: + self.spacer_widget = QtWidgets.QWidget(self) + self.spacer_widget.setAttribute(QtCore.Qt.WA_TranslucentBackground) + self.spacer_widget.setVisible(False) + + layout.addWidget(self.spacer_widget, 1) + + layout.addWidget(self.up_btn, 0) + layout.addWidget(self.down_btn, 0) + + self.value_input.value_changed.connect(self._on_value_change) + + @property + def as_widget(self): + return self._parent.as_widget + + @property + def any_parent_as_widget(self): + return self.as_widget or self._parent.any_parent_as_widget + + def set_as_empty(self, is_empty=True): + self._is_empty = is_empty + + self.spacer_widget.setVisible(is_empty) + self.value_input.setVisible(not is_empty) + self.remove_btn.setEnabled(not is_empty) + self.up_btn.setVisible(not is_empty) + self.down_btn.setVisible(not is_empty) + self.order_changed() + self._on_value_change() + + def order_changed(self): + row = self.row() + parent_row_count = self.parent_rows_count() + if parent_row_count == 1: + self.up_btn.setVisible(False) + self.down_btn.setVisible(False) + return + + if not self.up_btn.isVisible(): + self.up_btn.setVisible(True) + self.down_btn.setVisible(True) + + if row == 0: + self.up_btn.setEnabled(False) + self.down_btn.setEnabled(True) + + elif row == parent_row_count - 1: + self.up_btn.setEnabled(True) + self.down_btn.setEnabled(False) + + else: + self.up_btn.setEnabled(True) + self.down_btn.setEnabled(True) + + def _on_value_change(self, item=None): + self.value_changed.emit(self) + + def row(self): + return self._parent.input_fields.index(self) + + def parent_rows_count(self): + return len(self._parent.input_fields) + + def _on_add_clicked(self): + if self._is_empty: + self.set_as_empty(False) + else: + self._parent.add_row(row=self.row() + 1) + + def _on_remove_clicked(self): + self._parent.remove_row(self) + + def _on_up_clicked(self): + row = self.row() + self._parent.swap_rows(row - 1, row) + + def _on_down_clicked(self): + row = self.row() + self._parent.swap_rows(row, row + 1) + + def config_value(self): + if not self._is_empty: + return self.value_input.item_value() + return NOT_SET + + @property + def child_has_studio_override(self): + return self.value_input.child_has_studio_override + + @property + def child_modified(self): + return self.value_input.child_modified + + @property + def child_overriden(self): + return self.value_input.child_overriden + + def hierarchical_style_update(self): + self.value_input.hierarchical_style_update() + + def mouseReleaseEvent(self, event): + return QtWidgets.QWidget.mouseReleaseEvent(self, event) + + def update_default_values(self, value): + self.value_input.update_default_values(value) + + def update_studio_values(self, value): + self.value_input.update_studio_values(value) + + def apply_overrides(self, value): + self.value_input.apply_overrides(value) + + +class ListWidget(QtWidgets.QWidget, InputObject): + default_input_value = [] + value_changed = QtCore.Signal(object) + + def __init__( + self, input_data, parent, + as_widget=False, label_widget=None, parent_widget=None + ): + if parent_widget is None: + parent_widget = parent + super(ListWidget, self).__init__(parent_widget) + self.setObjectName("ListWidget") + + self.initial_attributes(input_data, parent, as_widget) + + self.object_type = input_data["object_type"] + self.input_modifiers = input_data.get("input_modifiers") or {} + + self.input_fields = [] + + layout = QtWidgets.QHBoxLayout(self) + layout.setContentsMargins(0, 0, 0, 5) + layout.setSpacing(5) + + if not self.as_widget: + self.key = input_data["key"] + if not label_widget: + label_widget = QtWidgets.QLabel(input_data["label"], self) + layout.addWidget(label_widget, alignment=QtCore.Qt.AlignTop) + + self.label_widget = label_widget + + inputs_widget = QtWidgets.QWidget(self) + inputs_widget.setAttribute(QtCore.Qt.WA_TranslucentBackground) + layout.addWidget(inputs_widget) + + inputs_layout = QtWidgets.QVBoxLayout(inputs_widget) + inputs_layout.setContentsMargins(0, 0, 0, 0) + inputs_layout.setSpacing(3) + + self.inputs_widget = inputs_widget + self.inputs_layout = inputs_layout + + self.add_row(is_empty=True) + + def count(self): + return len(self.input_fields) + + def update_studio_values(self, parent_values): + super(ListWidget, self).update_studio_values(parent_values) + + self.hierarchical_style_update() + + def set_value(self, value): + previous_inputs = tuple(self.input_fields) + for item_value in value: + self.add_row(value=item_value) + + for input_field in previous_inputs: + self.remove_row(input_field) + + if self.count() == 0: + self.add_row(is_empty=True) + + def swap_rows(self, row_1, row_2): + if row_1 == row_2: + return + + if row_1 > row_2: + row_1, row_2 = row_2, row_1 + + field_1 = self.input_fields[row_1] + field_2 = self.input_fields[row_2] + + self.input_fields[row_1] = field_2 + self.input_fields[row_2] = field_1 + + layout_index = self.inputs_layout.indexOf(field_1) + self.inputs_layout.insertWidget(layout_index + 1, field_1) + + field_1.order_changed() + field_2.order_changed() + + def add_row(self, row=None, value=None, is_empty=False): + # Create new item + item_widget = ListItem( + self.object_type, self.input_modifiers, self, self.inputs_widget + ) + + previous_field = None + next_field = None + + if row is None: + if self.input_fields: + previous_field = self.input_fields[-1] + self.inputs_layout.addWidget(item_widget) + self.input_fields.append(item_widget) + else: + if row > 0: + previous_field = self.input_fields[row - 1] + + max_index = self.count() + if row < max_index: + next_field = self.input_fields[row] + + self.inputs_layout.insertWidget(row, item_widget) + self.input_fields.insert(row, item_widget) + + if previous_field: + previous_field.order_changed() + + if next_field: + next_field.order_changed() + + if is_empty: + item_widget.set_as_empty() + item_widget.value_changed.connect(self._on_value_change) + + item_widget.order_changed() + + previous_input = None + for input_field in self.input_fields: + if previous_input is not None: + self.setTabOrder( + previous_input, input_field.value_input.focusProxy() + ) + previous_input = input_field.value_input.focusProxy() + + # Set text if entered text is not None + # else (when add button clicked) trigger `_on_value_change` + if value is not None: + if self._is_overriden: + item_widget.apply_overrides(value) + elif not self._has_studio_override: + item_widget.update_default_values(value) + else: + item_widget.update_studio_values(value) + self.hierarchical_style_update() + else: + self._on_value_change() + self.updateGeometry() + + def remove_row(self, item_widget): + item_widget.value_changed.disconnect() + + row = self.input_fields.index(item_widget) + previous_field = None + next_field = None + if row > 0: + previous_field = self.input_fields[row - 1] + + if row != len(self.input_fields) - 1: + next_field = self.input_fields[row + 1] + + self.inputs_layout.removeWidget(item_widget) + self.input_fields.pop(row) + item_widget.setParent(None) + item_widget.deleteLater() + + if previous_field: + previous_field.order_changed() + + if next_field: + next_field.order_changed() + + if self.count() == 0: + self.add_row(is_empty=True) + + self._on_value_change() + self.updateGeometry() + + def apply_overrides(self, parent_values): + self._is_modified = False + if parent_values is NOT_SET or self.key not in parent_values: + override_value = NOT_SET + else: + override_value = parent_values[self.key] + + self.override_value = override_value + + if override_value is NOT_SET: + self._is_overriden = False + self._was_overriden = False + if self.has_studio_override: + value = self.studio_value + else: + value = self.default_value + else: + self._is_overriden = True + self._was_overriden = True + value = override_value + + self._is_modified = False + self._state = None + + self.set_value(value) + + def hierarchical_style_update(self): + for input_field in self.input_fields: + input_field.hierarchical_style_update() + self.update_style() + + def update_style(self): + if self._as_widget: + if not self.isEnabled(): + state = self.style_state(False, False, False, False) + else: + state = self.style_state( + False, + self._is_invalid, + False, + self._is_modified + ) + else: + state = self.style_state( + self.has_studio_override, + self.is_invalid, + self.is_overriden, + self.is_modified + ) + if self._state == state: + return + + if self.label_widget: + self.label_widget.setProperty("state", state) + self.label_widget.style().polish(self.label_widget) + + def item_value(self): + output = [] + for item in self.input_fields: + value = item.config_value() + if value is not NOT_SET: + output.append(value) + return output + + +class ListStrictWidget(QtWidgets.QWidget, InputObject): + value_changed = QtCore.Signal(object) + _default_input_value = None + + def __init__( + self, input_data, parent, + as_widget=False, label_widget=None, parent_widget=None + ): + if parent_widget is None: + parent_widget = parent + super(ListStrictWidget, self).__init__(parent_widget) + self.setObjectName("ListStrictWidget") + + self.initial_attributes(input_data, parent, as_widget) + + self.input_fields = [] + + layout = QtWidgets.QHBoxLayout(self) + layout.setContentsMargins(0, 0, 0, 5) + layout.setSpacing(5) + + if not self.as_widget: + self.key = input_data["key"] + if not label_widget: + label_widget = QtWidgets.QLabel(input_data["label"], self) + layout.addWidget(label_widget, alignment=QtCore.Qt.AlignTop) + + self.label_widget = label_widget + + self._add_children(layout, input_data) + + def _add_children(self, layout, input_data): + inputs_widget = QtWidgets.QWidget(self) + inputs_widget.setAttribute(QtCore.Qt.WA_TranslucentBackground) + layout.addWidget(inputs_widget) + + horizontal = input_data.get("horizontal", True) + if horizontal: + inputs_layout = QtWidgets.QHBoxLayout(inputs_widget) + else: + inputs_layout = QtWidgets.QGridLayout(inputs_widget) + + inputs_layout.setContentsMargins(0, 0, 0, 0) + inputs_layout.setSpacing(3) + + self.inputs_widget = inputs_widget + self.inputs_layout = inputs_layout + + children_item_mapping = [] + for child_configuration in input_data["object_types"]: + object_type = child_configuration["type"] + + item_widget = ListItem( + object_type, child_configuration, self, self.inputs_widget, + is_strict=True + ) + + self.input_fields.append(item_widget) + item_widget.value_changed.connect(self._on_value_change) + + label = child_configuration.get("label") + label_widget = None + if label: + label_widget = QtWidgets.QLabel(label, self) + + children_item_mapping.append((label_widget, item_widget)) + + if horizontal: + self._add_children_horizontally(children_item_mapping) + else: + self._add_children_vertically(children_item_mapping) + + self.updateGeometry() + + def _add_children_vertically(self, children_item_mapping): + any_has_label = False + for item_mapping in children_item_mapping: + if item_mapping[0]: + any_has_label = True + break + + row = self.inputs_layout.count() + if not any_has_label: + self.inputs_layout.setColumnStretch(1, 1) + for item_mapping in children_item_mapping: + item_widget = item_mapping[1] + self.inputs_layout.addWidget(item_widget, row, 0, 1, 1) + + spacer_widget = QtWidgets.QWidget(self.inputs_widget) + self.inputs_layout.addWidget(spacer_widget, row, 1, 1, 1) + row += 1 + + else: + self.inputs_layout.setColumnStretch(2, 1) + for label_widget, item_widget in children_item_mapping: + self.inputs_layout.addWidget( + label_widget, row, 0, 1, 1, + alignment=QtCore.Qt.AlignRight | QtCore.Qt.AlignTop + ) + self.inputs_layout.addWidget(item_widget, row, 1, 1, 1) + + spacer_widget = QtWidgets.QWidget(self.inputs_widget) + self.inputs_layout.addWidget(spacer_widget, row, 2, 1, 1) + row += 1 + + def _add_children_horizontally(self, children_item_mapping): + for label_widget, item_widget in children_item_mapping: + if label_widget: + self.inputs_layout.addWidget(label_widget, 0) + self.inputs_layout.addWidget(item_widget, 0) + + spacer_widget = QtWidgets.QWidget(self.inputs_widget) + self.inputs_layout.addWidget(spacer_widget, 1) + + @property + def default_input_value(self): + if self._default_input_value is None: + self.set_value(NOT_SET) + self._default_input_value = self.item_value() + return self._default_input_value + + def set_value(self, value): + if self._is_overriden: + method_name = "apply_overrides" + elif not self._has_studio_override: + method_name = "update_default_values" + else: + method_name = "update_studio_values" + + for idx, input_field in enumerate(self.input_fields): + if value is NOT_SET: + _value = value + else: + if idx > len(value) - 1: + _value = NOT_SET + else: + _value = value[idx] + _method = getattr(input_field, method_name) + _method(_value) + + def hierarchical_style_update(self): + for input_field in self.input_fields: + input_field.hierarchical_style_update() + self.update_style() + + def update_style(self): + if self._as_widget: + if not self.isEnabled(): + state = self.style_state(False, False, False, False) + else: + state = self.style_state( + False, + self._is_invalid, + False, + self._is_modified + ) + else: + state = self.style_state( + self.has_studio_override, + self.is_invalid, + self.is_overriden, + self.is_modified + ) + + if self._state == state: + return + + if self.label_widget: + self.label_widget.setProperty("state", state) + self.label_widget.style().polish(self.label_widget) + + def item_value(self): + output = [] + for item in self.input_fields: + output.append(item.config_value()) + return output + + +class ModifiableDictItem(QtWidgets.QWidget, SettingObject): + _btn_size = 20 + value_changed = QtCore.Signal(object) + + def __init__(self, object_type, input_modifiers, config_parent, parent): + super(ModifiableDictItem, self).__init__(parent) + + self._set_default_attributes() + self._parent = config_parent + + any_parent_as_widget = config_parent.as_widget + if not any_parent_as_widget: + any_parent_as_widget = config_parent.any_parent_as_widget + + self._any_parent_as_widget = any_parent_as_widget + self._any_parent_is_group = True + + self._is_empty = False + self.is_key_duplicated = False + + layout = QtWidgets.QHBoxLayout(self) + layout.setContentsMargins(0, 0, 0, 0) + layout.setSpacing(3) + + ItemKlass = TypeToKlass.types[object_type] + + self.key_input = QtWidgets.QLineEdit(self) + self.key_input.setObjectName("DictKey") + + self.value_input = ItemKlass( + input_modifiers, + self, + as_widget=True, + label_widget=None + ) + self.add_btn = QtWidgets.QPushButton("+") + self.remove_btn = QtWidgets.QPushButton("-") + + self.add_btn.setFocusPolicy(QtCore.Qt.ClickFocus) + self.remove_btn.setFocusPolicy(QtCore.Qt.ClickFocus) + + self.add_btn.setProperty("btn-type", "tool-item") + self.remove_btn.setProperty("btn-type", "tool-item") + + self.spacer_widget = QtWidgets.QWidget(self) + self.spacer_widget.setAttribute(QtCore.Qt.WA_TranslucentBackground) + self.spacer_widget.setVisible(False) + + layout.addWidget(self.add_btn, 0) + layout.addWidget(self.remove_btn, 0) + layout.addWidget(self.key_input, 0) + layout.addWidget(self.spacer_widget, 1) + layout.addWidget(self.value_input, 1) + + self.setFocusProxy(self.value_input) + + self.add_btn.setFixedSize(self._btn_size, self._btn_size) + self.remove_btn.setFixedSize(self._btn_size, self._btn_size) + self.add_btn.clicked.connect(self.on_add_clicked) + self.remove_btn.clicked.connect(self.on_remove_clicked) + + self.key_input.textChanged.connect(self._on_value_change) + self.value_input.value_changed.connect(self._on_value_change) + + self.origin_key = NOT_SET + + def key_value(self): + return self.key_input.text() + + def is_key_invalid(self): + if self._is_empty: + return False + + if self.key_value() == "": + return True + + if self.is_key_duplicated: + return True + return False + + def _on_value_change(self, item=None): + self.update_style() + self.value_changed.emit(self) + + def update_default_values(self, key, value): + self.origin_key = key + self.key_input.setText(key) + self.value_input.update_default_values(value) + + def update_studio_values(self, key, value): + self.origin_key = key + self.key_input.setText(key) + self.value_input.update_studio_values(value) + + def apply_overrides(self, key, value): + self.origin_key = key + self.key_input.setText(key) + self.value_input.apply_overrides(value) + + @property + def is_group(self): + return self._parent.is_group + + def on_add_clicked(self): + if self._is_empty: + self.set_as_empty(False) + else: + self._parent.add_row(row=self.row() + 1) + + def on_remove_clicked(self): + self._parent.remove_row(self) + + def set_as_empty(self, is_empty=True): + self._is_empty = is_empty + + self.key_input.setVisible(not is_empty) + self.value_input.setVisible(not is_empty) + self.remove_btn.setEnabled(not is_empty) + self.spacer_widget.setVisible(is_empty) + self._on_value_change() + + @property + def any_parent_is_group(self): + return self._parent.any_parent_is_group + + def is_key_modified(self): + return self.key_value() != self.origin_key + + def is_value_modified(self): + return self.value_input.is_modified + + @property + def is_modified(self): + return self.is_value_modified() or self.is_key_modified() + + def hierarchical_style_update(self): + self.value_input.hierarchical_style_update() + self.update_style() + + @property + def is_invalid(self): + if self._is_empty: + return False + return self.is_key_invalid() or self.value_input.is_invalid + + def update_style(self): + state = "" + if not self._is_empty: + if self.is_key_invalid(): + state = "invalid" + elif self.is_key_modified(): + state = "modified" + + self.key_input.setProperty("state", state) + self.key_input.style().polish(self.key_input) + + def row(self): + return self._parent.input_fields.index(self) + + def item_value(self): + key = self.key_input.text() + value = self.value_input.item_value() + return {key: value} + + def config_value(self): + if self._is_empty: + return {} + return self.item_value() + + def mouseReleaseEvent(self, event): + return QtWidgets.QWidget.mouseReleaseEvent(self, event) + + +class ModifiableDict(QtWidgets.QWidget, InputObject): + default_input_value = {} + # Should be used only for dictionary with one datatype as value + # TODO this is actually input field (do not care if is group or not) + value_changed = QtCore.Signal(object) + expand_in_grid = True + + def __init__( + self, input_data, parent, + as_widget=False, label_widget=None, parent_widget=None + ): + if parent_widget is None: + parent_widget = parent + super(ModifiableDict, self).__init__(parent_widget) + self.setObjectName("ModifiableDict") + + self.initial_attributes(input_data, parent, as_widget) + + self.input_fields = [] + + self.key = input_data["key"] + + if input_data.get("highlight_content", False): + content_state = "hightlighted" + bottom_margin = 5 + else: + content_state = "" + bottom_margin = 0 + + main_layout = QtWidgets.QHBoxLayout(self) + main_layout.setContentsMargins(0, 0, 0, 0) + main_layout.setSpacing(0) + + if as_widget: + body_widget = None + else: + body_widget = ExpandingWidget(input_data["label"], self) + main_layout.addWidget(body_widget) + + self.body_widget = body_widget + self.label_widget = body_widget.label_widget + + collapsable = input_data.get("collapsable", True) + if collapsable: + collapsed = input_data.get("collapsed", True) + if not collapsed: + body_widget.toggle_content() + + else: + body_widget.hide_toolbox(hide_content=False) + + if body_widget is None: + content_parent_widget = self + else: + content_parent_widget = body_widget + + content_widget = QtWidgets.QWidget(content_parent_widget) + content_widget.setObjectName("ContentWidget") + content_widget.setProperty("content_state", content_state) + content_layout = QtWidgets.QVBoxLayout(content_widget) + content_layout.setContentsMargins(CHILD_OFFSET, 3, 0, bottom_margin) + + if body_widget is None: + main_layout.addWidget(content_widget) + else: + body_widget.set_content_widget(content_widget) + + self.body_widget = body_widget + self.content_widget = content_widget + self.content_layout = content_layout + + self.setAttribute(QtCore.Qt.WA_TranslucentBackground) + + self.object_type = input_data["object_type"] + self.input_modifiers = input_data.get("input_modifiers") or {} + + self.add_row(is_empty=True) + + def count(self): + return len(self.input_fields) + + def set_value(self, value): + previous_inputs = tuple(self.input_fields) + for item_key, item_value in value.items(): + self.add_row(key=item_key, value=item_value) + + for input_field in previous_inputs: + self.remove_row(input_field) + + if self.count() == 0: + self.add_row(is_empty=True) + + def _on_value_change(self, item=None): + if self.ignore_value_changes: + return + + fields_by_keys = collections.defaultdict(list) + for input_field in self.input_fields: + key = input_field.key_value() + fields_by_keys[key].append(input_field) + + for fields in fields_by_keys.values(): + if len(fields) == 1: + field = fields[0] + if field.is_key_duplicated: + field.is_key_duplicated = False + field.update_style() + else: + for field in fields: + field.is_key_duplicated = True + field.update_style() + + if self.is_overidable: + self._is_overriden = True + else: + self._has_studio_override = True + + if self._is_invalid: + self._is_modified = True + elif self._is_overriden: + self._is_modified = self.item_value() != self.override_value + elif self._has_studio_override: + self._is_modified = self.item_value() != self.studio_value + else: + self._is_modified = self.item_value() != self.default_value + + self.update_style() + + self.value_changed.emit(self) + + def hierarchical_style_update(self): + for input_field in self.input_fields: + input_field.hierarchical_style_update() + self.update_style() + + def update_style(self): + if self._as_widget: + if not self.isEnabled(): + state = self.style_state(False, False, False, False) + else: + state = self.style_state( + False, + self.is_invalid, + False, + self._is_modified + ) + else: + state = self.style_state( + self.has_studio_override, + self.is_invalid, + self.is_overriden, + self.is_modified + ) + if self._state == state: + return + + if state: + child_state = "child-{}".format(state) + else: + child_state = "" + + if self.body_widget: + self.body_widget.side_line_widget.setProperty("state", child_state) + self.body_widget.side_line_widget.style().polish( + self.body_widget.side_line_widget + ) + + if not self._as_widget: + self.label_widget.setProperty("state", state) + self.label_widget.style().polish(self.label_widget) + + self._state = state + + def all_item_values(self): + output = {} + for item in self.input_fields: + output.update(item.item_value()) + return output + + def item_value(self): + output = {} + for item in self.input_fields: + output.update(item.config_value()) + return output + + def add_row(self, row=None, key=None, value=None, is_empty=False): + # Create new item + item_widget = ModifiableDictItem( + self.object_type, self.input_modifiers, self, self.content_widget + ) + if is_empty: + item_widget.set_as_empty() + + item_widget.value_changed.connect(self._on_value_change) + + if row is None: + self.content_layout.addWidget(item_widget) + self.input_fields.append(item_widget) + else: + self.content_layout.insertWidget(row, item_widget) + self.input_fields.insert(row, item_widget) + + previous_input = None + for input_field in self.input_fields: + if previous_input is not None: + self.setTabOrder( + previous_input, input_field.key_input + ) + previous_input = input_field.value_input.focusProxy() + self.setTabOrder( + input_field.key_input, previous_input + ) + + # Set value if entered value is not None + # else (when add button clicked) trigger `_on_value_change` + if value is not None and key is not None: + if not self._has_studio_override: + item_widget.update_default_values(key, value) + elif self._is_overriden: + item_widget.apply_overrides(key, value) + else: + item_widget.update_studio_values(key, value) + self.hierarchical_style_update() + else: + self._on_value_change() + self.parent().updateGeometry() + + def remove_row(self, item_widget): + item_widget.value_changed.disconnect() + + self.content_layout.removeWidget(item_widget) + self.input_fields.remove(item_widget) + item_widget.setParent(None) + item_widget.deleteLater() + + if self.count() == 0: + self.add_row(is_empty=True) + + self._on_value_change() + self.parent().updateGeometry() + + @property + def is_invalid(self): + return self._is_invalid or self.child_invalid + + @property + def child_invalid(self): + for input_field in self.input_fields: + if input_field.is_invalid: + return True + return False + + +# Dictionaries +class DictWidget(QtWidgets.QWidget, SettingObject): + value_changed = QtCore.Signal(object) + expand_in_grid = True + + def __init__( + self, input_data, parent, + as_widget=False, label_widget=None, parent_widget=None + ): + if parent_widget is None: + parent_widget = parent + super(DictWidget, self).__init__(parent_widget) + + self.initial_attributes(input_data, parent, as_widget) + + self.input_fields = [] + + self.checkbox_widget = None + self.checkbox_key = input_data.get("checkbox_key") + + self.label_widget = label_widget + + if self.as_widget: + self._ui_as_widget(input_data) + else: + self._ui_as_item(input_data) + + def _ui_as_item(self, input_data): + self.key = input_data["key"] + if input_data.get("highlight_content", False): + content_state = "hightlighted" + bottom_margin = 5 + else: + content_state = "" + bottom_margin = 0 + + main_layout = QtWidgets.QHBoxLayout(self) + main_layout.setContentsMargins(0, 0, 0, 0) + main_layout.setSpacing(0) + + body_widget = ExpandingWidget(input_data["label"], self) + + main_layout.addWidget(body_widget) + + content_widget = QtWidgets.QWidget(body_widget) + content_widget.setObjectName("ContentWidget") + content_widget.setProperty("content_state", content_state) + content_layout = QtWidgets.QGridLayout(content_widget) + content_layout.setContentsMargins(CHILD_OFFSET, 5, 0, bottom_margin) + + body_widget.set_content_widget(content_widget) + + self.body_widget = body_widget + self.content_widget = content_widget + self.content_layout = content_layout + + self.label_widget = body_widget.label_widget + + for child_data in input_data.get("children", []): + self.add_children_gui(child_data) + + collapsable = input_data.get("collapsable", True) + if len(self.input_fields) == 1 and self.checkbox_widget: + body_widget.hide_toolbox(hide_content=True) + + elif collapsable: + collapsed = input_data.get("collapsed", True) + if not collapsed: + body_widget.toggle_content() + else: + body_widget.hide_toolbox(hide_content=False) + + def _ui_as_widget(self, input_data): + body = QtWidgets.QWidget(self) + body.setObjectName("DictAsWidgetBody") + + content_layout = QtWidgets.QGridLayout(body) + content_layout.setContentsMargins(5, 5, 5, 5) + self.content_layout = content_layout + + layout = QtWidgets.QHBoxLayout(self) + layout.setContentsMargins(0, 0, 0, 0) + layout.setSpacing(5) + layout.addWidget(body) + + for child_configuration in input_data["children"]: + self.add_children_gui(child_configuration) + + def add_children_gui(self, child_configuration): + item_type = child_configuration["type"] + klass = TypeToKlass.types.get(item_type) + + row = self.content_layout.rowCount() + if not getattr(klass, "is_input_type", False): + item = klass(child_configuration, self) + self.content_layout.addWidget(item, row, 0, 1, 2) + return item + + if self.checkbox_key and not self.checkbox_widget: + key = child_configuration.get("key") + if key == self.checkbox_key: + return self._add_checkbox_child(child_configuration) + + label_widget = None + if not klass.expand_in_grid: + label = child_configuration.get("label") + if label is not None: + label_widget = GridLabelWidget(label, self) + self.content_layout.addWidget(label_widget, row, 0, 1, 1) + + item = klass(child_configuration, self, label_widget=label_widget) + item.value_changed.connect(self._on_value_change) + + if label_widget: + label_widget.input_field = item + self.content_layout.addWidget(item, row, 1, 1, 1) + else: + self.content_layout.addWidget(item, row, 0, 1, 2) + + self.input_fields.append(item) + return item + + def _add_checkbox_child(self, child_configuration): + item = BooleanWidget( + child_configuration, self, label_widget=self.label_widget + ) + item.value_changed.connect(self._on_value_change) + + self.body_widget.add_widget_after_label(item) + self.checkbox_widget = item + self.input_fields.append(item) + return item + + def remove_overrides(self): + self._is_overriden = False + self._is_modified = False + for input_field in self.input_fields: + input_field.remove_overrides() + + def reset_to_pype_default(self): + for input_field in self.input_fields: + input_field.reset_to_pype_default() + self._has_studio_override = False + + def set_studio_default(self): + for input_field in self.input_fields: + input_field.set_studio_default() + + if self.is_group: + self._has_studio_override = True + + def discard_changes(self): + self._is_overriden = self._was_overriden + self._is_modified = False + + for input_field in self.input_fields: + input_field.discard_changes() + + self._is_modified = self.child_modified + + def set_as_overriden(self): + if self.is_overriden: + return + + if self.is_group: + self._is_overriden = True + return + + for item in self.input_fields: + item.set_as_overriden() + + def update_default_values(self, parent_values): + # Make sure this is set to False + self._state = None + self._child_state = None + + value = NOT_SET + if self.as_widget: + value = parent_values + elif parent_values is not NOT_SET: + value = parent_values.get(self.key, NOT_SET) + + for item in self.input_fields: + item.update_default_values(value) + + def update_studio_values(self, parent_values): + # Make sure this is set to False + self._state = None + self._child_state = None + value = NOT_SET + if self.as_widget: + value = parent_values + else: + if parent_values is not NOT_SET: + value = parent_values.get(self.key, NOT_SET) + + self._has_studio_override = False + if self.is_group and value is not NOT_SET: + self._has_studio_override = True + + self._had_studio_override = bool(self._has_studio_override) + + for item in self.input_fields: + item.update_studio_values(value) + + def apply_overrides(self, parent_values): + # Make sure this is set to False + self._state = None + self._child_state = None + + if not self.as_widget: + metadata = {} + groups = tuple() + override_values = NOT_SET + if parent_values is not NOT_SET: + metadata = parent_values.get(METADATA_KEY) or metadata + groups = metadata.get("groups") or groups + override_values = parent_values.get(self.key, override_values) + + self._is_overriden = self.key in groups + + for item in self.input_fields: + item.apply_overrides(override_values) + + if not self.as_widget: + if not self._is_overriden: + self._is_overriden = ( + self.is_group + and self.is_overidable + and self.child_overriden + ) + self._was_overriden = bool(self._is_overriden) + + def _on_value_change(self, item=None): + if self.ignore_value_changes: + return + + if self.is_group and not (self.as_widget or self.any_parent_as_widget): + if self.is_overidable: + self._is_overriden = True + else: + self._has_studio_override = True + + # TODO check if this is required + self.hierarchical_style_update() + + self.value_changed.emit(self) + + self.update_style() + + def hierarchical_style_update(self): + for input_field in self.input_fields: + input_field.hierarchical_style_update() + self.update_style() + + def update_style(self, is_overriden=None): + # TODO add style update when used as widget + if self.as_widget: + return + + child_has_studio_override = self.child_has_studio_override + child_modified = self.child_modified + child_invalid = self.child_invalid + child_state = self.style_state( + child_has_studio_override, + child_invalid, + self.child_overriden, + child_modified + ) + if child_state: + child_state = "child-{}".format(child_state) + + if child_state != self._child_state: + self.body_widget.side_line_widget.setProperty("state", child_state) + self.body_widget.side_line_widget.style().polish( + self.body_widget.side_line_widget + ) + self._child_state = child_state + + state = self.style_state( + self.had_studio_override, + child_invalid, + self.is_overriden, + self.is_modified + ) + if self._state == state: + return + + self.label_widget.setProperty("state", state) + self.label_widget.style().polish(self.label_widget) + + self._state = state + + @property + def is_modified(self): + if self.is_group: + return self._is_modified or self.child_modified + return False + + @property + def child_has_studio_override(self): + for input_field in self.input_fields: + if ( + input_field.has_studio_override + or input_field.child_has_studio_override + ): + return True + return False + + @property + def child_modified(self): + for input_field in self.input_fields: + if input_field.child_modified: + return True + return False + + @property + def child_overriden(self): + for input_field in self.input_fields: + if input_field.is_overriden or input_field.child_overriden: + return True + return False + + @property + def child_invalid(self): + for input_field in self.input_fields: + if input_field.child_invalid: + return True + return False + + def get_invalid(self): + output = [] + for input_field in self.input_fields: + output.extend(input_field.get_invalid()) + return output + + def item_value(self): + output = {} + for input_field in self.input_fields: + # TODO maybe merge instead of update should be used + # NOTE merge is custom function which merges 2 dicts + output.update(input_field.config_value()) + return output + + def studio_overrides(self): + if ( + not (self.as_widget or self.any_parent_as_widget) + and not self.has_studio_override + and not self.child_has_studio_override + ): + return NOT_SET, False + + values = {} + groups = [] + for input_field in self.input_fields: + value, is_group = input_field.studio_overrides() + if value is not NOT_SET: + values.update(value) + if is_group: + groups.extend(value.keys()) + if groups: + values[METADATA_KEY] = {"groups": groups} + return {self.key: values}, self.is_group + + def overrides(self): + if not self.is_overriden and not self.child_overriden: + return NOT_SET, False + + values = {} + groups = [] + for input_field in self.input_fields: + value, is_group = input_field.overrides() + if value is not NOT_SET: + values.update(value) + if is_group: + groups.extend(value.keys()) + if groups: + values[METADATA_KEY] = {"groups": groups} + return {self.key: values}, self.is_group + + +class DictInvisible(QtWidgets.QWidget, SettingObject): + # TODO is not overridable by itself + value_changed = QtCore.Signal(object) + allow_actions = False + expand_in_grid = True + + def __init__( + self, input_data, parent, + as_widget=False, label_widget=None, parent_widget=None + ): + if parent_widget is None: + parent_widget = parent + super(DictInvisible, self).__init__(parent_widget) + self.setObjectName("DictInvisible") + + self.initial_attributes(input_data, parent, as_widget) + + if self._is_group: + raise TypeError("DictInvisible can't be marked as group input.") + + self.setAttribute(QtCore.Qt.WA_TranslucentBackground) + + layout = QtWidgets.QGridLayout(self) + layout.setContentsMargins(0, 0, 0, 0) + layout.setSpacing(5) + + self.content_layout = layout + + self.input_fields = [] + + self.key = input_data["key"] + + for child_data in input_data.get("children", []): + self.add_children_gui(child_data) + + def add_children_gui(self, child_configuration): + item_type = child_configuration["type"] + klass = TypeToKlass.types.get(item_type) + + row = self.content_layout.rowCount() + if not getattr(klass, "is_input_type", False): + item = klass(child_configuration, self) + self.content_layout.addWidget(item, row, 0, 1, 2) + return item + + label_widget = None + if not klass.expand_in_grid: + label = child_configuration.get("label") + if label is not None: + label_widget = GridLabelWidget(label, self) + self.content_layout.addWidget(label_widget, row, 0, 1, 1) + + item = klass(child_configuration, self, label_widget=label_widget) + item.value_changed.connect(self._on_value_change) + + if label_widget: + label_widget.input_field = item + self.content_layout.addWidget(item, row, 1, 1, 1) + else: + self.content_layout.addWidget(item, row, 0, 1, 2) + + self.input_fields.append(item) + return item + + def update_style(self, *args, **kwargs): + return + + @property + def child_has_studio_override(self): + for input_field in self.input_fields: + if ( + input_field.has_studio_override + or input_field.child_has_studio_override + ): + return True + return False + + @property + def child_modified(self): + for input_field in self.input_fields: + if input_field.child_modified: + return True + return False + + @property + def child_overriden(self): + for input_field in self.input_fields: + if input_field.is_overriden or input_field.child_overriden: + return True + return False + + @property + def child_invalid(self): + for input_field in self.input_fields: + if input_field.child_invalid: + return True + return False + + def get_invalid(self): + output = [] + for input_field in self.input_fields: + output.extend(input_field.get_invalid()) + return output + + def item_value(self): + output = {} + for input_field in self.input_fields: + # TODO maybe merge instead of update should be used + # NOTE merge is custom function which merges 2 dicts + output.update(input_field.config_value()) + return output + + def _on_value_change(self, item=None): + if self.ignore_value_changes: + return + + if self.is_group and not self.any_parent_as_widget: + if self.is_overidable: + self._is_overriden = True + else: + self._has_studio_override = True + self.hierarchical_style_update() + + self.value_changed.emit(self) + + def hierarchical_style_update(self): + for input_field in self.input_fields: + input_field.hierarchical_style_update() + self.update_style() + + def remove_overrides(self): + self._is_overriden = False + self._is_modified = False + for input_field in self.input_fields: + input_field.remove_overrides() + + def reset_to_pype_default(self): + for input_field in self.input_fields: + input_field.reset_to_pype_default() + self._has_studio_override = False + + def set_studio_default(self): + for input_field in self.input_fields: + input_field.set_studio_default() + + if self.is_group: + self._has_studio_override = True + + def discard_changes(self): + self._is_modified = False + self._is_overriden = self._was_overriden + + for input_field in self.input_fields: + input_field.discard_changes() + + self._is_modified = self.child_modified + + def set_as_overriden(self): + if self.is_overriden: + return + + if self.is_group: + self._is_overriden = True + return + + for item in self.input_fields: + item.set_as_overriden() + + def update_default_values(self, parent_values): + value = NOT_SET + if self._as_widget: + value = parent_values + elif parent_values is not NOT_SET: + value = parent_values.get(self.key, NOT_SET) + + for item in self.input_fields: + item.update_default_values(value) + + def update_studio_values(self, parent_values): + value = NOT_SET + if parent_values is not NOT_SET: + value = parent_values.get(self.key, NOT_SET) + + for item in self.input_fields: + item.update_studio_values(value) + + def apply_overrides(self, parent_values): + # Make sure this is set to False + self._state = None + self._child_state = None + + metadata = {} + groups = tuple() + override_values = NOT_SET + if parent_values is not NOT_SET: + metadata = parent_values.get(METADATA_KEY) or metadata + groups = metadata.get("groups") or groups + override_values = parent_values.get(self.key, override_values) + + self._is_overriden = self.key in groups + + for item in self.input_fields: + item.apply_overrides(override_values) + + if not self._is_overriden: + self._is_overriden = ( + self.is_group + and self.is_overidable + and self.child_overriden + ) + self._was_overriden = bool(self._is_overriden) + + def studio_overrides(self): + if ( + not (self.as_widget or self.any_parent_as_widget) + and not self.has_studio_override + and not self.child_has_studio_override + ): + return NOT_SET, False + + values = {} + groups = [] + for input_field in self.input_fields: + value, is_group = input_field.studio_overrides() + if value is not NOT_SET: + values.update(value) + if is_group: + groups.extend(value.keys()) + if groups: + values[METADATA_KEY] = {"groups": groups} + return {self.key: values}, self.is_group + + def overrides(self): + if not self.is_overriden and not self.child_overriden: + return NOT_SET, False + + values = {} + groups = [] + for input_field in self.input_fields: + value, is_group = input_field.overrides() + if value is not NOT_SET: + values.update(value) + if is_group: + groups.extend(value.keys()) + if groups: + values[METADATA_KEY] = {"groups": groups} + return {self.key: values}, self.is_group + + +class PathWidget(QtWidgets.QWidget, SettingObject): + value_changed = QtCore.Signal(object) + platforms = ("windows", "darwin", "linux") + platform_labels_mapping = { + "windows": "Windows", + "darwin": "MacOS", + "linux": "Linux" + } + + def __init__( + self, input_data, parent, + as_widget=False, label_widget=None, parent_widget=None + ): + if parent_widget is None: + parent_widget = parent + super(PathWidget, self).__init__(parent_widget) + + self.initial_attributes(input_data, parent, as_widget) + + # This is partial input and dictionary input + if not self.any_parent_is_group and not self._as_widget: + self._is_group = True + else: + self._is_group = False + + self.multiplatform = input_data.get("multiplatform", False) + self.multipath = input_data.get("multipath", False) + + self.input_fields = [] + + layout = QtWidgets.QHBoxLayout(self) + layout.setContentsMargins(0, 0, 0, 0) + layout.setSpacing(5) + + if not self._as_widget: + self.key = input_data["key"] + if not label_widget: + label = input_data["label"] + label_widget = QtWidgets.QLabel(label) + label_widget.setAttribute(QtCore.Qt.WA_TranslucentBackground) + layout.addWidget(label_widget, 0, alignment=QtCore.Qt.AlignTop) + self.label_widget = label_widget + + self.content_widget = QtWidgets.QWidget(self) + self.content_layout = QtWidgets.QVBoxLayout(self.content_widget) + self.content_layout.setSpacing(0) + self.content_layout.setContentsMargins(0, 0, 0, 0) + + layout.addWidget(self.content_widget) + + self.create_gui() + + @property + def default_input_value(self): + if self.multipath: + value_type = list + else: + value_type = str + + if self.multiplatform: + return { + platform: value_type() + for platform in self.platforms + } + else: + return value_type() + + def create_gui(self): + if not self.multiplatform and not self.multipath: + input_data = {"key": self.key} + path_input = PathInputWidget( + input_data, self, label_widget=self.label_widget + ) + self.setFocusProxy(path_input) + self.content_layout.addWidget(path_input) + self.input_fields.append(path_input) + path_input.value_changed.connect(self._on_value_change) + return + + input_data_for_list = { + "object_type": "path-input" + } + if not self.multiplatform: + input_data_for_list["key"] = self.key + input_widget = ListWidget( + input_data_for_list, self, label_widget=self.label_widget + ) + self.setFocusProxy(input_widget) + self.content_layout.addWidget(input_widget) + self.input_fields.append(input_widget) + input_widget.value_changed.connect(self._on_value_change) + return + + proxy_widget = QtWidgets.QWidget(self.content_widget) + proxy_layout = QtWidgets.QFormLayout(proxy_widget) + for platform_key in self.platforms: + platform_label = self.platform_labels_mapping[platform_key] + label_widget = QtWidgets.QLabel(platform_label, proxy_widget) + if self.multipath: + input_data_for_list["key"] = platform_key + input_widget = ListWidget( + input_data_for_list, self, label_widget=label_widget + ) + else: + input_data = {"key": platform_key} + input_widget = PathInputWidget( + input_data, self, label_widget=label_widget + ) + proxy_layout.addRow(label_widget, input_widget) + self.input_fields.append(input_widget) + input_widget.value_changed.connect(self._on_value_change) + + self.setFocusProxy(self.input_fields[0]) + self.content_layout.addWidget(proxy_widget) + + def update_default_values(self, parent_values): + self._state = None + self._child_state = None + self._is_modified = False + + value = NOT_SET + if self._as_widget: + value = parent_values + elif parent_values is not NOT_SET: + if not self.multiplatform: + value = parent_values + else: + value = parent_values.get(self.key, NOT_SET) + + if value is NOT_SET: + if self.develop_mode: + if self._as_widget or not self.multiplatform: + value = {self.key: self.default_input_value} + else: + value = self.default_input_value + self.defaults_not_set = True + if value is NOT_SET: + raise NotImplementedError(( + "{} Does not have implemented" + " attribute `default_input_value`" + ).format(self)) + + else: + raise ValueError( + "Default value is not set. This is implementation BUG." + ) + else: + self.defaults_not_set = False + + self.default_value = value + self._has_studio_override = False + self._had_studio_override = False + + if not self.multiplatform: + self.input_fields[0].update_default_values(value) + else: + for input_field in self.input_fields: + input_field.update_default_values(value) + + def update_studio_values(self, parent_values): + self._state = None + self._child_state = None + self._is_modified = False + + value = NOT_SET + if self._as_widget: + value = parent_values + elif parent_values is not NOT_SET: + if not self.multiplatform: + value = parent_values + else: + value = parent_values.get(self.key, NOT_SET) + + self.studio_value = value + if value is not NOT_SET: + self._has_studio_override = True + self._had_studio_override = True + else: + self._has_studio_override = False + self._had_studio_override = False + value = self.default_value + + if not self.multiplatform: + self.input_fields[0].update_studio_values(value) + else: + for input_field in self.input_fields: + input_field.update_studio_values(value) + + def apply_overrides(self, parent_values): + self._is_modified = False + self._state = None + self._child_state = None + + override_values = NOT_SET + if self._as_widget: + override_values = parent_values + elif parent_values is not NOT_SET: + if not self.multiplatform: + override_values = parent_values + else: + override_values = parent_values.get(self.key, NOT_SET) + + self._is_overriden = override_values is not NOT_SET + self._was_overriden = bool(self._is_overriden) + + if not self.multiplatform: + self.input_fields[0].apply_overrides(parent_values) + else: + for input_field in self.input_fields: + input_field.apply_overrides(override_values) + + if not self._is_overriden: + self._is_overriden = ( + self.is_group + and self.is_overidable + and self.child_overriden + ) + self._is_modified = False + self._was_overriden = bool(self._is_overriden) + + def set_value(self, value): + if not self.multiplatform: + self.input_fields[0].set_value(value) + + else: + for input_field in self.input_fields: + _value = value[input_field.key] + input_field.set_value(_value) + + def _on_value_change(self, item=None): + if self.ignore_value_changes: + return + + if not self.any_parent_as_widget: + if self.is_overidable: + self._is_overriden = True + else: + self._has_studio_override = True + + if self._is_invalid: + self._is_modified = True + elif self._is_overriden: + self._is_modified = self.item_value() != self.override_value + elif self._has_studio_override: + self._is_modified = self.item_value() != self.studio_value + else: + self._is_modified = self.item_value() != self.default_value + + self.hierarchical_style_update() + + self.value_changed.emit(self) + + def update_style(self, is_overriden=None): + child_has_studio_override = self.child_has_studio_override + child_modified = self.child_modified + child_invalid = self.child_invalid + child_state = self.style_state( + child_has_studio_override, + child_invalid, + self.child_overriden, + child_modified + ) + if child_state: + child_state = "child-{}".format(child_state) + + if child_state != self._child_state: + self.setProperty("state", child_state) + self.style().polish(self) + self._child_state = child_state + + if not self._as_widget: + state = self.style_state( + child_has_studio_override, + child_invalid, + self.is_overriden, + self.is_modified + ) + if self._state == state: + return + + self.label_widget.setProperty("state", state) + self.label_widget.style().polish(self.label_widget) + + self._state = state + + def remove_overrides(self): + self._is_overriden = False + self._is_modified = False + for input_field in self.input_fields: + input_field.remove_overrides() + + def reset_to_pype_default(self): + for input_field in self.input_fields: + input_field.reset_to_pype_default() + self._has_studio_override = False + + def set_studio_default(self): + for input_field in self.input_fields: + input_field.set_studio_default() + + if self.is_group: + self._has_studio_override = True + + def discard_changes(self): + self._is_modified = False + self._is_overriden = self._was_overriden + + for input_field in self.input_fields: + input_field.discard_changes() + + self._is_modified = self.child_modified + + def set_as_overriden(self): + self._is_overriden = True + + @property + def child_has_studio_override(self): + for input_field in self.input_fields: + if ( + input_field.has_studio_override + or input_field.child_has_studio_override + ): + return True + return False + + @property + def child_modified(self): + for input_field in self.input_fields: + if input_field.child_modified: + return True + return False + + @property + def child_overriden(self): + for input_field in self.input_fields: + if input_field.child_overriden: + return True + return False + + @property + def child_invalid(self): + for input_field in self.input_fields: + if input_field.child_invalid: + return True + return False + + def hierarchical_style_update(self): + for input_field in self.input_fields: + input_field.hierarchical_style_update() + self.update_style() + + def item_value(self): + if not self.multiplatform and not self.multipath: + return self.input_fields[0].item_value() + + if not self.multiplatform: + return self.input_fields[0].item_value() + + output = {} + for input_field in self.input_fields: + output.update(input_field.config_value()) + return output + + def studio_overrides(self): + if ( + not (self.as_widget or self.any_parent_as_widget) + and not self.has_studio_override + and not self.child_has_studio_override + ): + return NOT_SET, False + + value = self.item_value() + if not self.multiplatform: + value = {self.key: value} + return value, self.is_group + + def overrides(self): + if not self.is_overriden and not self.child_overriden: + return NOT_SET, False + + value = self.item_value() + if not self.multiplatform: + value = {self.key: value} + return value, self.is_group + + +# Proxy for form layout +class FormLabel(QtWidgets.QLabel): + def __init__(self, *args, **kwargs): + super(FormLabel, self).__init__(*args, **kwargs) + self.item = None + + +class DictFormWidget(QtWidgets.QWidget, SettingObject): + value_changed = QtCore.Signal(object) + allow_actions = False + expand_in_grid = True + + def __init__( + self, input_data, parent, + as_widget=False, label_widget=None, parent_widget=None + ): + if parent_widget is None: + parent_widget = parent + super(DictFormWidget, self).__init__(parent_widget) + + self.initial_attributes(input_data, parent, as_widget) + + self._as_widget = False + self._is_group = False + + self.input_fields = [] + self.content_layout = QtWidgets.QFormLayout(self) + self.content_layout.setContentsMargins(0, 0, 0, 0) + + for child_data in input_data.get("children", []): + self.add_children_gui(child_data) + + self.setAttribute(QtCore.Qt.WA_TranslucentBackground) + + def add_children_gui(self, child_configuration): + item_type = child_configuration["type"] + # Pop label to not be set in child + label = child_configuration["label"] + + klass = TypeToKlass.types.get(item_type) + + label_widget = FormLabel(label, self) + + item = klass(child_configuration, self, label_widget=label_widget) + label_widget.item = item + + item.value_changed.connect(self._on_value_change) + self.content_layout.addRow(label_widget, item) + self.input_fields.append(item) + return item + + def mouseReleaseEvent(self, event): + if event.button() == QtCore.Qt.RightButton: + position = self.mapFromGlobal(QtGui.QCursor().pos()) + widget = self.childAt(position) + if widget and isinstance(widget, FormLabel): + widget.item.mouseReleaseEvent(event) + event.accept() + return + super(DictFormWidget, self).mouseReleaseEvent(event) + + def apply_overrides(self, parent_values): + for item in self.input_fields: + item.apply_overrides(parent_values) + + def discard_changes(self): + self._is_modified = False + self._is_overriden = self._was_overriden + + for item in self.input_fields: + item.discard_changes() + + self._is_modified = self.child_modified + + def remove_overrides(self): + self._is_overriden = False + self._is_modified = False + for input_field in self.input_fields: + input_field.remove_overrides() + + def reset_to_pype_default(self): + for input_field in self.input_fields: + input_field.reset_to_pype_default() + self._has_studio_override = False + + def set_studio_default(self): + for input_field in self.input_fields: + input_field.set_studio_default() + + if self.is_group: + self._has_studio_override = True + + def set_as_overriden(self): + if self.is_overriden: + return + + if self.is_group: + self._is_overriden = True + return + + for item in self.input_fields: + item.set_as_overriden() + + def update_default_values(self, value): + for item in self.input_fields: + item.update_default_values(value) + + def update_studio_values(self, value): + for item in self.input_fields: + item.update_studio_values(value) + + def _on_value_change(self, item=None): + if self.ignore_value_changes: + return + + self.value_changed.emit(self) + if self.any_parent_is_group: + self.hierarchical_style_update() + + @property + def child_has_studio_override(self): + for input_field in self.input_fields: + if ( + input_field.has_studio_override + or input_field.child_has_studio_override + ): + return True + return False + + @property + def child_modified(self): + for input_field in self.input_fields: + if input_field.child_modified: + return True + return False + + @property + def child_overriden(self): + for input_field in self.input_fields: + if input_field.is_overriden or input_field.child_overriden: + return True + return False + + @property + def child_invalid(self): + for input_field in self.input_fields: + if input_field.child_invalid: + return True + return False + + def get_invalid(self): + output = [] + for input_field in self.input_fields: + output.extend(input_field.get_invalid()) + return output + + def hierarchical_style_update(self): + for input_field in self.input_fields: + input_field.hierarchical_style_update() + + def item_value(self): + output = {} + for input_field in self.input_fields: + # TODO maybe merge instead of update should be used + # NOTE merge is custom function which merges 2 dicts + output.update(input_field.config_value()) + return output + + def config_value(self): + return self.item_value() + + def studio_overrides(self): + if ( + not (self.as_widget or self.any_parent_as_widget) + and not self.has_studio_override + and not self.child_has_studio_override + ): + return NOT_SET, False + + values = {} + groups = [] + for input_field in self.input_fields: + value, is_group = input_field.studio_overrides() + if value is not NOT_SET: + values.update(value) + if is_group: + groups.extend(value.keys()) + if groups: + values[METADATA_KEY] = {"groups": groups} + return values, self.is_group + + def overrides(self): + if not self.is_overriden and not self.child_overriden: + return NOT_SET, False + + values = {} + groups = [] + for input_field in self.input_fields: + value, is_group = input_field.overrides() + if value is not NOT_SET: + values.update(value) + if is_group: + groups.extend(value.keys()) + if groups: + values[METADATA_KEY] = {"groups": groups} + return values, self.is_group + + +class LabelWidget(QtWidgets.QWidget): + is_input_type = False + + def __init__(self, configuration, parent=None): + super(LabelWidget, self).__init__(parent) + self.setObjectName("LabelWidget") + + label = configuration["label"] + + layout = QtWidgets.QHBoxLayout(self) + layout.setContentsMargins(5, 5, 5, 5) + label_widget = QtWidgets.QLabel(label, self) + layout.addWidget(label_widget) + + +class SplitterWidget(QtWidgets.QWidget): + is_input_type = False + _height = 2 + + def __init__(self, configuration, parent=None): + super(SplitterWidget, self).__init__(parent) + + layout = QtWidgets.QHBoxLayout(self) + layout.setContentsMargins(5, 5, 5, 5) + splitter_item = QtWidgets.QWidget(self) + splitter_item.setObjectName("SplitterItem") + splitter_item.setMinimumHeight(self._height) + splitter_item.setMaximumHeight(self._height) + layout.addWidget(splitter_item) + + +TypeToKlass.types["boolean"] = BooleanWidget +TypeToKlass.types["number"] = NumberWidget +TypeToKlass.types["text"] = TextWidget +TypeToKlass.types["path-input"] = PathInputWidget +TypeToKlass.types["raw-json"] = RawJsonWidget +TypeToKlass.types["list"] = ListWidget +TypeToKlass.types["list-strict"] = ListStrictWidget +TypeToKlass.types["dict-modifiable"] = ModifiableDict +# DEPRECATED - remove when removed from schemas +TypeToKlass.types["dict-item"] = DictWidget +TypeToKlass.types["dict"] = DictWidget +TypeToKlass.types["dict-invisible"] = DictInvisible +TypeToKlass.types["path-widget"] = PathWidget +TypeToKlass.types["form"] = DictFormWidget + +TypeToKlass.types["label"] = LabelWidget +TypeToKlass.types["splitter"] = SplitterWidget diff --git a/pype/tools/settings/settings/widgets/lib.py b/pype/tools/settings/settings/widgets/lib.py new file mode 100644 index 0000000000..f54989cfd7 --- /dev/null +++ b/pype/tools/settings/settings/widgets/lib.py @@ -0,0 +1,310 @@ +import os +import json +import copy +from pype.settings.lib import OVERRIDEN_KEY +from queue import Queue + + +# Singleton database of available inputs +class TypeToKlass: + types = {} + + +NOT_SET = type("NOT_SET", (), {"__bool__": lambda obj: False})() +METADATA_KEY = type("METADATA_KEY", (), {}) +OVERRIDE_VERSION = 1 +CHILD_OFFSET = 15 + + +def convert_gui_data_to_overrides(data, first=True): + if not data or not isinstance(data, dict): + return data + + output = {} + if first: + output["__override_version__"] = OVERRIDE_VERSION + + if METADATA_KEY in data: + metadata = data.pop(METADATA_KEY) + for key, value in metadata.items(): + if key == "groups": + output[OVERRIDEN_KEY] = value + else: + KeyError("Unknown metadata key \"{}\"".format(key)) + + for key, value in data.items(): + output[key] = convert_gui_data_to_overrides(value, False) + return output + + +def convert_overrides_to_gui_data(data, first=True): + if not data or not isinstance(data, dict): + return data + + output = {} + if OVERRIDEN_KEY in data: + groups = data.pop(OVERRIDEN_KEY) + if METADATA_KEY not in output: + output[METADATA_KEY] = {} + output[METADATA_KEY]["groups"] = groups + + for key, value in data.items(): + output[key] = convert_overrides_to_gui_data(value, False) + + return output + + +def _fill_inner_schemas(schema_data, schema_collection): + if schema_data["type"] == "schema": + raise ValueError("First item in schema data can't be schema.") + + children = schema_data.get("children") + if not children: + return schema_data + + new_children = [] + for child in children: + if child["type"] != "schema": + new_child = _fill_inner_schemas(child, schema_collection) + new_children.append(new_child) + continue + + new_child = _fill_inner_schemas( + schema_collection[child["name"]], + schema_collection + ) + new_children.append(new_child) + + schema_data["children"] = new_children + return schema_data + + +class SchemaMissingFileInfo(Exception): + def __init__(self, invalid): + full_path_keys = [] + for item in invalid: + full_path_keys.append("\"{}\"".format("/".join(item))) + + msg = ( + "Schema has missing definition of output file (\"is_file\" key)" + " for keys. [{}]" + ).format(", ".join(full_path_keys)) + super(SchemaMissingFileInfo, self).__init__(msg) + + +class SchemeGroupHierarchyBug(Exception): + def __init__(self, invalid): + full_path_keys = [] + for item in invalid: + full_path_keys.append("\"{}\"".format("/".join(item))) + + msg = ( + "Items with attribute \"is_group\" can't have another item with" + " \"is_group\" attribute as child. Error happened for keys: [{}]" + ).format(", ".join(full_path_keys)) + super(SchemeGroupHierarchyBug, self).__init__(msg) + + +class SchemaDuplicatedKeys(Exception): + def __init__(self, invalid): + items = [] + for key_path, keys in invalid.items(): + joined_keys = ", ".join([ + "\"{}\"".format(key) for key in keys + ]) + items.append("\"{}\" ({})".format(key_path, joined_keys)) + + msg = ( + "Schema items contain duplicated keys in one hierarchy level. {}" + ).format(" || ".join(items)) + super(SchemaDuplicatedKeys, self).__init__(msg) + + +def file_keys_from_schema(schema_data): + output = [] + item_type = schema_data["type"] + klass = TypeToKlass.types[item_type] + if not klass.is_input_type: + return output + + keys = [] + key = schema_data.get("key") + if key: + keys.append(key) + + for child in schema_data["children"]: + if child.get("is_file"): + _keys = copy.deepcopy(keys) + _keys.append(child["key"]) + output.append(_keys) + continue + + for result in file_keys_from_schema(child): + _keys = copy.deepcopy(keys) + _keys.extend(result) + output.append(_keys) + return output + + +def validate_all_has_ending_file(schema_data, is_top=True): + item_type = schema_data["type"] + klass = TypeToKlass.types[item_type] + if not klass.is_input_type: + return None + + if schema_data.get("is_file"): + return None + + children = schema_data.get("children") + if not children: + return [[schema_data["key"]]] + + invalid = [] + keyless = "key" not in schema_data + for child in children: + result = validate_all_has_ending_file(child, False) + if result is None: + continue + + if keyless: + invalid.extend(result) + else: + for item in result: + new_invalid = [schema_data["key"]] + new_invalid.extend(item) + invalid.append(new_invalid) + + if not invalid: + return None + + if not is_top: + return invalid + + raise SchemaMissingFileInfo(invalid) + + +def validate_is_group_is_unique_in_hierarchy( + schema_data, any_parent_is_group=False, keys=None +): + is_top = keys is None + if keys is None: + keys = [] + + keyless = "key" not in schema_data + + if not keyless: + keys.append(schema_data["key"]) + + invalid = [] + is_group = schema_data.get("is_group") + if is_group and any_parent_is_group: + invalid.append(copy.deepcopy(keys)) + + if is_group: + any_parent_is_group = is_group + + children = schema_data.get("children") + if not children: + return invalid + + for child in children: + result = validate_is_group_is_unique_in_hierarchy( + child, any_parent_is_group, copy.deepcopy(keys) + ) + if not result: + continue + + invalid.extend(result) + + if invalid and is_group and keys not in invalid: + invalid.append(copy.deepcopy(keys)) + + if not is_top: + return invalid + + if invalid: + raise SchemeGroupHierarchyBug(invalid) + + +def validate_keys_are_unique(schema_data, keys=None): + children = schema_data.get("children") + if not children: + return + + is_top = keys is None + if keys is None: + keys = [schema_data["key"]] + else: + keys.append(schema_data["key"]) + + child_queue = Queue() + for child in children: + child_queue.put(child) + + child_inputs = [] + while not child_queue.empty(): + child = child_queue.get() + if "key" not in child: + _children = child.get("children") or [] + for _child in _children: + child_queue.put(_child) + else: + child_inputs.append(child) + + duplicated_keys = set() + child_keys = set() + for child in child_inputs: + key = child["key"] + if key in child_keys: + duplicated_keys.add(key) + else: + child_keys.add(key) + + invalid = {} + if duplicated_keys: + joined_keys = "/".join(keys) + invalid[joined_keys] = duplicated_keys + + for child in child_inputs: + result = validate_keys_are_unique(child, copy.deepcopy(keys)) + if result: + invalid.update(result) + + if not is_top: + return invalid + + if invalid: + raise SchemaDuplicatedKeys(invalid) + + +def validate_schema(schema_data): + validate_all_has_ending_file(schema_data) + validate_is_group_is_unique_in_hierarchy(schema_data) + validate_keys_are_unique(schema_data) + + +def gui_schema(subfolder, main_schema_name): + subfolder, main_schema_name + dirpath = os.path.join( + os.path.dirname(os.path.dirname(__file__)), + "gui_schemas", + subfolder + ) + + loaded_schemas = {} + for filename in os.listdir(dirpath): + basename, ext = os.path.splitext(filename) + if ext != ".json": + continue + + filepath = os.path.join(dirpath, filename) + with open(filepath, "r") as json_stream: + schema_data = json.load(json_stream) + loaded_schemas[basename] = schema_data + + main_schema = _fill_inner_schemas( + loaded_schemas[main_schema_name], + loaded_schemas + ) + validate_schema(main_schema) + return main_schema diff --git a/pype/tools/settings/settings/widgets/tests.py b/pype/tools/settings/settings/widgets/tests.py new file mode 100644 index 0000000000..fc53e38ad5 --- /dev/null +++ b/pype/tools/settings/settings/widgets/tests.py @@ -0,0 +1,136 @@ +from Qt import QtWidgets, QtCore + + +def indented_print(data, indent=0): + spaces = " " * (indent * 4) + if not isinstance(data, dict): + print("{}{}".format(spaces, data)) + return + + for key, value in data.items(): + print("{}{}".format(spaces, key)) + indented_print(value, indent + 1) + + +class SelectableMenu(QtWidgets.QMenu): + + selection_changed = QtCore.Signal() + + def mouseReleaseEvent(self, event): + action = self.activeAction() + if action and action.isEnabled(): + action.trigger() + self.selection_changed.emit() + else: + super(SelectableMenu, self).mouseReleaseEvent(event) + + def event(self, event): + result = super(SelectableMenu, self).event(event) + if event.type() == QtCore.QEvent.Show: + parent = self.parent() + + move_point = parent.mapToGlobal(QtCore.QPoint(0, parent.height())) + check_point = ( + move_point + + QtCore.QPoint(self.width(), self.height()) + ) + visibility_check = ( + QtWidgets.QApplication.desktop().rect().contains(check_point) + ) + if not visibility_check: + move_point -= QtCore.QPoint(0, parent.height() + self.height()) + self.move(move_point) + + self.updateGeometry() + self.repaint() + + return result + + +class AddibleComboBox(QtWidgets.QComboBox): + """Searchable ComboBox with empty placeholder value as first value""" + + def __init__(self, placeholder="", parent=None): + super(AddibleComboBox, self).__init__(parent) + + self.setEditable(True) + # self.setInsertPolicy(self.NoInsert) + + self.lineEdit().setPlaceholderText(placeholder) + # self.lineEdit().returnPressed.connect(self.on_return_pressed) + + # Apply completer settings + completer = self.completer() + completer.setCompletionMode(completer.PopupCompletion) + completer.setCaseSensitivity(QtCore.Qt.CaseInsensitive) + + # def on_return_pressed(self): + # text = self.lineEdit().text().strip() + # if not text: + # return + # + # index = self.findText(text) + # if index < 0: + # self.addItems([text]) + # index = self.findText(text) + + def populate(self, items): + self.clear() + # self.addItems([""]) # ensure first item is placeholder + self.addItems(items) + + def get_valid_value(self): + """Return the current text if it's a valid value else None + + Note: The empty placeholder value is valid and returns as "" + + """ + + text = self.currentText() + lookup = set(self.itemText(i) for i in range(self.count())) + if text not in lookup: + return None + + return text or None + + +class MultiselectEnum(QtWidgets.QWidget): + + selection_changed = QtCore.Signal() + + def __init__(self, title, parent=None): + super(MultiselectEnum, self).__init__(parent) + toolbutton = QtWidgets.QToolButton(self) + toolbutton.setText(title) + + toolmenu = SelectableMenu(toolbutton) + + toolbutton.setMenu(toolmenu) + toolbutton.setPopupMode(QtWidgets.QToolButton.MenuButtonPopup) + + layout = QtWidgets.QHBoxLayout() + layout.setContentsMargins(0, 0, 0, 0) + layout.addWidget(toolbutton) + + self.setLayout(layout) + + toolmenu.selection_changed.connect(self.selection_changed) + + self.toolbutton = toolbutton + self.toolmenu = toolmenu + self.main_layout = layout + + def populate(self, items): + self.toolmenu.clear() + self.addItems(items) + + def addItems(self, items): + for item in items: + action = self.toolmenu.addAction(item) + action.setCheckable(True) + action.setChecked(True) + self.toolmenu.addAction(action) + + def items(self): + for action in self.toolmenu.actions(): + yield action diff --git a/pype/tools/settings/settings/widgets/widgets.py b/pype/tools/settings/settings/widgets/widgets.py new file mode 100644 index 0000000000..2a1f5fe804 --- /dev/null +++ b/pype/tools/settings/settings/widgets/widgets.py @@ -0,0 +1,281 @@ +from Qt import QtWidgets, QtCore, QtGui + + +class NumberSpinBox(QtWidgets.QDoubleSpinBox): + def __init__(self, *args, **kwargs): + min_value = kwargs.pop("minimum", -99999) + max_value = kwargs.pop("maximum", 99999) + decimals = kwargs.pop("decimal", 0) + super(NumberSpinBox, self).__init__(*args, **kwargs) + self.setFocusPolicy(QtCore.Qt.StrongFocus) + self.setDecimals(decimals) + self.setMinimum(min_value) + self.setMaximum(max_value) + + def wheelEvent(self, event): + if self.hasFocus(): + super(NumberSpinBox, self).wheelEvent(event) + else: + event.ignore() + + def value(self): + output = super(NumberSpinBox, self).value() + if self.decimals() == 0: + output = int(output) + return output + + +class PathInput(QtWidgets.QLineEdit): + def clear_end_path(self): + value = self.text().strip() + if value.endswith("/"): + while value and value[-1] == "/": + value = value[:-1] + self.setText(value) + + def keyPressEvent(self, event): + # Always change backslash `\` for forwardslash `/` + if event.key() == QtCore.Qt.Key_Backslash: + event.accept() + new_event = QtGui.QKeyEvent( + event.type(), + QtCore.Qt.Key_Slash, + event.modifiers(), + "/", + event.isAutoRepeat(), + event.count() + ) + QtWidgets.QApplication.sendEvent(self, new_event) + return + super(PathInput, self).keyPressEvent(event) + + def focusOutEvent(self, event): + super(PathInput, self).focusOutEvent(event) + self.clear_end_path() + + +class ClickableWidget(QtWidgets.QWidget): + clicked = QtCore.Signal() + + def mouseReleaseEvent(self, event): + if event.button() == QtCore.Qt.LeftButton: + self.clicked.emit() + super(ClickableWidget, self).mouseReleaseEvent(event) + + +class ExpandingWidget(QtWidgets.QWidget): + def __init__(self, label, parent): + super(ExpandingWidget, self).__init__(parent) + + self.toolbox_hidden = False + + top_part = ClickableWidget(parent=self) + + button_size = QtCore.QSize(5, 5) + button_toggle = QtWidgets.QToolButton(parent=top_part) + button_toggle.setProperty("btn-type", "expand-toggle") + button_toggle.setIconSize(button_size) + button_toggle.setArrowType(QtCore.Qt.RightArrow) + button_toggle.setCheckable(True) + button_toggle.setChecked(False) + + label_widget = QtWidgets.QLabel(label, parent=top_part) + label_widget.setObjectName("DictLabel") + + side_line_widget = QtWidgets.QWidget(top_part) + side_line_widget.setObjectName("SideLineWidget") + side_line_layout = QtWidgets.QHBoxLayout(side_line_widget) + side_line_layout.setContentsMargins(5, 10, 0, 10) + side_line_layout.addWidget(button_toggle) + side_line_layout.addWidget(label_widget) + + top_part_layout = QtWidgets.QHBoxLayout(top_part) + top_part_layout.setContentsMargins(0, 0, 0, 0) + top_part_layout.addWidget(side_line_widget) + + self.setAttribute(QtCore.Qt.WA_TranslucentBackground) + + self.top_part_ending = None + self.after_label_layout = None + self.end_of_layout = None + + self.side_line_widget = side_line_widget + self.side_line_layout = side_line_layout + self.button_toggle = button_toggle + self.label_widget = label_widget + + top_part.clicked.connect(self._top_part_clicked) + self.button_toggle.clicked.connect(self._btn_clicked) + + self.main_layout = QtWidgets.QVBoxLayout(self) + self.main_layout.setContentsMargins(0, 0, 0, 0) + self.main_layout.setSpacing(0) + self.main_layout.addWidget(top_part) + + def hide_toolbox(self, hide_content=False): + self.button_toggle.setArrowType(QtCore.Qt.NoArrow) + self.toolbox_hidden = True + self.content_widget.setVisible(not hide_content) + self.parent().updateGeometry() + + def set_content_widget(self, content_widget): + content_widget.setVisible(False) + self.main_layout.addWidget(content_widget) + self.content_widget = content_widget + + def _btn_clicked(self): + self.toggle_content(self.button_toggle.isChecked()) + + def _top_part_clicked(self): + self.toggle_content() + + def toggle_content(self, *args): + if self.toolbox_hidden: + return + + if len(args) > 0: + checked = args[0] + else: + checked = not self.button_toggle.isChecked() + arrow_type = QtCore.Qt.RightArrow + if checked: + arrow_type = QtCore.Qt.DownArrow + self.button_toggle.setChecked(checked) + self.button_toggle.setArrowType(arrow_type) + self.content_widget.setVisible(checked) + self.parent().updateGeometry() + + def add_widget_after_label(self, widget): + self._add_side_widget_subwidgets() + self.after_label_layout.addWidget(widget) + + def _add_side_widget_subwidgets(self): + if self.top_part_ending is not None: + return + + top_part_ending = QtWidgets.QWidget(self.side_line_widget) + top_part_ending.setAttribute(QtCore.Qt.WA_TranslucentBackground) + + top_part_ending_layout = QtWidgets.QHBoxLayout(top_part_ending) + top_part_ending_layout.setContentsMargins(0, 0, 0, 0) + top_part_ending_layout.setSpacing(0) + top_part_ending_layout.setAlignment(QtCore.Qt.AlignVCenter) + + after_label_widget = QtWidgets.QWidget(top_part_ending) + spacer_item = QtWidgets.QWidget(top_part_ending) + end_of_widget = QtWidgets.QWidget(top_part_ending) + + self.after_label_layout = QtWidgets.QVBoxLayout(after_label_widget) + self.after_label_layout.setContentsMargins(0, 0, 0, 0) + + self.end_of_layout = QtWidgets.QVBoxLayout(end_of_widget) + self.end_of_layout.setContentsMargins(0, 0, 0, 0) + + spacer_layout = QtWidgets.QVBoxLayout(spacer_item) + spacer_layout.setContentsMargins(0, 0, 0, 0) + + top_part_ending_layout.addWidget(after_label_widget, 0) + top_part_ending_layout.addWidget(spacer_item, 1) + top_part_ending_layout.addWidget(end_of_widget, 0) + + top_part_ending.setAttribute(QtCore.Qt.WA_TranslucentBackground) + after_label_widget.setAttribute(QtCore.Qt.WA_TranslucentBackground) + spacer_item.setAttribute(QtCore.Qt.WA_TranslucentBackground) + end_of_widget.setAttribute(QtCore.Qt.WA_TranslucentBackground) + + self.top_part_ending = top_part_ending + self.side_line_layout.addWidget(top_part_ending) + + def resizeEvent(self, event): + super(ExpandingWidget, self).resizeEvent(event) + self.content_widget.updateGeometry() + + +class UnsavedChangesDialog(QtWidgets.QDialog): + message = "You have unsaved changes. What do you want to do with them?" + + def __init__(self, parent=None): + super().__init__(parent) + message_label = QtWidgets.QLabel(self.message) + + btns_widget = QtWidgets.QWidget(self) + btns_layout = QtWidgets.QHBoxLayout(btns_widget) + + btn_ok = QtWidgets.QPushButton("Save") + btn_ok.clicked.connect(self.on_ok_pressed) + btn_discard = QtWidgets.QPushButton("Discard") + btn_discard.clicked.connect(self.on_discard_pressed) + btn_cancel = QtWidgets.QPushButton("Cancel") + btn_cancel.clicked.connect(self.on_cancel_pressed) + + btns_layout.addWidget(btn_ok) + btns_layout.addWidget(btn_discard) + btns_layout.addWidget(btn_cancel) + + layout = QtWidgets.QVBoxLayout(self) + layout.addWidget(message_label) + layout.addWidget(btns_widget) + + self.state = None + + def on_cancel_pressed(self): + self.done(0) + + def on_ok_pressed(self): + self.done(1) + + def on_discard_pressed(self): + self.done(2) + + +class SpacerWidget(QtWidgets.QWidget): + def __init__(self, parent=None): + super(SpacerWidget, self).__init__(parent) + self.setAttribute(QtCore.Qt.WA_TranslucentBackground) + + +class GridLabelWidget(QtWidgets.QWidget): + def __init__(self, label, parent=None): + super(GridLabelWidget, self).__init__(parent) + + self.input_field = None + + self.properties = {} + + layout = QtWidgets.QVBoxLayout(self) + layout.setContentsMargins(0, 0, 0, 0) + layout.setSpacing(0) + + label_proxy = QtWidgets.QWidget(self) + label_proxy_layout = QtWidgets.QHBoxLayout(label_proxy) + label_proxy_layout.setContentsMargins(0, 0, 0, 0) + label_proxy_layout.setSpacing(0) + + label_widget = QtWidgets.QLabel(label, label_proxy) + spacer_widget_h = SpacerWidget(label_proxy) + label_proxy_layout.addWidget( + spacer_widget_h, 0, alignment=QtCore.Qt.AlignRight + ) + label_proxy_layout.addWidget( + label_widget, 0, alignment=QtCore.Qt.AlignRight + ) + + spacer_widget_v = SpacerWidget(self) + + layout.addWidget(label_proxy, 0) + layout.addWidget(spacer_widget_v, 1) + + self.label_widget = label_widget + + def setProperty(self, name, value): + cur_value = self.properties.get(name) + if cur_value == value: + return + + self.label_widget.setProperty(name, value) + self.label_widget.style().polish(self.label_widget) + + def mouseReleaseEvent(self, event): + if self.input_field: + return self.input_field.show_actions_menu(event) + return super(GridLabelWidget, self).mouseReleaseEvent(event) diff --git a/pype/tools/settings/settings/widgets/window.py b/pype/tools/settings/settings/widgets/window.py new file mode 100644 index 0000000000..f83da8efe0 --- /dev/null +++ b/pype/tools/settings/settings/widgets/window.py @@ -0,0 +1,28 @@ +from Qt import QtWidgets +from .base import SystemWidget, ProjectWidget + + +class MainWidget(QtWidgets.QWidget): + widget_width = 1000 + widget_height = 600 + + def __init__(self, develop, parent=None): + super(MainWidget, self).__init__(parent) + self.setObjectName("MainWidget") + self.setWindowTitle("Pype Settings") + + self.resize(self.widget_width, self.widget_height) + + header_tab_widget = QtWidgets.QTabWidget(parent=self) + + studio_widget = SystemWidget(develop, header_tab_widget) + project_widget = ProjectWidget(develop, header_tab_widget) + header_tab_widget.addTab(studio_widget, "System") + header_tab_widget.addTab(project_widget, "Project") + + layout = QtWidgets.QVBoxLayout(self) + layout.setContentsMargins(5, 5, 5, 5) + layout.setSpacing(0) + layout.addWidget(header_tab_widget) + + self.setLayout(layout) diff --git a/pype/tools/standalonepublish/__main__.py b/pype/tools/standalonepublish/__main__.py index aba8e6c0a4..85a574f8dc 100644 --- a/pype/tools/standalonepublish/__main__.py +++ b/pype/tools/standalonepublish/__main__.py @@ -1,15 +1,26 @@ import os import sys import app +import ctypes import signal -from Qt import QtWidgets +from Qt import QtWidgets, QtGui from avalon import style +from pype.api import resources if __name__ == "__main__": + + # Allow to change icon of running process in windows taskbar + if os.name == "nt": + ctypes.windll.shell32.SetCurrentProcessExplicitAppUserModelID( + u"standalonepublish" + ) + qt_app = QtWidgets.QApplication([]) # app.setQuitOnLastWindowClosed(False) qt_app.setStyleSheet(style.load_stylesheet()) + icon = QtGui.QIcon(resources.pype_icon_filepath()) + qt_app.setWindowIcon(icon) def signal_handler(sig, frame): print("You pressed Ctrl+C. Process ended.") diff --git a/pype/tools/standalonepublish/app.py b/pype/tools/standalonepublish/app.py index d139366a1c..feba46987f 100644 --- a/pype/tools/standalonepublish/app.py +++ b/pype/tools/standalonepublish/app.py @@ -1,7 +1,7 @@ from bson.objectid import ObjectId from Qt import QtWidgets, QtCore from widgets import AssetWidget, FamilyWidget, ComponentsWidget, ShadowWidget -from avalon.tools.libraryloader.io_nonsingleton import DbConnector +from avalon.api import AvalonMongoDB class Window(QtWidgets.QDialog): @@ -10,7 +10,7 @@ class Window(QtWidgets.QDialog): :param parent: Main widget that cares about all GUIs :type parent: QtWidgets.QMainWindow """ - _db = DbConnector() + _db = AvalonMongoDB() _jobs = {} valid_family = False valid_components = False diff --git a/pype/tools/standalonepublish/resources/__init__.py b/pype/tools/standalonepublish/widgets/resources/__init__.py similarity index 100% rename from pype/tools/standalonepublish/resources/__init__.py rename to pype/tools/standalonepublish/widgets/resources/__init__.py diff --git a/pype/tools/standalonepublish/resources/edit.svg b/pype/tools/standalonepublish/widgets/resources/edit.svg similarity index 100% rename from pype/tools/standalonepublish/resources/edit.svg rename to pype/tools/standalonepublish/widgets/resources/edit.svg diff --git a/pype/tools/standalonepublish/resources/file.png b/pype/tools/standalonepublish/widgets/resources/file.png similarity index 100% rename from pype/tools/standalonepublish/resources/file.png rename to pype/tools/standalonepublish/widgets/resources/file.png diff --git a/pype/tools/standalonepublish/resources/files.png b/pype/tools/standalonepublish/widgets/resources/files.png similarity index 100% rename from pype/tools/standalonepublish/resources/files.png rename to pype/tools/standalonepublish/widgets/resources/files.png diff --git a/pype/tools/standalonepublish/resources/houdini.png b/pype/tools/standalonepublish/widgets/resources/houdini.png similarity index 100% rename from pype/tools/standalonepublish/resources/houdini.png rename to pype/tools/standalonepublish/widgets/resources/houdini.png diff --git a/pype/tools/standalonepublish/resources/image_file.png b/pype/tools/standalonepublish/widgets/resources/image_file.png similarity index 100% rename from pype/tools/standalonepublish/resources/image_file.png rename to pype/tools/standalonepublish/widgets/resources/image_file.png diff --git a/pype/tools/standalonepublish/resources/image_files.png b/pype/tools/standalonepublish/widgets/resources/image_files.png similarity index 100% rename from pype/tools/standalonepublish/resources/image_files.png rename to pype/tools/standalonepublish/widgets/resources/image_files.png diff --git a/pype/tools/standalonepublish/resources/information.svg b/pype/tools/standalonepublish/widgets/resources/information.svg similarity index 100% rename from pype/tools/standalonepublish/resources/information.svg rename to pype/tools/standalonepublish/widgets/resources/information.svg diff --git a/pype/tools/standalonepublish/resources/maya.png b/pype/tools/standalonepublish/widgets/resources/maya.png similarity index 100% rename from pype/tools/standalonepublish/resources/maya.png rename to pype/tools/standalonepublish/widgets/resources/maya.png diff --git a/pype/tools/standalonepublish/resources/menu.png b/pype/tools/standalonepublish/widgets/resources/menu.png similarity index 100% rename from pype/tools/standalonepublish/resources/menu.png rename to pype/tools/standalonepublish/widgets/resources/menu.png diff --git a/pype/tools/standalonepublish/resources/menu_disabled.png b/pype/tools/standalonepublish/widgets/resources/menu_disabled.png similarity index 100% rename from pype/tools/standalonepublish/resources/menu_disabled.png rename to pype/tools/standalonepublish/widgets/resources/menu_disabled.png diff --git a/pype/tools/standalonepublish/resources/menu_hover.png b/pype/tools/standalonepublish/widgets/resources/menu_hover.png similarity index 100% rename from pype/tools/standalonepublish/resources/menu_hover.png rename to pype/tools/standalonepublish/widgets/resources/menu_hover.png diff --git a/pype/tools/standalonepublish/resources/menu_pressed.png b/pype/tools/standalonepublish/widgets/resources/menu_pressed.png similarity index 100% rename from pype/tools/standalonepublish/resources/menu_pressed.png rename to pype/tools/standalonepublish/widgets/resources/menu_pressed.png diff --git a/pype/tools/standalonepublish/resources/menu_pressed_hover.png b/pype/tools/standalonepublish/widgets/resources/menu_pressed_hover.png similarity index 100% rename from pype/tools/standalonepublish/resources/menu_pressed_hover.png rename to pype/tools/standalonepublish/widgets/resources/menu_pressed_hover.png diff --git a/pype/tools/standalonepublish/resources/nuke.png b/pype/tools/standalonepublish/widgets/resources/nuke.png similarity index 100% rename from pype/tools/standalonepublish/resources/nuke.png rename to pype/tools/standalonepublish/widgets/resources/nuke.png diff --git a/pype/tools/standalonepublish/resources/premiere.png b/pype/tools/standalonepublish/widgets/resources/premiere.png similarity index 100% rename from pype/tools/standalonepublish/resources/premiere.png rename to pype/tools/standalonepublish/widgets/resources/premiere.png diff --git a/pype/tools/standalonepublish/resources/trash.png b/pype/tools/standalonepublish/widgets/resources/trash.png similarity index 100% rename from pype/tools/standalonepublish/resources/trash.png rename to pype/tools/standalonepublish/widgets/resources/trash.png diff --git a/pype/tools/standalonepublish/resources/trash_disabled.png b/pype/tools/standalonepublish/widgets/resources/trash_disabled.png similarity index 100% rename from pype/tools/standalonepublish/resources/trash_disabled.png rename to pype/tools/standalonepublish/widgets/resources/trash_disabled.png diff --git a/pype/tools/standalonepublish/resources/trash_hover.png b/pype/tools/standalonepublish/widgets/resources/trash_hover.png similarity index 100% rename from pype/tools/standalonepublish/resources/trash_hover.png rename to pype/tools/standalonepublish/widgets/resources/trash_hover.png diff --git a/pype/tools/standalonepublish/resources/trash_pressed.png b/pype/tools/standalonepublish/widgets/resources/trash_pressed.png similarity index 100% rename from pype/tools/standalonepublish/resources/trash_pressed.png rename to pype/tools/standalonepublish/widgets/resources/trash_pressed.png diff --git a/pype/tools/standalonepublish/resources/trash_pressed_hover.png b/pype/tools/standalonepublish/widgets/resources/trash_pressed_hover.png similarity index 100% rename from pype/tools/standalonepublish/resources/trash_pressed_hover.png rename to pype/tools/standalonepublish/widgets/resources/trash_pressed_hover.png diff --git a/pype/tools/standalonepublish/resources/video_file.png b/pype/tools/standalonepublish/widgets/resources/video_file.png similarity index 100% rename from pype/tools/standalonepublish/resources/video_file.png rename to pype/tools/standalonepublish/widgets/resources/video_file.png diff --git a/pype/tools/standalonepublish/widgets/widget_asset.py b/pype/tools/standalonepublish/widgets/widget_asset.py index c468c9627b..6f041a535f 100644 --- a/pype/tools/standalonepublish/widgets/widget_asset.py +++ b/pype/tools/standalonepublish/widgets/widget_asset.py @@ -240,7 +240,7 @@ class AssetWidget(QtWidgets.QWidget): self.combo_projects.clear() if len(projects) > 0: self.combo_projects.addItems(projects) - self.dbcon.activate_project(projects[0]) + self.dbcon.Session["AVALON_PROJECT"] = projects[0] def on_project_change(self): projects = list() @@ -248,7 +248,7 @@ class AssetWidget(QtWidgets.QWidget): projects.append(project['name']) project_name = self.combo_projects.currentText() if project_name in projects: - self.dbcon.activate_project(project_name) + self.dbcon.Session["AVALON_PROJECT"] = project_name self.refresh() def _refresh_model(self): diff --git a/pype/tools/standalonepublish/widgets/widget_component_item.py b/pype/tools/standalonepublish/widgets/widget_component_item.py index dd838075e3..3850d68b96 100644 --- a/pype/tools/standalonepublish/widgets/widget_component_item.py +++ b/pype/tools/standalonepublish/widgets/widget_component_item.py @@ -1,6 +1,6 @@ import os from Qt import QtCore, QtGui, QtWidgets -from pype.resources import get_resource +from .resources import get_resource from avalon import style @@ -353,27 +353,37 @@ class LightingButton(QtWidgets.QPushButton): class PngFactory: - png_names = { - "trash": { - "normal": QtGui.QIcon(get_resource("trash.png")), - "hover": QtGui.QIcon(get_resource("trash_hover.png")), - "pressed": QtGui.QIcon(get_resource("trash_pressed.png")), - "pressed_hover": QtGui.QIcon( - get_resource("trash_pressed_hover.png") - ), - "disabled": QtGui.QIcon(get_resource("trash_disabled.png")) - }, + png_names = None - "menu": { - "normal": QtGui.QIcon(get_resource("menu.png")), - "hover": QtGui.QIcon(get_resource("menu_hover.png")), - "pressed": QtGui.QIcon(get_resource("menu_pressed.png")), - "pressed_hover": QtGui.QIcon( - get_resource("menu_pressed_hover.png") - ), - "disabled": QtGui.QIcon(get_resource("menu_disabled.png")) + @classmethod + def init(cls): + cls.png_names = { + "trash": { + "normal": QtGui.QIcon(get_resource("trash.png")), + "hover": QtGui.QIcon(get_resource("trash_hover.png")), + "pressed": QtGui.QIcon(get_resource("trash_pressed.png")), + "pressed_hover": QtGui.QIcon( + get_resource("trash_pressed_hover.png") + ), + "disabled": QtGui.QIcon(get_resource("trash_disabled.png")) + }, + + "menu": { + "normal": QtGui.QIcon(get_resource("menu.png")), + "hover": QtGui.QIcon(get_resource("menu_hover.png")), + "pressed": QtGui.QIcon(get_resource("menu_pressed.png")), + "pressed_hover": QtGui.QIcon( + get_resource("menu_pressed_hover.png") + ), + "disabled": QtGui.QIcon(get_resource("menu_disabled.png")) + } } - } + + @classmethod + def get_png(cls, name): + if cls.png_names is None: + cls.init() + return cls.png_names.get(name) class PngButton(QtWidgets.QPushButton): @@ -406,7 +416,7 @@ class PngButton(QtWidgets.QPushButton): png_dict = {} if name: - png_dict = PngFactory.png_names.get(name) or {} + png_dict = PngFactory.get_png(name) or {} if not png_dict: print(( "WARNING: There is not set icon with name \"{}\"" diff --git a/pype/tools/standalonepublish/widgets/widget_drop_frame.py b/pype/tools/standalonepublish/widgets/widget_drop_frame.py index e13f701b30..a7abe1b24c 100644 --- a/pype/tools/standalonepublish/widgets/widget_drop_frame.py +++ b/pype/tools/standalonepublish/widgets/widget_drop_frame.py @@ -268,9 +268,10 @@ class DropDataFrame(QtWidgets.QFrame): args = [ ffprobe_path, '-v', 'quiet', - '-print_format', 'json', + '-print_format json', '-show_format', - '-show_streams', filepath + '-show_streams', + '"{}"'.format(filepath) ] ffprobe_p = subprocess.Popen( ' '.join(args), diff --git a/pype/tools/tray/modules_imports.json b/pype/tools/tray/modules_imports.json index e9526dcddb..6a278840ea 100644 --- a/pype/tools/tray/modules_imports.json +++ b/pype/tools/tray/modules_imports.json @@ -54,5 +54,10 @@ "type": "module", "import_path": "pype.modules.adobe_communicator", "fromlist": ["pype", "modules"] + }, { + "title": "Websocket Server", + "type": "module", + "import_path": "pype.modules.websocket_server", + "fromlist": ["pype", "modules"] } ] diff --git a/pype/tools/tray/pype_tray.py b/pype/tools/tray/pype_tray.py index 9537b62581..a4cf4eabfe 100644 --- a/pype/tools/tray/pype_tray.py +++ b/pype/tools/tray/pype_tray.py @@ -537,6 +537,14 @@ class PypeTrayApplication(QtWidgets.QApplication): super(self.__class__, self).__init__(sys.argv) # Allows to close widgets without exiting app self.setQuitOnLastWindowClosed(False) + + # Allow show icon istead of python icon in task bar (Windows) + if os.name == "nt": + import ctypes + ctypes.windll.shell32.SetCurrentProcessExplicitAppUserModelID( + u"pype_tray" + ) + # Sets up splash splash_widget = self.set_splash() diff --git a/pype/version.py b/pype/version.py index 0d4f03098b..521faacafe 100644 --- a/pype/version.py +++ b/pype/version.py @@ -1 +1 @@ -__version__ = "2.11.5" +__version__ = "2.12.3" diff --git a/pype/widgets/message_window.py b/pype/widgets/message_window.py index 41c709b933..969d6ccdd1 100644 --- a/pype/widgets/message_window.py +++ b/pype/widgets/message_window.py @@ -1,4 +1,4 @@ -from Qt import QtWidgets +from Qt import QtWidgets, QtCore import sys import logging @@ -49,6 +49,17 @@ class Window(QtWidgets.QWidget): def message(title=None, message=None, level="info", parent=None): + """ + Produces centered dialog with specific level denoting severity + Args: + title: (string) dialog title + message: (string) message + level: (string) info|warning|critical + parent: (QtWidgets.QApplication) + + Returns: + None + """ app = parent if not app: app = QtWidgets.QApplication(sys.argv) @@ -68,3 +79,60 @@ def message(title=None, message=None, level="info", parent=None): # skip all possible issues that may happen feature is not crutial log.warning("Couldn't center message.", exc_info=True) # sys.exit(app.exec_()) + + +class ScrollMessageBox(QtWidgets.QDialog): + """ + Basic version of scrollable QMessageBox. No other existing dialog + implementation is scrollable. + Args: + icon: + title: + messages: of messages + cancelable: - True if Cancel button should be added + """ + def __init__(self, icon, title, messages, cancelable=False): + super(ScrollMessageBox, self).__init__() + self.setWindowTitle(title) + self.icon = icon + + self.setWindowFlags(QtCore.Qt.WindowTitleHint) + + layout = QtWidgets.QVBoxLayout(self) + + scroll_widget = QtWidgets.QScrollArea(self) + scroll_widget.setWidgetResizable(True) + content_widget = QtWidgets.QWidget(self) + scroll_widget.setWidget(content_widget) + + max_len = 0 + content_layout = QtWidgets.QVBoxLayout(content_widget) + for message in messages: + label_widget = QtWidgets.QLabel(message, content_widget) + content_layout.addWidget(label_widget) + max_len = max(max_len, len(message)) + + # guess size of scrollable area + max_width = QtWidgets.QApplication.desktop().availableGeometry().width + scroll_widget.setMinimumWidth(min(max_width, max_len * 6)) + layout.addWidget(scroll_widget) + + if not cancelable: # if no specific buttons OK only + buttons = QtWidgets.QDialogButtonBox.Ok + else: + buttons = QtWidgets.QDialogButtonBox.Ok | \ + QtWidgets.QDialogButtonBox.Cancel + + btn_box = QtWidgets.QDialogButtonBox(buttons) + btn_box.accepted.connect(self.accept) + + if cancelable: + btn_box.reject.connect(self.reject) + + btn = QtWidgets.QPushButton('Copy to clipboard') + btn.clicked.connect(lambda: QtWidgets.QApplication. + clipboard().setText("\n".join(messages))) + btn_box.addButton(btn, QtWidgets.QDialogButtonBox.NoRole) + + layout.addWidget(btn_box) + self.show() diff --git a/schema/config-1.1.json b/schema/config-1.1.json new file mode 100644 index 0000000000..ea5ab0ff27 --- /dev/null +++ b/schema/config-1.1.json @@ -0,0 +1,87 @@ +{ + "$schema": "http://json-schema.org/draft-04/schema#", + + "title": "pype:config-1.1", + "description": "A project configuration.", + + "type": "object", + + "additionalProperties": false, + "required": [ + "tasks", + "apps" + ], + + "properties": { + "schema": { + "description": "Schema identifier for payload", + "type": "string" + }, + "template": { + "type": "object", + "additionalProperties": false, + "patternProperties": { + "^.*$": { + "type": "string" + } + } + }, + "tasks": { + "type": "object", + "items": { + "type": "object", + "properties": { + "name": {"type": "string"}, + "icon": {"type": "string"}, + "group": {"type": "string"}, + "label": {"type": "string"} + }, + "required": [ + "short_name" + ] + } + }, + "apps": { + "type": "array", + "items": { + "type": "object", + "properties": { + "name": {"type": "string"}, + "icon": {"type": "string"}, + "group": {"type": "string"}, + "label": {"type": "string"} + }, + "required": ["name"] + } + }, + "families": { + "type": "array", + "items": { + "type": "object", + "properties": { + "name": {"type": "string"}, + "icon": {"type": "string"}, + "label": {"type": "string"}, + "hideFilter": {"type": "boolean"} + }, + "required": ["name"] + } + }, + "groups": { + "type": "array", + "items": { + "type": "object", + "properties": { + "name": {"type": "string"}, + "icon": {"type": "string"}, + "color": {"type": "string"}, + "order": {"type": ["integer", "number"]} + }, + "required": ["name"] + } + }, + "copy": { + "type": "object" + } + } +} diff --git a/schema/inventory-1.1.json b/schema/inventory-1.1.json new file mode 100644 index 0000000000..1b572b7d23 --- /dev/null +++ b/schema/inventory-1.1.json @@ -0,0 +1,10 @@ +{ + "$schema": "http://json-schema.org/draft-04/schema#", + + "title": "pype:config-1.1", + "description": "A project configuration.", + + "type": "object", + + "additionalProperties": true +} diff --git a/schema/project-2.1.json b/schema/project-2.1.json new file mode 100644 index 0000000000..40e3bdb638 --- /dev/null +++ b/schema/project-2.1.json @@ -0,0 +1,86 @@ +{ + "$schema": "http://json-schema.org/draft-04/schema#", + + "title": "pype:project-2.1", + "description": "A unit of data", + + "type": "object", + + "additionalProperties": true, + + "required": [ + "schema", + "type", + "name", + "data", + "config" + ], + + "properties": { + "schema": { + "description": "Schema identifier for payload", + "type": "string", + "enum": ["avalon-core:project-2.1", "pype:project-2.1"], + "example": "avalon-core:project-2.1" + }, + "type": { + "description": "The type of document", + "type": "string", + "enum": ["project"], + "example": "project" + }, + "parent": { + "description": "Unique identifier to parent document", + "example": "592c33475f8c1b064c4d1696" + }, + "name": { + "description": "Name of directory", + "type": "string", + "pattern": "^[a-zA-Z0-9_.]*$", + "example": "hulk" + }, + "data": { + "description": "Document metadata", + "type": "object", + "example": { + "fps": 24, + "width": 1920, + "height": 1080 + } + }, + "config": { + "type": "object", + "description": "Document metadata", + "example": { + "schema": "pype:config-1.1", + "apps": [ + { + "name": "maya2016", + "label": "Autodesk Maya 2016" + }, + { + "name": "nuke10", + "label": "The Foundry Nuke 10.0" + } + ], + "tasks": { + "Model": {"short_name": "mdl"}, + "Render": {"short_name": "rnd"}, + "Animate": {"short_name": "anim"}, + "Rig": {"short_name": "rig"}, + "Lookdev": {"short_name": "look"}, + "Layout": {"short_name": "lay"} + }, + "template": { + "work": + "{root}/{project}/{silo}/{asset}/work/{task}/{app}", + "publish": + "{root}/{project}/{silo}/{asset}/publish/{subset}/v{version:0>3}/{subset}.{representation}" + } + }, + "$ref": "config-1.1.json" + } + }, + + "definitions": {} +}