diff --git a/.github/workflows/prerelease.yml b/.github/workflows/prerelease.yml index 60ce608b21..258458e2d4 100644 --- a/.github/workflows/prerelease.yml +++ b/.github/workflows/prerelease.yml @@ -33,7 +33,7 @@ jobs: id: version if: steps.version_type.outputs.type != 'skip' run: | - RESULT=$(python ./tools/ci_tools.py --nightly) + RESULT=$(python ./tools/ci_tools.py --nightly --github_token ${{ secrets.GITHUB_TOKEN }}) echo ::set-output name=next_tag::$RESULT diff --git a/CHANGELOG.md b/CHANGELOG.md index 95792f8a7a..9a3571eca6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,39 +1,99 @@ # Changelog +## [3.6.2-nightly.1](https://github.com/pypeclub/OpenPype/tree/HEAD) + +[Full Changelog](https://github.com/pypeclub/OpenPype/compare/3.6.1...HEAD) + +**πŸš€ Enhancements** + +- Tools: SceneInventory in OpenPype [\#2255](https://github.com/pypeclub/OpenPype/pull/2255) +- Tools: Tasks widget [\#2251](https://github.com/pypeclub/OpenPype/pull/2251) +- Added endpoint for configured extensions [\#2221](https://github.com/pypeclub/OpenPype/pull/2221) + +**πŸ› Bug fixes** + +- Burnins: Support mxf metadata [\#2247](https://github.com/pypeclub/OpenPype/pull/2247) + +## [3.6.1](https://github.com/pypeclub/OpenPype/tree/3.6.1) (2021-11-16) + +[Full Changelog](https://github.com/pypeclub/OpenPype/compare/CI/3.6.1-nightly.1...3.6.1) + +**πŸ› Bug fixes** + +- Loader doesn't allow changing of version before loading [\#2254](https://github.com/pypeclub/OpenPype/pull/2254) + +## [3.6.0](https://github.com/pypeclub/OpenPype/tree/3.6.0) (2021-11-15) + +[Full Changelog](https://github.com/pypeclub/OpenPype/compare/CI/3.6.0-nightly.6...3.6.0) + +### πŸ“– Documentation + +- Add alternative sites for Site Sync [\#2206](https://github.com/pypeclub/OpenPype/pull/2206) +- Add command line way of running site sync server [\#2188](https://github.com/pypeclub/OpenPype/pull/2188) + +**πŸ†• New features** + +- Add validate active site button to sync queue on a project [\#2176](https://github.com/pypeclub/OpenPype/pull/2176) +- Maya : Colorspace configuration [\#2170](https://github.com/pypeclub/OpenPype/pull/2170) +- Blender: Added support for audio [\#2168](https://github.com/pypeclub/OpenPype/pull/2168) + +**πŸš€ Enhancements** + +- Tools: Subset manager in OpenPype [\#2243](https://github.com/pypeclub/OpenPype/pull/2243) +- General: Skip module directories without init file [\#2239](https://github.com/pypeclub/OpenPype/pull/2239) +- General: Static interfaces [\#2238](https://github.com/pypeclub/OpenPype/pull/2238) +- Style: Fix transparent image in style [\#2235](https://github.com/pypeclub/OpenPype/pull/2235) +- Add a "following workfile versioning" option on publish [\#2225](https://github.com/pypeclub/OpenPype/pull/2225) +- Modules: Module can add cli commands [\#2224](https://github.com/pypeclub/OpenPype/pull/2224) +- Webpublisher: Separate webpublisher logic [\#2222](https://github.com/pypeclub/OpenPype/pull/2222) +- Add both side availability on Site Sync sites to Loader [\#2220](https://github.com/pypeclub/OpenPype/pull/2220) +- Tools: Center loader and library loader on show [\#2219](https://github.com/pypeclub/OpenPype/pull/2219) +- Maya : Validate shape zero [\#2212](https://github.com/pypeclub/OpenPype/pull/2212) +- Maya : validate unique names [\#2211](https://github.com/pypeclub/OpenPype/pull/2211) +- Tools: OpenPype stylesheet in workfiles tool [\#2208](https://github.com/pypeclub/OpenPype/pull/2208) +- Ftrack: Replace Queue with deque in event handlers logic [\#2204](https://github.com/pypeclub/OpenPype/pull/2204) +- Tools: New select context dialog [\#2200](https://github.com/pypeclub/OpenPype/pull/2200) +- Maya : Validate mesh ngons [\#2199](https://github.com/pypeclub/OpenPype/pull/2199) +- Dirmap in Nuke [\#2198](https://github.com/pypeclub/OpenPype/pull/2198) +- Delivery: Check 'frame' key in template for sequence delivery [\#2196](https://github.com/pypeclub/OpenPype/pull/2196) +- Settings: Site sync project settings improvement [\#2193](https://github.com/pypeclub/OpenPype/pull/2193) +- Usage of tools code [\#2185](https://github.com/pypeclub/OpenPype/pull/2185) +- Settings: Dictionary based on project roots [\#2184](https://github.com/pypeclub/OpenPype/pull/2184) +- Subset name: Be able to pass asset document to get subset name [\#2179](https://github.com/pypeclub/OpenPype/pull/2179) + +**πŸ› Bug fixes** + +- Ftrack: Sync project ftrack id cache issue [\#2250](https://github.com/pypeclub/OpenPype/pull/2250) +- Ftrack: Session creation and Prepare project [\#2245](https://github.com/pypeclub/OpenPype/pull/2245) +- Added queue for studio processing in PS [\#2237](https://github.com/pypeclub/OpenPype/pull/2237) +- Python 2: Unicode to string conversion [\#2236](https://github.com/pypeclub/OpenPype/pull/2236) +- Fix - enum for color coding in PS [\#2234](https://github.com/pypeclub/OpenPype/pull/2234) +- Pyblish Tool: Fix targets handling [\#2232](https://github.com/pypeclub/OpenPype/pull/2232) +- Ftrack: Base event fix of 'get\_project\_from\_entity' method [\#2214](https://github.com/pypeclub/OpenPype/pull/2214) +- Maya : multiple subsets review broken [\#2210](https://github.com/pypeclub/OpenPype/pull/2210) +- Fix - different command used for Linux and Mac OS [\#2207](https://github.com/pypeclub/OpenPype/pull/2207) +- Tools: Workfiles tool don't use avalon widgets [\#2205](https://github.com/pypeclub/OpenPype/pull/2205) +- Ftrack: Fill missing ftrack id on mongo project [\#2203](https://github.com/pypeclub/OpenPype/pull/2203) +- Project Manager: Fix copying of tasks [\#2191](https://github.com/pypeclub/OpenPype/pull/2191) +- Blender: Fix trying to pack an image when the shader node has no texture [\#2183](https://github.com/pypeclub/OpenPype/pull/2183) +- Maya: review viewport settings [\#2177](https://github.com/pypeclub/OpenPype/pull/2177) +- Maya: Aspect ratio [\#2174](https://github.com/pypeclub/OpenPype/pull/2174) + ## [3.5.0](https://github.com/pypeclub/OpenPype/tree/3.5.0) (2021-10-17) -[Full Changelog](https://github.com/pypeclub/OpenPype/compare/3.4.1...3.5.0) - -**Deprecated:** - -- Maya: Change mayaAscii family to mayaScene [\#2106](https://github.com/pypeclub/OpenPype/pull/2106) +[Full Changelog](https://github.com/pypeclub/OpenPype/compare/CI/3.5.0-nightly.8...3.5.0) **πŸ†• New features** - Added project and task into context change message in Maya [\#2131](https://github.com/pypeclub/OpenPype/pull/2131) - Add ExtractBurnin to photoshop review [\#2124](https://github.com/pypeclub/OpenPype/pull/2124) - PYPE-1218 - changed namespace to contain subset name in Maya [\#2114](https://github.com/pypeclub/OpenPype/pull/2114) -- Added running configurable disk mapping command before start of OP [\#2091](https://github.com/pypeclub/OpenPype/pull/2091) -- SFTP provider [\#2073](https://github.com/pypeclub/OpenPype/pull/2073) -- Maya: Validate setdress top group [\#2068](https://github.com/pypeclub/OpenPype/pull/2068) **πŸš€ Enhancements** - Maya: make rig validators configurable in settings [\#2137](https://github.com/pypeclub/OpenPype/pull/2137) - Settings: Updated readme for entity types in settings [\#2132](https://github.com/pypeclub/OpenPype/pull/2132) - Nuke: unified clip loader [\#2128](https://github.com/pypeclub/OpenPype/pull/2128) -- Settings UI: Project model refreshing and sorting [\#2104](https://github.com/pypeclub/OpenPype/pull/2104) -- Create Read From Rendered - Disable Relative paths by default [\#2093](https://github.com/pypeclub/OpenPype/pull/2093) -- Added choosing different dirmap mapping if workfile synched locally [\#2088](https://github.com/pypeclub/OpenPype/pull/2088) -- General: Remove IdleManager module [\#2084](https://github.com/pypeclub/OpenPype/pull/2084) -- Tray UI: Message box about missing settings defaults [\#2080](https://github.com/pypeclub/OpenPype/pull/2080) -- Tray UI: Show menu where first click happened [\#2079](https://github.com/pypeclub/OpenPype/pull/2079) -- Global: add global validators to settings [\#2078](https://github.com/pypeclub/OpenPype/pull/2078) -- Use CRF for burnin when available [\#2070](https://github.com/pypeclub/OpenPype/pull/2070) -- Project manager: Filter first item after selection of project [\#2069](https://github.com/pypeclub/OpenPype/pull/2069) -- Nuke: Adding `still` image family workflow [\#2064](https://github.com/pypeclub/OpenPype/pull/2064) -- Maya: validate authorized loaded plugins [\#2062](https://github.com/pypeclub/OpenPype/pull/2062) -- Tools: add support for pyenv on windows [\#2051](https://github.com/pypeclub/OpenPype/pull/2051) **πŸ› Bug fixes** @@ -41,96 +101,15 @@ - Fix - oiiotool wasn't recognized even if present [\#2129](https://github.com/pypeclub/OpenPype/pull/2129) - General: Disk mapping group [\#2120](https://github.com/pypeclub/OpenPype/pull/2120) - Hiero: publishing effect first time makes wrong resources path [\#2115](https://github.com/pypeclub/OpenPype/pull/2115) -- Add startup script for Houdini Core. [\#2110](https://github.com/pypeclub/OpenPype/pull/2110) -- TVPaint: Behavior name of loop also accept repeat [\#2109](https://github.com/pypeclub/OpenPype/pull/2109) -- Ftrack: Project settings save custom attributes skip unknown attributes [\#2103](https://github.com/pypeclub/OpenPype/pull/2103) -- Blender: Fix NoneType error when animation\_data is missing for a rig [\#2101](https://github.com/pypeclub/OpenPype/pull/2101) -- Fix broken import in sftp provider [\#2100](https://github.com/pypeclub/OpenPype/pull/2100) -- Global: Fix docstring on publish plugin extract review [\#2097](https://github.com/pypeclub/OpenPype/pull/2097) -- Delivery Action Files Sequence fix [\#2096](https://github.com/pypeclub/OpenPype/pull/2096) -- General: Cloud mongo ca certificate issue [\#2095](https://github.com/pypeclub/OpenPype/pull/2095) -- TVPaint: Creator use context from workfile [\#2087](https://github.com/pypeclub/OpenPype/pull/2087) -- Blender: fix texture missing when publishing blend files [\#2085](https://github.com/pypeclub/OpenPype/pull/2085) -- General: Startup validations oiio tool path fix on linux [\#2083](https://github.com/pypeclub/OpenPype/pull/2083) -- Deadline: Collect deadline server does not check existence of deadline key [\#2082](https://github.com/pypeclub/OpenPype/pull/2082) -- Blender: fixed Curves with modifiers in Rigs [\#2081](https://github.com/pypeclub/OpenPype/pull/2081) -- Nuke UI scaling [\#2077](https://github.com/pypeclub/OpenPype/pull/2077) -- Maya: Fix multi-camera renders [\#2065](https://github.com/pypeclub/OpenPype/pull/2065) -- Fix Sync Queue when project disabled [\#2063](https://github.com/pypeclub/OpenPype/pull/2063) - -**Merged pull requests:** - -- Bump pywin32 from 300 to 301 [\#2086](https://github.com/pypeclub/OpenPype/pull/2086) ## [3.4.1](https://github.com/pypeclub/OpenPype/tree/3.4.1) (2021-09-23) [Full Changelog](https://github.com/pypeclub/OpenPype/compare/CI/3.4.1-nightly.1...3.4.1) -**πŸ†• New features** - -- Settings: Flag project as deactivated and hide from tools' view [\#2008](https://github.com/pypeclub/OpenPype/pull/2008) - -**πŸš€ Enhancements** - -- General: Startup validations [\#2054](https://github.com/pypeclub/OpenPype/pull/2054) -- Nuke: proxy mode validator [\#2052](https://github.com/pypeclub/OpenPype/pull/2052) -- Ftrack: Removed ftrack interface [\#2049](https://github.com/pypeclub/OpenPype/pull/2049) -- Settings UI: Deffered set value on entity [\#2044](https://github.com/pypeclub/OpenPype/pull/2044) -- Loader: Families filtering [\#2043](https://github.com/pypeclub/OpenPype/pull/2043) -- Settings UI: Project view enhancements [\#2042](https://github.com/pypeclub/OpenPype/pull/2042) -- Settings for Nuke IncrementScriptVersion [\#2039](https://github.com/pypeclub/OpenPype/pull/2039) -- Loader & Library loader: Use tools from OpenPype [\#2038](https://github.com/pypeclub/OpenPype/pull/2038) -- Adding predefined project folders creation in PM [\#2030](https://github.com/pypeclub/OpenPype/pull/2030) -- WebserverModule: Removed interface of webserver module [\#2028](https://github.com/pypeclub/OpenPype/pull/2028) -- TimersManager: Removed interface of timers manager [\#2024](https://github.com/pypeclub/OpenPype/pull/2024) -- Feature Maya import asset from scene inventory [\#2018](https://github.com/pypeclub/OpenPype/pull/2018) - -**πŸ› Bug fixes** - -- Timers manger: Typo fix [\#2058](https://github.com/pypeclub/OpenPype/pull/2058) -- Hiero: Editorial fixes [\#2057](https://github.com/pypeclub/OpenPype/pull/2057) -- Differentiate jpg sequences from thumbnail [\#2056](https://github.com/pypeclub/OpenPype/pull/2056) -- FFmpeg: Split command to list does not work [\#2046](https://github.com/pypeclub/OpenPype/pull/2046) -- Removed shell flag in subprocess call [\#2045](https://github.com/pypeclub/OpenPype/pull/2045) - -**Merged pull requests:** - -- Bump prismjs from 1.24.0 to 1.25.0 in /website [\#2050](https://github.com/pypeclub/OpenPype/pull/2050) - ## [3.4.0](https://github.com/pypeclub/OpenPype/tree/3.4.0) (2021-09-17) [Full Changelog](https://github.com/pypeclub/OpenPype/compare/CI/3.4.0-nightly.6...3.4.0) -**πŸ†• New features** - -- Nuke: Compatibility with Nuke 13 [\#2003](https://github.com/pypeclub/OpenPype/pull/2003) - -**πŸš€ Enhancements** - -- Added possibility to configure of synchronization of workfile version… [\#2041](https://github.com/pypeclub/OpenPype/pull/2041) -- General: Task types in profiles [\#2036](https://github.com/pypeclub/OpenPype/pull/2036) -- Console interpreter: Handle invalid sizes on initialization [\#2022](https://github.com/pypeclub/OpenPype/pull/2022) -- Ftrack: Show OpenPype versions in event server status [\#2019](https://github.com/pypeclub/OpenPype/pull/2019) -- General: Staging icon [\#2017](https://github.com/pypeclub/OpenPype/pull/2017) -- Ftrack: Sync to avalon actions have jobs [\#2015](https://github.com/pypeclub/OpenPype/pull/2015) -- Modules: Connect method is not required [\#2009](https://github.com/pypeclub/OpenPype/pull/2009) -- Settings UI: Number with configurable steps [\#2001](https://github.com/pypeclub/OpenPype/pull/2001) -- Moving project folder structure creation out of ftrack module \#1989 [\#1996](https://github.com/pypeclub/OpenPype/pull/1996) - -**πŸ› Bug fixes** - -- Workfiles tool: Task selection [\#2040](https://github.com/pypeclub/OpenPype/pull/2040) -- Ftrack: Delete old versions missing settings key [\#2037](https://github.com/pypeclub/OpenPype/pull/2037) -- Nuke: typo on a button [\#2034](https://github.com/pypeclub/OpenPype/pull/2034) -- Hiero: Fix "none" named tags [\#2033](https://github.com/pypeclub/OpenPype/pull/2033) -- FFmpeg: Subprocess arguments as list [\#2032](https://github.com/pypeclub/OpenPype/pull/2032) -- General: Fix Python 2 breaking line [\#2016](https://github.com/pypeclub/OpenPype/pull/2016) -- Bugfix/webpublisher task type [\#2006](https://github.com/pypeclub/OpenPype/pull/2006) - -### πŸ“– Documentation - -- Documentation: Ftrack launch argsuments update [\#2014](https://github.com/pypeclub/OpenPype/pull/2014) - ## [3.3.1](https://github.com/pypeclub/OpenPype/tree/3.3.1) (2021-08-20) [Full Changelog](https://github.com/pypeclub/OpenPype/compare/CI/3.3.1-nightly.1...3.3.1) diff --git a/openpype/cli.py b/openpype/cli.py index c69407e295..3194723d4c 100644 --- a/openpype/cli.py +++ b/openpype/cli.py @@ -57,6 +57,17 @@ def tray(debug=False): PypeCommands().launch_tray(debug) +@PypeCommands.add_modules +@main.group(help="Run command line arguments of OpenPype modules") +@click.pass_context +def module(ctx): + """Module specific commands created dynamically. + + These commands are generated dynamically by currently loaded addon/modules. + """ + pass + + @main.command() @click.option("-d", "--debug", is_flag=True, help="Print debug messages") @click.option("--ftrack-url", envvar="FTRACK_SERVER", @@ -147,7 +158,9 @@ def extractenvironments(output_json_path, project, asset, task, app): @click.option("-d", "--debug", is_flag=True, help="Print debug messages") @click.option("-t", "--targets", help="Targets module", default=None, multiple=True) -def publish(debug, paths, targets): +@click.option("-g", "--gui", is_flag=True, + help="Show Publish UI", default=False) +def publish(debug, paths, targets, gui): """Start CLI publishing. Publish collects json from paths provided as an argument. @@ -155,7 +168,7 @@ def publish(debug, paths, targets): """ if debug: os.environ['OPENPYPE_DEBUG'] = '3' - PypeCommands.publish(list(paths), targets) + PypeCommands.publish(list(paths), targets, gui) @main.command() @@ -166,7 +179,7 @@ def publish(debug, paths, targets): @click.option("-p", "--project", help="Project") @click.option("-t", "--targets", help="Targets", default=None, multiple=True) -def remotepublish(debug, project, path, host, targets=None, user=None): +def remotepublishfromapp(debug, project, path, host, user=None, targets=None): """Start CLI publishing. Publish collects json from paths provided as an argument. @@ -174,7 +187,27 @@ def remotepublish(debug, project, path, host, targets=None, user=None): """ if debug: os.environ['OPENPYPE_DEBUG'] = '3' - PypeCommands.remotepublish(project, path, host, user, targets=targets) + PypeCommands.remotepublishfromapp( + project, path, host, user, targets=targets + ) + + +@main.command() +@click.argument("path") +@click.option("-d", "--debug", is_flag=True, help="Print debug messages") +@click.option("-u", "--user", help="User email address") +@click.option("-p", "--project", help="Project") +@click.option("-t", "--targets", help="Targets", default=None, + multiple=True) +def remotepublish(debug, project, path, user=None, targets=None): + """Start CLI publishing. + + Publish collects json from paths provided as an argument. + More than one path is allowed. + """ + if debug: + os.environ['OPENPYPE_DEBUG'] = '3' + PypeCommands.remotepublish(project, path, user, targets=targets) @main.command() @@ -263,6 +296,34 @@ def projectmanager(): PypeCommands().launch_project_manager() +@main.command() +@click.argument("output_path") +@click.option("--project", help="Define project context") +@click.option("--asset", help="Define asset in project (project must be set)") +@click.option( + "--strict", + is_flag=True, + help="Full context must be set otherwise dialog can't be closed." +) +def contextselection( + output_path, + project, + asset, + strict +): + """Show Qt dialog to select context. + + Context is project name, asset name and task name. The result is stored + into json file which path is passed in first argument. + """ + PypeCommands.contextselection( + output_path, + project, + asset, + strict + ) + + @main.command( context_settings=dict( ignore_unknown_options=True, @@ -298,3 +359,28 @@ def run(script): def runtests(folder, mark, pyargs): """Run all automatic tests after proper initialization via start.py""" PypeCommands().run_tests(folder, mark, pyargs) + + +@main.command() +@click.option("-d", "--debug", + is_flag=True, help=("Run process in debug mode")) +@click.option("-a", "--active_site", required=True, + help="Name of active stie") +def syncserver(debug, active_site): + """Run sync site server in background. + + Some Site Sync use cases need to expose site to another one. + For example if majority of artists work in studio, they are not using + SS at all, but if you want to expose published assets to 'studio' site + to SFTP for only a couple of artists, some background process must + mark published assets to live on multiple sites (they might be + physically in same location - mounted shared disk). + + Process mimics OP Tray with specific 'active_site' name, all + configuration for this "dummy" user comes from Setting or Local + Settings (configured by starting OP Tray with env + var OPENPYPE_LOCAL_ID set to 'active_site'. + """ + if debug: + os.environ['OPENPYPE_DEBUG'] = '3' + PypeCommands().syncserver(active_site) diff --git a/openpype/hooks/pre_foundry_apps.py b/openpype/hooks/pre_foundry_apps.py index 85f68c6b60..7df1a6a833 100644 --- a/openpype/hooks/pre_foundry_apps.py +++ b/openpype/hooks/pre_foundry_apps.py @@ -13,7 +13,7 @@ class LaunchFoundryAppsWindows(PreLaunchHook): # Should be as last hook because must change launch arguments to string order = 1000 - app_groups = ["nuke", "nukex", "hiero", "nukestudio"] + app_groups = ["nuke", "nukex", "hiero", "nukestudio", "photoshop"] platforms = ["windows"] def execute(self): diff --git a/openpype/hosts/blender/api/plugin.py b/openpype/hosts/blender/api/plugin.py index 50b73ade2b..6d437059b8 100644 --- a/openpype/hosts/blender/api/plugin.py +++ b/openpype/hosts/blender/api/plugin.py @@ -95,6 +95,30 @@ def get_local_collection_with_name(name): return None +def deselect_all(): + """Deselect all objects in the scene. + + Blender gives context error if trying to deselect object that it isn't + in object mode. + """ + modes = [] + active = bpy.context.view_layer.objects.active + + for obj in bpy.data.objects: + if obj.mode != 'OBJECT': + modes.append((obj, obj.mode)) + bpy.context.view_layer.objects.active = obj + bpy.ops.object.mode_set(mode='OBJECT') + + bpy.ops.object.select_all(action='DESELECT') + + for p in modes: + bpy.context.view_layer.objects.active = p[0] + bpy.ops.object.mode_set(mode=p[1]) + + bpy.context.view_layer.objects.active = active + + class Creator(PypeCreatorMixin, blender.Creator): pass diff --git a/openpype/hosts/blender/plugins/create/create_camera.py b/openpype/hosts/blender/plugins/create/create_camera.py index c7fea30787..98ccca313c 100644 --- a/openpype/hosts/blender/plugins/create/create_camera.py +++ b/openpype/hosts/blender/plugins/create/create_camera.py @@ -3,11 +3,12 @@ import bpy from avalon import api -from avalon.blender import lib -import openpype.hosts.blender.api.plugin +from avalon.blender import lib, ops +from avalon.blender.pipeline import AVALON_INSTANCES +from openpype.hosts.blender.api import plugin -class CreateCamera(openpype.hosts.blender.api.plugin.Creator): +class CreateCamera(plugin.Creator): """Polygonal static geometry""" name = "cameraMain" @@ -16,17 +17,46 @@ class CreateCamera(openpype.hosts.blender.api.plugin.Creator): icon = "video-camera" def process(self): + """ Run the creator on Blender main thread""" + mti = ops.MainThreadItem(self._process) + ops.execute_in_main_thread(mti) + def _process(self): + # Get Instance Containter or create it if it does not exist + instances = bpy.data.collections.get(AVALON_INSTANCES) + if not instances: + instances = bpy.data.collections.new(name=AVALON_INSTANCES) + bpy.context.scene.collection.children.link(instances) + + # Create instance object asset = self.data["asset"] subset = self.data["subset"] - name = openpype.hosts.blender.api.plugin.asset_name(asset, subset) - collection = bpy.data.collections.new(name=name) - bpy.context.scene.collection.children.link(collection) + name = plugin.asset_name(asset, subset) + + camera = bpy.data.cameras.new(subset) + camera_obj = bpy.data.objects.new(subset, camera) + + instances.objects.link(camera_obj) + + asset_group = bpy.data.objects.new(name=name, object_data=None) + asset_group.empty_display_type = 'SINGLE_ARROW' + instances.objects.link(asset_group) self.data['task'] = api.Session.get('AVALON_TASK') - lib.imprint(collection, self.data) + print(f"self.data: {self.data}") + lib.imprint(asset_group, self.data) if (self.options or {}).get("useSelection"): - for obj in lib.get_selection(): - collection.objects.link(obj) + bpy.context.view_layer.objects.active = asset_group + selected = lib.get_selection() + for obj in selected: + obj.select_set(True) + selected.append(asset_group) + bpy.ops.object.parent_set(keep_transform=True) + else: + plugin.deselect_all() + camera_obj.select_set(True) + asset_group.select_set(True) + bpy.context.view_layer.objects.active = asset_group + bpy.ops.object.parent_set(keep_transform=True) - return collection + return asset_group diff --git a/openpype/hosts/blender/plugins/load/load_abc.py b/openpype/hosts/blender/plugins/load/load_abc.py index 92656fac9e..5969432c36 100644 --- a/openpype/hosts/blender/plugins/load/load_abc.py +++ b/openpype/hosts/blender/plugins/load/load_abc.py @@ -47,7 +47,7 @@ class CacheModelLoader(plugin.AssetLoader): bpy.data.objects.remove(empty) def _process(self, libpath, asset_group, group_name): - bpy.ops.object.select_all(action='DESELECT') + plugin.deselect_all() collection = bpy.context.view_layer.active_layer_collection.collection @@ -109,7 +109,7 @@ class CacheModelLoader(plugin.AssetLoader): avalon_info = obj[AVALON_PROPERTY] avalon_info.update({"container_name": group_name}) - bpy.ops.object.select_all(action='DESELECT') + plugin.deselect_all() return objects diff --git a/openpype/hosts/blender/plugins/load/load_audio.py b/openpype/hosts/blender/plugins/load/load_audio.py new file mode 100644 index 0000000000..660e4d7890 --- /dev/null +++ b/openpype/hosts/blender/plugins/load/load_audio.py @@ -0,0 +1,217 @@ +"""Load audio in Blender.""" + +from pathlib import Path +from pprint import pformat +from typing import Dict, List, Optional + +import bpy + +from avalon import api +from avalon.blender.pipeline import AVALON_CONTAINERS +from avalon.blender.pipeline import AVALON_CONTAINER_ID +from avalon.blender.pipeline import AVALON_PROPERTY +from openpype.hosts.blender.api import plugin + + +class AudioLoader(plugin.AssetLoader): + """Load audio in Blender.""" + + families = ["audio"] + representations = ["wav"] + + label = "Load Audio" + icon = "volume-up" + color = "orange" + + def process_asset( + self, context: dict, name: str, namespace: Optional[str] = None, + options: Optional[Dict] = None + ) -> Optional[List]: + """ + Arguments: + name: Use pre-defined name + namespace: Use pre-defined namespace + context: Full parenthood of representation to load + options: Additional settings dictionary + """ + libpath = self.fname + asset = context["asset"]["name"] + subset = context["subset"]["name"] + + asset_name = plugin.asset_name(asset, subset) + unique_number = plugin.get_unique_number(asset, subset) + group_name = plugin.asset_name(asset, subset, unique_number) + namespace = namespace or f"{asset}_{unique_number}" + + avalon_container = bpy.data.collections.get(AVALON_CONTAINERS) + if not avalon_container: + avalon_container = bpy.data.collections.new(name=AVALON_CONTAINERS) + bpy.context.scene.collection.children.link(avalon_container) + + asset_group = bpy.data.objects.new(group_name, object_data=None) + avalon_container.objects.link(asset_group) + + # Blender needs the Sequence Editor in the current window, to be able + # to load the audio. We take one of the areas in the window, save its + # type, and switch to the Sequence Editor. After loading the audio, + # we switch back to the previous area. + window_manager = bpy.context.window_manager + old_type = window_manager.windows[-1].screen.areas[0].type + window_manager.windows[-1].screen.areas[0].type = "SEQUENCE_EDITOR" + + # We override the context to load the audio in the sequence editor. + oc = bpy.context.copy() + oc["area"] = window_manager.windows[-1].screen.areas[0] + + bpy.ops.sequencer.sound_strip_add(oc, filepath=libpath, frame_start=1) + + window_manager.windows[-1].screen.areas[0].type = old_type + + p = Path(libpath) + audio = p.name + + asset_group[AVALON_PROPERTY] = { + "schema": "openpype:container-2.0", + "id": AVALON_CONTAINER_ID, + "name": name, + "namespace": namespace or '', + "loader": str(self.__class__.__name__), + "representation": str(context["representation"]["_id"]), + "libpath": libpath, + "asset_name": asset_name, + "parent": str(context["representation"]["parent"]), + "family": context["representation"]["context"]["family"], + "objectName": group_name, + "audio": audio + } + + objects = [] + self[:] = objects + return [objects] + + def exec_update(self, container: Dict, representation: Dict): + """Update an audio strip in the sequence editor. + + Arguments: + container (openpype:container-1.0): Container to update, + from `host.ls()`. + representation (openpype:representation-1.0): Representation to + update, from `host.ls()`. + """ + object_name = container["objectName"] + asset_group = bpy.data.objects.get(object_name) + libpath = Path(api.get_representation_path(representation)) + + self.log.info( + "Container: %s\nRepresentation: %s", + pformat(container, indent=2), + pformat(representation, indent=2), + ) + + assert asset_group, ( + f"The asset is not loaded: {container['objectName']}" + ) + assert libpath, ( + "No existing library file found for {container['objectName']}" + ) + assert libpath.is_file(), ( + f"The file doesn't exist: {libpath}" + ) + + metadata = asset_group.get(AVALON_PROPERTY) + group_libpath = metadata["libpath"] + + normalized_group_libpath = ( + str(Path(bpy.path.abspath(group_libpath)).resolve()) + ) + normalized_libpath = ( + str(Path(bpy.path.abspath(str(libpath))).resolve()) + ) + self.log.debug( + "normalized_group_libpath:\n %s\nnormalized_libpath:\n %s", + normalized_group_libpath, + normalized_libpath, + ) + if normalized_group_libpath == normalized_libpath: + self.log.info("Library already loaded, not updating...") + return + + old_audio = container["audio"] + p = Path(libpath) + new_audio = p.name + + # Blender needs the Sequence Editor in the current window, to be able + # to update the audio. We take one of the areas in the window, save its + # type, and switch to the Sequence Editor. After updating the audio, + # we switch back to the previous area. + window_manager = bpy.context.window_manager + old_type = window_manager.windows[-1].screen.areas[0].type + window_manager.windows[-1].screen.areas[0].type = "SEQUENCE_EDITOR" + + # We override the context to load the audio in the sequence editor. + oc = bpy.context.copy() + oc["area"] = window_manager.windows[-1].screen.areas[0] + + # We deselect all sequencer strips, and then select the one we + # need to remove. + bpy.ops.sequencer.select_all(oc, action='DESELECT') + scene = bpy.context.scene + scene.sequence_editor.sequences_all[old_audio].select = True + + bpy.ops.sequencer.delete(oc) + bpy.data.sounds.remove(bpy.data.sounds[old_audio]) + + bpy.ops.sequencer.sound_strip_add( + oc, filepath=str(libpath), frame_start=1) + + window_manager.windows[-1].screen.areas[0].type = old_type + + metadata["libpath"] = str(libpath) + metadata["representation"] = str(representation["_id"]) + metadata["parent"] = str(representation["parent"]) + metadata["audio"] = new_audio + + def exec_remove(self, container: Dict) -> bool: + """Remove an audio strip from the sequence editor and the container. + + Arguments: + container (openpype:container-1.0): Container to remove, + from `host.ls()`. + + Returns: + bool: Whether the container was deleted. + """ + object_name = container["objectName"] + asset_group = bpy.data.objects.get(object_name) + + if not asset_group: + return False + + audio = container["audio"] + + # Blender needs the Sequence Editor in the current window, to be able + # to remove the audio. We take one of the areas in the window, save its + # type, and switch to the Sequence Editor. After removing the audio, + # we switch back to the previous area. + window_manager = bpy.context.window_manager + old_type = window_manager.windows[-1].screen.areas[0].type + window_manager.windows[-1].screen.areas[0].type = "SEQUENCE_EDITOR" + + # We override the context to load the audio in the sequence editor. + oc = bpy.context.copy() + oc["area"] = window_manager.windows[-1].screen.areas[0] + + # We deselect all sequencer strips, and then select the one we + # need to remove. + bpy.ops.sequencer.select_all(oc, action='DESELECT') + bpy.context.scene.sequence_editor.sequences_all[audio].select = True + + bpy.ops.sequencer.delete(oc) + + window_manager.windows[-1].screen.areas[0].type = old_type + + bpy.data.sounds.remove(bpy.data.sounds[audio]) + + bpy.data.objects.remove(asset_group) + + return True diff --git a/openpype/hosts/blender/plugins/load/load_camera.py b/openpype/hosts/blender/plugins/load/load_camera.py deleted file mode 100644 index 30300100e0..0000000000 --- a/openpype/hosts/blender/plugins/load/load_camera.py +++ /dev/null @@ -1,247 +0,0 @@ -"""Load a camera asset in Blender.""" - -import logging -from pathlib import Path -from pprint import pformat -from typing import Dict, List, Optional - -from avalon import api, blender -import bpy -import openpype.hosts.blender.api.plugin - -logger = logging.getLogger("openpype").getChild("blender").getChild("load_camera") - - -class BlendCameraLoader(openpype.hosts.blender.api.plugin.AssetLoader): - """Load a camera from a .blend file. - - Warning: - Loading the same asset more then once is not properly supported at the - moment. - """ - - families = ["camera"] - representations = ["blend"] - - label = "Link Camera" - icon = "code-fork" - color = "orange" - - def _remove(self, objects, lib_container): - for obj in list(objects): - bpy.data.cameras.remove(obj.data) - - bpy.data.collections.remove(bpy.data.collections[lib_container]) - - def _process(self, libpath, lib_container, container_name, actions): - - relative = bpy.context.preferences.filepaths.use_relative_paths - with bpy.data.libraries.load( - libpath, link=True, relative=relative - ) as (_, data_to): - data_to.collections = [lib_container] - - scene = bpy.context.scene - - scene.collection.children.link(bpy.data.collections[lib_container]) - - camera_container = scene.collection.children[lib_container].make_local() - - objects_list = [] - - for obj in camera_container.objects: - local_obj = obj.make_local() - local_obj.data.make_local() - - if not local_obj.get(blender.pipeline.AVALON_PROPERTY): - local_obj[blender.pipeline.AVALON_PROPERTY] = dict() - - avalon_info = local_obj[blender.pipeline.AVALON_PROPERTY] - avalon_info.update({"container_name": container_name}) - - if actions[0] is not None: - if local_obj.animation_data is None: - local_obj.animation_data_create() - local_obj.animation_data.action = actions[0] - - if actions[1] is not None: - if local_obj.data.animation_data is None: - local_obj.data.animation_data_create() - local_obj.data.animation_data.action = actions[1] - - objects_list.append(local_obj) - - camera_container.pop(blender.pipeline.AVALON_PROPERTY) - - bpy.ops.object.select_all(action='DESELECT') - - return objects_list - - def process_asset( - self, context: dict, name: str, namespace: Optional[str] = None, - options: Optional[Dict] = None - ) -> Optional[List]: - """ - Arguments: - name: Use pre-defined name - namespace: Use pre-defined namespace - context: Full parenthood of representation to load - options: Additional settings dictionary - """ - - libpath = self.fname - asset = context["asset"]["name"] - subset = context["subset"]["name"] - lib_container = openpype.hosts.blender.api.plugin.asset_name(asset, subset) - container_name = openpype.hosts.blender.api.plugin.asset_name( - asset, subset, namespace - ) - - container = bpy.data.collections.new(lib_container) - container.name = container_name - blender.pipeline.containerise_existing( - container, - name, - namespace, - context, - self.__class__.__name__, - ) - - container_metadata = container.get( - blender.pipeline.AVALON_PROPERTY) - - container_metadata["libpath"] = libpath - container_metadata["lib_container"] = lib_container - - objects_list = self._process( - libpath, lib_container, container_name, (None, None)) - - # Save the list of objects in the metadata container - container_metadata["objects"] = objects_list - - nodes = list(container.objects) - nodes.append(container) - self[:] = nodes - return nodes - - def update(self, container: Dict, representation: Dict): - """Update the loaded asset. - - This will remove all objects of the current collection, load the new - ones and add them to the collection. - If the objects of the collection are used in another collection they - will not be removed, only unlinked. Normally this should not be the - case though. - - Warning: - No nested collections are supported at the moment! - """ - - collection = bpy.data.collections.get( - container["objectName"] - ) - - libpath = Path(api.get_representation_path(representation)) - extension = libpath.suffix.lower() - - logger.info( - "Container: %s\nRepresentation: %s", - pformat(container, indent=2), - pformat(representation, indent=2), - ) - - assert collection, ( - f"The asset is not loaded: {container['objectName']}" - ) - assert not (collection.children), ( - "Nested collections are not supported." - ) - assert libpath, ( - "No existing library file found for {container['objectName']}" - ) - assert libpath.is_file(), ( - f"The file doesn't exist: {libpath}" - ) - assert extension in openpype.hosts.blender.api.plugin.VALID_EXTENSIONS, ( - f"Unsupported file: {libpath}" - ) - - collection_metadata = collection.get( - blender.pipeline.AVALON_PROPERTY) - collection_libpath = collection_metadata["libpath"] - objects = collection_metadata["objects"] - lib_container = collection_metadata["lib_container"] - - normalized_collection_libpath = ( - str(Path(bpy.path.abspath(collection_libpath)).resolve()) - ) - normalized_libpath = ( - str(Path(bpy.path.abspath(str(libpath))).resolve()) - ) - logger.debug( - "normalized_collection_libpath:\n %s\nnormalized_libpath:\n %s", - normalized_collection_libpath, - normalized_libpath, - ) - if normalized_collection_libpath == normalized_libpath: - logger.info("Library already loaded, not updating...") - return - - camera = objects[0] - - camera_action = None - camera_data_action = None - - if camera.animation_data and camera.animation_data.action: - camera_action = camera.animation_data.action - - if camera.data.animation_data and camera.data.animation_data.action: - camera_data_action = camera.data.animation_data.action - - actions = (camera_action, camera_data_action) - - self._remove(objects, lib_container) - - objects_list = self._process( - str(libpath), lib_container, collection.name, actions) - - # Save the list of objects in the metadata container - collection_metadata["objects"] = objects_list - collection_metadata["libpath"] = str(libpath) - collection_metadata["representation"] = str(representation["_id"]) - - bpy.ops.object.select_all(action='DESELECT') - - def remove(self, container: Dict) -> bool: - """Remove an existing container from a Blender scene. - - Arguments: - container (openpype:container-1.0): Container to remove, - from `host.ls()`. - - Returns: - bool: Whether the container was deleted. - - Warning: - No nested collections are supported at the moment! - """ - - collection = bpy.data.collections.get( - container["objectName"] - ) - if not collection: - return False - assert not (collection.children), ( - "Nested collections are not supported." - ) - - collection_metadata = collection.get( - blender.pipeline.AVALON_PROPERTY) - objects = collection_metadata["objects"] - lib_container = collection_metadata["lib_container"] - - self._remove(objects, lib_container) - - bpy.data.collections.remove(collection) - - return True diff --git a/openpype/hosts/blender/plugins/load/load_camera_blend.py b/openpype/hosts/blender/plugins/load/load_camera_blend.py new file mode 100644 index 0000000000..834eb467d8 --- /dev/null +++ b/openpype/hosts/blender/plugins/load/load_camera_blend.py @@ -0,0 +1,252 @@ +"""Load a camera asset in Blender.""" + +import logging +from pathlib import Path +from pprint import pformat +from typing import Dict, List, Optional + +import bpy + +from avalon import api +from avalon.blender.pipeline import AVALON_CONTAINERS +from avalon.blender.pipeline import AVALON_CONTAINER_ID +from avalon.blender.pipeline import AVALON_PROPERTY +from openpype.hosts.blender.api import plugin + +logger = logging.getLogger("openpype").getChild( + "blender").getChild("load_camera") + + +class BlendCameraLoader(plugin.AssetLoader): + """Load a camera from a .blend file. + + Warning: + Loading the same asset more then once is not properly supported at the + moment. + """ + + families = ["camera"] + representations = ["blend"] + + label = "Link Camera (Blend)" + icon = "code-fork" + color = "orange" + + def _remove(self, asset_group): + objects = list(asset_group.children) + + for obj in objects: + if obj.type == 'CAMERA': + bpy.data.cameras.remove(obj.data) + + def _process(self, libpath, asset_group, group_name): + with bpy.data.libraries.load( + libpath, link=True, relative=False + ) as (data_from, data_to): + data_to.objects = data_from.objects + + parent = bpy.context.scene.collection + + empties = [obj for obj in data_to.objects if obj.type == 'EMPTY'] + + container = None + + for empty in empties: + if empty.get(AVALON_PROPERTY): + container = empty + break + + assert container, "No asset group found" + + # Children must be linked before parents, + # otherwise the hierarchy will break + objects = [] + nodes = list(container.children) + + for obj in nodes: + obj.parent = asset_group + + for obj in nodes: + objects.append(obj) + nodes.extend(list(obj.children)) + + objects.reverse() + + for obj in objects: + parent.objects.link(obj) + + for obj in objects: + local_obj = plugin.prepare_data(obj, group_name) + + if local_obj.type != 'EMPTY': + plugin.prepare_data(local_obj.data, group_name) + + if not local_obj.get(AVALON_PROPERTY): + local_obj[AVALON_PROPERTY] = dict() + + avalon_info = local_obj[AVALON_PROPERTY] + avalon_info.update({"container_name": group_name}) + + objects.reverse() + + bpy.data.orphans_purge(do_local_ids=False) + + plugin.deselect_all() + + return objects + + def process_asset( + self, context: dict, name: str, namespace: Optional[str] = None, + options: Optional[Dict] = None + ) -> Optional[List]: + """ + Arguments: + name: Use pre-defined name + namespace: Use pre-defined namespace + context: Full parenthood of representation to load + options: Additional settings dictionary + """ + libpath = self.fname + asset = context["asset"]["name"] + subset = context["subset"]["name"] + + asset_name = plugin.asset_name(asset, subset) + unique_number = plugin.get_unique_number(asset, subset) + group_name = plugin.asset_name(asset, subset, unique_number) + namespace = namespace or f"{asset}_{unique_number}" + + avalon_container = bpy.data.collections.get(AVALON_CONTAINERS) + if not avalon_container: + avalon_container = bpy.data.collections.new(name=AVALON_CONTAINERS) + bpy.context.scene.collection.children.link(avalon_container) + + asset_group = bpy.data.objects.new(group_name, object_data=None) + asset_group.empty_display_type = 'SINGLE_ARROW' + avalon_container.objects.link(asset_group) + + objects = self._process(libpath, asset_group, group_name) + + bpy.context.scene.collection.objects.link(asset_group) + + asset_group[AVALON_PROPERTY] = { + "schema": "openpype:container-2.0", + "id": AVALON_CONTAINER_ID, + "name": name, + "namespace": namespace or '', + "loader": str(self.__class__.__name__), + "representation": str(context["representation"]["_id"]), + "libpath": libpath, + "asset_name": asset_name, + "parent": str(context["representation"]["parent"]), + "family": context["representation"]["context"]["family"], + "objectName": group_name + } + + self[:] = objects + return objects + + def exec_update(self, container: Dict, representation: Dict): + """Update the loaded asset. + + This will remove all children of the asset group, load the new ones + and add them as children of the group. + """ + object_name = container["objectName"] + asset_group = bpy.data.objects.get(object_name) + libpath = Path(api.get_representation_path(representation)) + extension = libpath.suffix.lower() + + self.log.info( + "Container: %s\nRepresentation: %s", + pformat(container, indent=2), + pformat(representation, indent=2), + ) + + assert asset_group, ( + f"The asset is not loaded: {container['objectName']}" + ) + assert libpath, ( + "No existing library file found for {container['objectName']}" + ) + assert libpath.is_file(), ( + f"The file doesn't exist: {libpath}" + ) + assert extension in plugin.VALID_EXTENSIONS, ( + f"Unsupported file: {libpath}" + ) + + metadata = asset_group.get(AVALON_PROPERTY) + group_libpath = metadata["libpath"] + + normalized_group_libpath = ( + str(Path(bpy.path.abspath(group_libpath)).resolve()) + ) + normalized_libpath = ( + str(Path(bpy.path.abspath(str(libpath))).resolve()) + ) + self.log.debug( + "normalized_group_libpath:\n %s\nnormalized_libpath:\n %s", + normalized_group_libpath, + normalized_libpath, + ) + if normalized_group_libpath == normalized_libpath: + self.log.info("Library already loaded, not updating...") + return + + # Check how many assets use the same library + count = 0 + for obj in bpy.data.collections.get(AVALON_CONTAINERS).objects: + if obj.get(AVALON_PROPERTY).get('libpath') == group_libpath: + count += 1 + + mat = asset_group.matrix_basis.copy() + + self._remove(asset_group) + + # If it is the last object to use that library, remove it + if count == 1: + library = bpy.data.libraries.get(bpy.path.basename(group_libpath)) + if library: + bpy.data.libraries.remove(library) + + self._process(str(libpath), asset_group, object_name) + + asset_group.matrix_basis = mat + + metadata["libpath"] = str(libpath) + metadata["representation"] = str(representation["_id"]) + metadata["parent"] = str(representation["parent"]) + + def exec_remove(self, container: Dict) -> bool: + """Remove an existing container from a Blender scene. + + Arguments: + container (openpype:container-1.0): Container to remove, + from `host.ls()`. + + Returns: + bool: Whether the container was deleted. + """ + object_name = container["objectName"] + asset_group = bpy.data.objects.get(object_name) + libpath = asset_group.get(AVALON_PROPERTY).get('libpath') + + # Check how many assets use the same library + count = 0 + for obj in bpy.data.collections.get(AVALON_CONTAINERS).objects: + if obj.get(AVALON_PROPERTY).get('libpath') == libpath: + count += 1 + + if not asset_group: + return False + + self._remove(asset_group) + + bpy.data.objects.remove(asset_group) + + # If it is the last object to use that library, remove it + if count == 1: + library = bpy.data.libraries.get(bpy.path.basename(libpath)) + bpy.data.libraries.remove(library) + + return True diff --git a/openpype/hosts/blender/plugins/load/load_camera_fbx.py b/openpype/hosts/blender/plugins/load/load_camera_fbx.py new file mode 100644 index 0000000000..5edba7ec0c --- /dev/null +++ b/openpype/hosts/blender/plugins/load/load_camera_fbx.py @@ -0,0 +1,218 @@ +"""Load an asset in Blender from an Alembic file.""" + +from pathlib import Path +from pprint import pformat +from typing import Dict, List, Optional + +import bpy + +from avalon import api +from avalon.blender import lib +from avalon.blender.pipeline import AVALON_CONTAINERS +from avalon.blender.pipeline import AVALON_CONTAINER_ID +from avalon.blender.pipeline import AVALON_PROPERTY +from openpype.hosts.blender.api import plugin + + +class FbxCameraLoader(plugin.AssetLoader): + """Load a camera from FBX. + + Stores the imported asset in an empty named after the asset. + """ + + families = ["camera"] + representations = ["fbx"] + + label = "Load Camera (FBX)" + icon = "code-fork" + color = "orange" + + def _remove(self, asset_group): + objects = list(asset_group.children) + + for obj in objects: + if obj.type == 'CAMERA': + bpy.data.cameras.remove(obj.data) + elif obj.type == 'EMPTY': + objects.extend(obj.children) + bpy.data.objects.remove(obj) + + def _process(self, libpath, asset_group, group_name): + plugin.deselect_all() + + collection = bpy.context.view_layer.active_layer_collection.collection + + bpy.ops.import_scene.fbx(filepath=libpath) + + parent = bpy.context.scene.collection + + objects = lib.get_selection() + + for obj in objects: + obj.parent = asset_group + + for obj in objects: + parent.objects.link(obj) + collection.objects.unlink(obj) + + for obj in objects: + name = obj.name + obj.name = f"{group_name}:{name}" + if obj.type != 'EMPTY': + name_data = obj.data.name + obj.data.name = f"{group_name}:{name_data}" + + if not obj.get(AVALON_PROPERTY): + obj[AVALON_PROPERTY] = dict() + + avalon_info = obj[AVALON_PROPERTY] + avalon_info.update({"container_name": group_name}) + + plugin.deselect_all() + + return objects + + def process_asset( + self, context: dict, name: str, namespace: Optional[str] = None, + options: Optional[Dict] = None + ) -> Optional[List]: + """ + Arguments: + name: Use pre-defined name + namespace: Use pre-defined namespace + context: Full parenthood of representation to load + options: Additional settings dictionary + """ + libpath = self.fname + asset = context["asset"]["name"] + subset = context["subset"]["name"] + + asset_name = plugin.asset_name(asset, subset) + unique_number = plugin.get_unique_number(asset, subset) + group_name = plugin.asset_name(asset, subset, unique_number) + namespace = namespace or f"{asset}_{unique_number}" + + avalon_container = bpy.data.collections.get(AVALON_CONTAINERS) + if not avalon_container: + avalon_container = bpy.data.collections.new(name=AVALON_CONTAINERS) + bpy.context.scene.collection.children.link(avalon_container) + + asset_group = bpy.data.objects.new(group_name, object_data=None) + avalon_container.objects.link(asset_group) + + objects = self._process(libpath, asset_group, group_name) + + objects = [] + nodes = list(asset_group.children) + + for obj in nodes: + objects.append(obj) + nodes.extend(list(obj.children)) + + bpy.context.scene.collection.objects.link(asset_group) + + asset_group[AVALON_PROPERTY] = { + "schema": "openpype:container-2.0", + "id": AVALON_CONTAINER_ID, + "name": name, + "namespace": namespace or '', + "loader": str(self.__class__.__name__), + "representation": str(context["representation"]["_id"]), + "libpath": libpath, + "asset_name": asset_name, + "parent": str(context["representation"]["parent"]), + "family": context["representation"]["context"]["family"], + "objectName": group_name + } + + self[:] = objects + return objects + + def exec_update(self, container: Dict, representation: Dict): + """Update the loaded asset. + + This will remove all objects of the current collection, load the new + ones and add them to the collection. + If the objects of the collection are used in another collection they + will not be removed, only unlinked. Normally this should not be the + case though. + + Warning: + No nested collections are supported at the moment! + """ + object_name = container["objectName"] + asset_group = bpy.data.objects.get(object_name) + libpath = Path(api.get_representation_path(representation)) + extension = libpath.suffix.lower() + + self.log.info( + "Container: %s\nRepresentation: %s", + pformat(container, indent=2), + pformat(representation, indent=2), + ) + + assert asset_group, ( + f"The asset is not loaded: {container['objectName']}" + ) + assert libpath, ( + "No existing library file found for {container['objectName']}" + ) + assert libpath.is_file(), ( + f"The file doesn't exist: {libpath}" + ) + assert extension in plugin.VALID_EXTENSIONS, ( + f"Unsupported file: {libpath}" + ) + + metadata = asset_group.get(AVALON_PROPERTY) + group_libpath = metadata["libpath"] + + normalized_group_libpath = ( + str(Path(bpy.path.abspath(group_libpath)).resolve()) + ) + normalized_libpath = ( + str(Path(bpy.path.abspath(str(libpath))).resolve()) + ) + self.log.debug( + "normalized_group_libpath:\n %s\nnormalized_libpath:\n %s", + normalized_group_libpath, + normalized_libpath, + ) + if normalized_group_libpath == normalized_libpath: + self.log.info("Library already loaded, not updating...") + return + + mat = asset_group.matrix_basis.copy() + + self._remove(asset_group) + self._process(str(libpath), asset_group, object_name) + + asset_group.matrix_basis = mat + + metadata["libpath"] = str(libpath) + metadata["representation"] = str(representation["_id"]) + + def exec_remove(self, container: Dict) -> bool: + """Remove an existing container from a Blender scene. + + Arguments: + container (openpype:container-1.0): Container to remove, + from `host.ls()`. + + Returns: + bool: Whether the container was deleted. + + Warning: + No nested collections are supported at the moment! + """ + object_name = container["objectName"] + asset_group = bpy.data.objects.get(object_name) + + if not asset_group: + return False + + self._remove(asset_group) + + bpy.data.objects.remove(asset_group) + + return True diff --git a/openpype/hosts/blender/plugins/load/load_fbx.py b/openpype/hosts/blender/plugins/load/load_fbx.py index b80dc69adc..5f69aecb1a 100644 --- a/openpype/hosts/blender/plugins/load/load_fbx.py +++ b/openpype/hosts/blender/plugins/load/load_fbx.py @@ -46,7 +46,7 @@ class FbxModelLoader(plugin.AssetLoader): bpy.data.objects.remove(obj) def _process(self, libpath, asset_group, group_name, action): - bpy.ops.object.select_all(action='DESELECT') + plugin.deselect_all() collection = bpy.context.view_layer.active_layer_collection.collection @@ -112,7 +112,7 @@ class FbxModelLoader(plugin.AssetLoader): avalon_info = obj[AVALON_PROPERTY] avalon_info.update({"container_name": group_name}) - bpy.ops.object.select_all(action='DESELECT') + plugin.deselect_all() return objects diff --git a/openpype/hosts/blender/plugins/load/load_layout_blend.py b/openpype/hosts/blender/plugins/load/load_layout_blend.py index 85cb4dfbd3..4c1f751a77 100644 --- a/openpype/hosts/blender/plugins/load/load_layout_blend.py +++ b/openpype/hosts/blender/plugins/load/load_layout_blend.py @@ -150,7 +150,7 @@ class BlendLayoutLoader(plugin.AssetLoader): bpy.data.orphans_purge(do_local_ids=False) - bpy.ops.object.select_all(action='DESELECT') + plugin.deselect_all() return objects diff --git a/openpype/hosts/blender/plugins/load/load_layout_json.py b/openpype/hosts/blender/plugins/load/load_layout_json.py index 1a4dbbb5cb..442cf05d85 100644 --- a/openpype/hosts/blender/plugins/load/load_layout_json.py +++ b/openpype/hosts/blender/plugins/load/load_layout_json.py @@ -12,6 +12,7 @@ from avalon.blender.pipeline import AVALON_CONTAINERS from avalon.blender.pipeline import AVALON_CONTAINER_ID from avalon.blender.pipeline import AVALON_PROPERTY from avalon.blender.pipeline import AVALON_INSTANCES +from openpype import lib from openpype.hosts.blender.api import plugin @@ -59,7 +60,7 @@ class JsonLayoutLoader(plugin.AssetLoader): return None def _process(self, libpath, asset, asset_group, actions): - bpy.ops.object.select_all(action='DESELECT') + plugin.deselect_all() with open(libpath, "r") as fp: data = json.load(fp) @@ -103,6 +104,21 @@ class JsonLayoutLoader(plugin.AssetLoader): options=options ) + # Create the camera asset and the camera instance + creator_plugin = lib.get_creator_by_name("CreateCamera") + if not creator_plugin: + raise ValueError("Creator plugin \"CreateCamera\" was " + "not found.") + + api.create( + creator_plugin, + name="camera", + # name=f"{unique_number}_{subset}_animation", + asset=asset, + options={"useSelection": False} + # data={"dependencies": str(context["representation"]["_id"])} + ) + def process_asset(self, context: dict, name: str, diff --git a/openpype/hosts/blender/plugins/load/load_model.py b/openpype/hosts/blender/plugins/load/load_model.py index af5591c299..c33c656dec 100644 --- a/openpype/hosts/blender/plugins/load/load_model.py +++ b/openpype/hosts/blender/plugins/load/load_model.py @@ -93,7 +93,7 @@ class BlendModelLoader(plugin.AssetLoader): bpy.data.orphans_purge(do_local_ids=False) - bpy.ops.object.select_all(action='DESELECT') + plugin.deselect_all() return objects @@ -126,7 +126,7 @@ class BlendModelLoader(plugin.AssetLoader): asset_group.empty_display_type = 'SINGLE_ARROW' avalon_container.objects.link(asset_group) - bpy.ops.object.select_all(action='DESELECT') + plugin.deselect_all() if options is not None: parent = options.get('parent') @@ -158,7 +158,7 @@ class BlendModelLoader(plugin.AssetLoader): bpy.ops.object.parent_set(keep_transform=True) - bpy.ops.object.select_all(action='DESELECT') + plugin.deselect_all() objects = self._process(libpath, asset_group, group_name) diff --git a/openpype/hosts/blender/plugins/load/load_rig.py b/openpype/hosts/blender/plugins/load/load_rig.py index 6062c293df..e80da8af45 100644 --- a/openpype/hosts/blender/plugins/load/load_rig.py +++ b/openpype/hosts/blender/plugins/load/load_rig.py @@ -156,7 +156,7 @@ class BlendRigLoader(plugin.AssetLoader): while bpy.data.orphans_purge(do_local_ids=False): pass - bpy.ops.object.select_all(action='DESELECT') + plugin.deselect_all() return objects @@ -191,7 +191,7 @@ class BlendRigLoader(plugin.AssetLoader): action = None - bpy.ops.object.select_all(action='DESELECT') + plugin.deselect_all() create_animation = False @@ -227,7 +227,7 @@ class BlendRigLoader(plugin.AssetLoader): bpy.ops.object.parent_set(keep_transform=True) - bpy.ops.object.select_all(action='DESELECT') + plugin.deselect_all() objects = self._process(libpath, asset_group, group_name, action) @@ -250,7 +250,7 @@ class BlendRigLoader(plugin.AssetLoader): data={"dependencies": str(context["representation"]["_id"])} ) - bpy.ops.object.select_all(action='DESELECT') + plugin.deselect_all() bpy.context.scene.collection.objects.link(asset_group) diff --git a/openpype/hosts/blender/plugins/publish/extract_abc.py b/openpype/hosts/blender/plugins/publish/extract_abc.py index 4696da3db4..b75bec4e28 100644 --- a/openpype/hosts/blender/plugins/publish/extract_abc.py +++ b/openpype/hosts/blender/plugins/publish/extract_abc.py @@ -28,7 +28,7 @@ class ExtractABC(api.Extractor): # Perform extraction self.log.info("Performing extraction..") - bpy.ops.object.select_all(action='DESELECT') + plugin.deselect_all() selected = [] asset_group = None @@ -50,7 +50,7 @@ class ExtractABC(api.Extractor): flatten=False ) - bpy.ops.object.select_all(action='DESELECT') + plugin.deselect_all() if "representations" not in instance.data: instance.data["representations"] = [] diff --git a/openpype/hosts/blender/plugins/publish/extract_blend.py b/openpype/hosts/blender/plugins/publish/extract_blend.py index e880b1bde0..565e2fe425 100644 --- a/openpype/hosts/blender/plugins/publish/extract_blend.py +++ b/openpype/hosts/blender/plugins/publish/extract_blend.py @@ -37,7 +37,8 @@ class ExtractBlend(openpype.api.Extractor): if tree.type == 'SHADER': for node in tree.nodes: if node.bl_idname == 'ShaderNodeTexImage': - node.image.pack() + if node.image: + node.image.pack() bpy.data.libraries.write(filepath, data_blocks) diff --git a/openpype/hosts/blender/plugins/publish/extract_camera.py b/openpype/hosts/blender/plugins/publish/extract_camera.py new file mode 100644 index 0000000000..a0e78178c8 --- /dev/null +++ b/openpype/hosts/blender/plugins/publish/extract_camera.py @@ -0,0 +1,73 @@ +import os + +from openpype import api +from openpype.hosts.blender.api import plugin + +import bpy + + +class ExtractCamera(api.Extractor): + """Extract as the camera as FBX.""" + + label = "Extract Camera" + hosts = ["blender"] + families = ["camera"] + optional = True + + def process(self, instance): + # Define extract output file path + stagingdir = self.staging_dir(instance) + filename = f"{instance.name}.fbx" + filepath = os.path.join(stagingdir, filename) + + # Perform extraction + self.log.info("Performing extraction..") + + plugin.deselect_all() + + selected = [] + + camera = None + + for obj in instance: + if obj.type == "CAMERA": + obj.select_set(True) + selected.append(obj) + camera = obj + break + + assert camera, "No camera found" + + context = plugin.create_blender_context( + active=camera, selected=selected) + + scale_length = bpy.context.scene.unit_settings.scale_length + bpy.context.scene.unit_settings.scale_length = 0.01 + + # We export the fbx + bpy.ops.export_scene.fbx( + context, + filepath=filepath, + use_active_collection=False, + use_selection=True, + object_types={'CAMERA'}, + bake_anim_simplify_factor=0.0 + ) + + bpy.context.scene.unit_settings.scale_length = scale_length + + plugin.deselect_all() + + if "representations" not in instance.data: + instance.data["representations"] = [] + + representation = { + 'name': 'fbx', + 'ext': 'fbx', + 'files': filename, + "stagingDir": stagingdir, + } + instance.data["representations"].append(representation) + + self.log.info("Extracted instance '%s' to: %s", + instance.name, representation) diff --git a/openpype/hosts/blender/plugins/publish/extract_fbx.py b/openpype/hosts/blender/plugins/publish/extract_fbx.py index b91f2a75ef..f9ffdea1d1 100644 --- a/openpype/hosts/blender/plugins/publish/extract_fbx.py +++ b/openpype/hosts/blender/plugins/publish/extract_fbx.py @@ -24,7 +24,7 @@ class ExtractFBX(api.Extractor): # Perform extraction self.log.info("Performing extraction..") - bpy.ops.object.select_all(action='DESELECT') + plugin.deselect_all() selected = [] asset_group = None @@ -60,7 +60,7 @@ class ExtractFBX(api.Extractor): add_leaf_bones=False ) - bpy.ops.object.select_all(action='DESELECT') + plugin.deselect_all() for mat in new_materials: bpy.data.materials.remove(mat) diff --git a/openpype/hosts/blender/plugins/publish/validate_camera_zero_keyframe.py b/openpype/hosts/blender/plugins/publish/validate_camera_zero_keyframe.py new file mode 100644 index 0000000000..39b9b67511 --- /dev/null +++ b/openpype/hosts/blender/plugins/publish/validate_camera_zero_keyframe.py @@ -0,0 +1,48 @@ +from typing import List + +import mathutils + +import pyblish.api +import openpype.hosts.blender.api.action + + +class ValidateCameraZeroKeyframe(pyblish.api.InstancePlugin): + """Camera must have a keyframe at frame 0. + + Unreal shifts the first keyframe to frame 0. Forcing the camera to have + a keyframe at frame 0 will ensure that the animation will be the same + in Unreal and Blender. + """ + + order = openpype.api.ValidateContentsOrder + hosts = ["blender"] + families = ["camera"] + category = "geometry" + version = (0, 1, 0) + label = "Zero Keyframe" + actions = [openpype.hosts.blender.api.action.SelectInvalidAction] + + _identity = mathutils.Matrix() + + @classmethod + def get_invalid(cls, instance) -> List: + invalid = [] + for obj in [obj for obj in instance]: + if obj.type == "CAMERA": + if obj.animation_data and obj.animation_data.action: + action = obj.animation_data.action + frames_set = set() + for fcu in action.fcurves: + for kp in fcu.keyframe_points: + frames_set.add(kp.co[0]) + frames = list(frames_set) + frames.sort() + if frames[0] != 0.0: + invalid.append(obj) + return invalid + + def process(self, instance): + invalid = self.get_invalid(instance) + if invalid: + raise RuntimeError( + f"Object found in instance is not in Object Mode: {invalid}") diff --git a/openpype/hosts/flame/__init__.py b/openpype/hosts/flame/__init__.py new file mode 100644 index 0000000000..48e8dc86c9 --- /dev/null +++ b/openpype/hosts/flame/__init__.py @@ -0,0 +1,105 @@ +from .api.utils import ( + setup +) + +from .api.pipeline import ( + install, + uninstall, + ls, + containerise, + update_container, + maintained_selection, + remove_instance, + list_instances, + imprint +) + +from .api.lib import ( + FlameAppFramework, + maintain_current_timeline, + get_project_manager, + get_current_project, + get_current_timeline, + create_bin, +) + +from .api.menu import ( + FlameMenuProjectConnect, + FlameMenuTimeline +) + +from .api.workio import ( + open_file, + save_file, + current_file, + has_unsaved_changes, + file_extensions, + work_root +) + +import os + +HOST_DIR = os.path.dirname( + os.path.abspath(__file__) +) +API_DIR = os.path.join(HOST_DIR, "api") +PLUGINS_DIR = os.path.join(HOST_DIR, "plugins") +PUBLISH_PATH = os.path.join(PLUGINS_DIR, "publish") +LOAD_PATH = os.path.join(PLUGINS_DIR, "load") +CREATE_PATH = os.path.join(PLUGINS_DIR, "create") +INVENTORY_PATH = os.path.join(PLUGINS_DIR, "inventory") + +app_framework = None +apps = [] + + +__all__ = [ + "HOST_DIR", + "API_DIR", + "PLUGINS_DIR", + "PUBLISH_PATH", + "LOAD_PATH", + "CREATE_PATH", + "INVENTORY_PATH", + "INVENTORY_PATH", + + "app_framework", + "apps", + + # pipeline + "install", + "uninstall", + "ls", + "containerise", + "update_container", + "reload_pipeline", + "maintained_selection", + "remove_instance", + "list_instances", + "imprint", + + # utils + "setup", + + # lib + "FlameAppFramework", + "maintain_current_timeline", + "get_project_manager", + "get_current_project", + "get_current_timeline", + "create_bin", + + # menu + "FlameMenuProjectConnect", + "FlameMenuTimeline", + + # plugin + + # workio + "open_file", + "save_file", + "current_file", + "has_unsaved_changes", + "file_extensions", + "work_root" +] diff --git a/openpype/hosts/flame/api/__init__.py b/openpype/hosts/flame/api/__init__.py new file mode 100644 index 0000000000..50a6b3f098 --- /dev/null +++ b/openpype/hosts/flame/api/__init__.py @@ -0,0 +1,3 @@ +""" +OpenPype Autodesk Flame api +""" diff --git a/openpype/hosts/flame/api/lib.py b/openpype/hosts/flame/api/lib.py new file mode 100644 index 0000000000..48331dcbc2 --- /dev/null +++ b/openpype/hosts/flame/api/lib.py @@ -0,0 +1,276 @@ +import sys +import os +import pickle +import contextlib +from pprint import pformat + +from openpype.api import Logger + +log = Logger().get_logger(__name__) + + +@contextlib.contextmanager +def io_preferences_file(klass, filepath, write=False): + try: + flag = "w" if write else "r" + yield open(filepath, flag) + + except IOError as _error: + klass.log.info("Unable to work with preferences `{}`: {}".format( + filepath, _error)) + + +class FlameAppFramework(object): + # flameAppFramework class takes care of preferences + + class prefs_dict(dict): + + def __init__(self, master, name, **kwargs): + self.name = name + self.master = master + if not self.master.get(self.name): + self.master[self.name] = {} + self.master[self.name].__init__() + + def __getitem__(self, k): + return self.master[self.name].__getitem__(k) + + def __setitem__(self, k, v): + return self.master[self.name].__setitem__(k, v) + + def __delitem__(self, k): + return self.master[self.name].__delitem__(k) + + def get(self, k, default=None): + return self.master[self.name].get(k, default) + + def setdefault(self, k, default=None): + return self.master[self.name].setdefault(k, default) + + def pop(self, k, v=object()): + if v is object(): + return self.master[self.name].pop(k) + return self.master[self.name].pop(k, v) + + def update(self, mapping=(), **kwargs): + self.master[self.name].update(mapping, **kwargs) + + def __contains__(self, k): + return self.master[self.name].__contains__(k) + + def copy(self): # don"t delegate w/ super - dict.copy() -> dict :( + return type(self)(self) + + def keys(self): + return self.master[self.name].keys() + + @classmethod + def fromkeys(cls, keys, v=None): + return cls.master[cls.name].fromkeys(keys, v) + + def __repr__(self): + return "{0}({1})".format( + type(self).__name__, self.master[self.name].__repr__()) + + def master_keys(self): + return self.master.keys() + + def __init__(self): + self.name = self.__class__.__name__ + self.bundle_name = "OpenPypeFlame" + # self.prefs scope is limited to flame project and user + self.prefs = {} + self.prefs_user = {} + self.prefs_global = {} + self.log = log + + try: + import flame + self.flame = flame + self.flame_project_name = self.flame.project.current_project.name + self.flame_user_name = flame.users.current_user.name + except Exception: + self.flame = None + self.flame_project_name = None + self.flame_user_name = None + + import socket + self.hostname = socket.gethostname() + + if sys.platform == "darwin": + self.prefs_folder = os.path.join( + os.path.expanduser("~"), + "Library", + "Caches", + "OpenPype", + self.bundle_name + ) + elif sys.platform.startswith("linux"): + self.prefs_folder = os.path.join( + os.path.expanduser("~"), + ".OpenPype", + self.bundle_name) + + self.prefs_folder = os.path.join( + self.prefs_folder, + self.hostname, + ) + + self.log.info("[{}] waking up".format(self.__class__.__name__)) + self.load_prefs() + + # menu auto-refresh defaults + + if not self.prefs_global.get("menu_auto_refresh"): + self.prefs_global["menu_auto_refresh"] = { + "media_panel": True, + "batch": True, + "main_menu": True, + "timeline_menu": True + } + + self.apps = [] + + def get_pref_file_paths(self): + + prefix = self.prefs_folder + os.path.sep + self.bundle_name + prefs_file_path = "_".join([ + prefix, self.flame_user_name, + self.flame_project_name]) + ".prefs" + prefs_user_file_path = "_".join([ + prefix, self.flame_user_name]) + ".prefs" + prefs_global_file_path = prefix + ".prefs" + + return (prefs_file_path, prefs_user_file_path, prefs_global_file_path) + + def load_prefs(self): + + (proj_pref_path, user_pref_path, + glob_pref_path) = self.get_pref_file_paths() + + with io_preferences_file(self, proj_pref_path) as prefs_file: + self.prefs = pickle.load(prefs_file) + self.log.info( + "Project - preferences contents:\n{}".format( + pformat(self.prefs) + )) + + with io_preferences_file(self, user_pref_path) as prefs_file: + self.prefs_user = pickle.load(prefs_file) + self.log.info( + "User - preferences contents:\n{}".format( + pformat(self.prefs_user) + )) + + with io_preferences_file(self, glob_pref_path) as prefs_file: + self.prefs_global = pickle.load(prefs_file) + self.log.info( + "Global - preferences contents:\n{}".format( + pformat(self.prefs_global) + )) + + return True + + def save_prefs(self): + # make sure the preference folder is available + if not os.path.isdir(self.prefs_folder): + try: + os.makedirs(self.prefs_folder) + except Exception: + self.log.info("Unable to create folder {}".format( + self.prefs_folder)) + return False + + # get all pref file paths + (proj_pref_path, user_pref_path, + glob_pref_path) = self.get_pref_file_paths() + + with io_preferences_file(self, proj_pref_path, True) as prefs_file: + pickle.dump(self.prefs, prefs_file) + self.log.info( + "Project - preferences contents:\n{}".format( + pformat(self.prefs) + )) + + with io_preferences_file(self, user_pref_path, True) as prefs_file: + pickle.dump(self.prefs_user, prefs_file) + self.log.info( + "User - preferences contents:\n{}".format( + pformat(self.prefs_user) + )) + + with io_preferences_file(self, glob_pref_path, True) as prefs_file: + pickle.dump(self.prefs_global, prefs_file) + self.log.info( + "Global - preferences contents:\n{}".format( + pformat(self.prefs_global) + )) + + return True + + +@contextlib.contextmanager +def maintain_current_timeline(to_timeline, from_timeline=None): + """Maintain current timeline selection during context + + Attributes: + from_timeline (resolve.Timeline)[optional]: + Example: + >>> print(from_timeline.GetName()) + timeline1 + >>> print(to_timeline.GetName()) + timeline2 + + >>> with maintain_current_timeline(to_timeline): + ... print(get_current_timeline().GetName()) + timeline2 + + >>> print(get_current_timeline().GetName()) + timeline1 + """ + # todo: this is still Resolve's implementation + project = get_current_project() + working_timeline = from_timeline or project.GetCurrentTimeline() + + # swith to the input timeline + project.SetCurrentTimeline(to_timeline) + + try: + # do a work + yield + finally: + # put the original working timeline to context + project.SetCurrentTimeline(working_timeline) + + +def get_project_manager(): + # TODO: get_project_manager + return + + +def get_media_storage(): + # TODO: get_media_storage + return + + +def get_current_project(): + # TODO: get_current_project + return + + +def get_current_timeline(new=False): + # TODO: get_current_timeline + return + + +def create_bin(name, root=None): + # TODO: create_bin + return + + +def rescan_hooks(): + import flame + try: + flame.execute_shortcut('Rescan Python Hooks') + except Exception: + pass diff --git a/openpype/hosts/flame/api/menu.py b/openpype/hosts/flame/api/menu.py new file mode 100644 index 0000000000..b4f1728acf --- /dev/null +++ b/openpype/hosts/flame/api/menu.py @@ -0,0 +1,208 @@ +import os +from Qt import QtWidgets +from copy import deepcopy + +from openpype.tools.utils.host_tools import HostToolsHelper + + +menu_group_name = 'OpenPype' + +default_flame_export_presets = { + 'Publish': { + 'PresetVisibility': 2, + 'PresetType': 0, + 'PresetFile': 'OpenEXR/OpenEXR (16-bit fp PIZ).xml' + }, + 'Preview': { + 'PresetVisibility': 3, + 'PresetType': 2, + 'PresetFile': 'Generate Preview.xml' + }, + 'Thumbnail': { + 'PresetVisibility': 3, + 'PresetType': 0, + 'PresetFile': 'Generate Thumbnail.xml' + } +} + + +class _FlameMenuApp(object): + def __init__(self, framework): + self.name = self.__class__.__name__ + self.framework = framework + self.log = framework.log + self.menu_group_name = menu_group_name + self.dynamic_menu_data = {} + + # flame module is only avaliable when a + # flame project is loaded and initialized + self.flame = None + try: + import flame + self.flame = flame + except ImportError: + self.flame = None + + self.flame_project_name = flame.project.current_project.name + self.prefs = self.framework.prefs_dict(self.framework.prefs, self.name) + self.prefs_user = self.framework.prefs_dict( + self.framework.prefs_user, self.name) + self.prefs_global = self.framework.prefs_dict( + self.framework.prefs_global, self.name) + + self.mbox = QtWidgets.QMessageBox() + + self.menu = { + "actions": [{ + 'name': os.getenv("AVALON_PROJECT", "project"), + 'isEnabled': False + }], + "name": self.menu_group_name + } + self.tools_helper = HostToolsHelper() + + def __getattr__(self, name): + def method(*args, **kwargs): + print('calling %s' % name) + return method + + def rescan(self, *args, **kwargs): + if not self.flame: + try: + import flame + self.flame = flame + except ImportError: + self.flame = None + + if self.flame: + self.flame.execute_shortcut('Rescan Python Hooks') + self.log.info('Rescan Python Hooks') + + +class FlameMenuProjectConnect(_FlameMenuApp): + + # flameMenuProjectconnect app takes care of the preferences dialog as well + + def __init__(self, framework): + _FlameMenuApp.__init__(self, framework) + + def __getattr__(self, name): + def method(*args, **kwargs): + project = self.dynamic_menu_data.get(name) + if project: + self.link_project(project) + return method + + def build_menu(self): + if not self.flame: + return [] + + flame_project_name = self.flame_project_name + self.log.info("______ {} ______".format(flame_project_name)) + + menu = deepcopy(self.menu) + + menu['actions'].append({ + "name": "Workfiles ...", + "execute": lambda x: self.tools_helper.show_workfiles() + }) + menu['actions'].append({ + "name": "Create ...", + "execute": lambda x: self.tools_helper.show_creator() + }) + menu['actions'].append({ + "name": "Publish ...", + "execute": lambda x: self.tools_helper.show_publish() + }) + menu['actions'].append({ + "name": "Load ...", + "execute": lambda x: self.tools_helper.show_loader() + }) + menu['actions'].append({ + "name": "Manage ...", + "execute": lambda x: self.tools_helper.show_scene_inventory() + }) + menu['actions'].append({ + "name": "Library ...", + "execute": lambda x: self.tools_helper.show_library_loader() + }) + return menu + + def get_projects(self, *args, **kwargs): + pass + + def refresh(self, *args, **kwargs): + self.rescan() + + def rescan(self, *args, **kwargs): + if not self.flame: + try: + import flame + self.flame = flame + except ImportError: + self.flame = None + + if self.flame: + self.flame.execute_shortcut('Rescan Python Hooks') + self.log.info('Rescan Python Hooks') + + +class FlameMenuTimeline(_FlameMenuApp): + + # flameMenuProjectconnect app takes care of the preferences dialog as well + + def __init__(self, framework): + _FlameMenuApp.__init__(self, framework) + + def __getattr__(self, name): + def method(*args, **kwargs): + project = self.dynamic_menu_data.get(name) + if project: + self.link_project(project) + return method + + def build_menu(self): + if not self.flame: + return [] + + flame_project_name = self.flame_project_name + self.log.info("______ {} ______".format(flame_project_name)) + + menu = deepcopy(self.menu) + + menu['actions'].append({ + "name": "Create ...", + "execute": lambda x: self.tools_helper.show_creator() + }) + menu['actions'].append({ + "name": "Publish ...", + "execute": lambda x: self.tools_helper.show_publish() + }) + menu['actions'].append({ + "name": "Load ...", + "execute": lambda x: self.tools_helper.show_loader() + }) + menu['actions'].append({ + "name": "Manage ...", + "execute": lambda x: self.tools_helper.show_scene_inventory() + }) + + return menu + + def get_projects(self, *args, **kwargs): + pass + + def refresh(self, *args, **kwargs): + self.rescan() + + def rescan(self, *args, **kwargs): + if not self.flame: + try: + import flame + self.flame = flame + except ImportError: + self.flame = None + + if self.flame: + self.flame.execute_shortcut('Rescan Python Hooks') + self.log.info('Rescan Python Hooks') diff --git a/openpype/hosts/flame/api/pipeline.py b/openpype/hosts/flame/api/pipeline.py new file mode 100644 index 0000000000..26dfe7c032 --- /dev/null +++ b/openpype/hosts/flame/api/pipeline.py @@ -0,0 +1,155 @@ +""" +Basic avalon integration +""" +import contextlib +from avalon import api as avalon +from pyblish import api as pyblish +from openpype.api import Logger + +AVALON_CONTAINERS = "AVALON_CONTAINERS" + +log = Logger().get_logger(__name__) + + +def install(): + from .. import ( + PUBLISH_PATH, + LOAD_PATH, + CREATE_PATH, + INVENTORY_PATH + ) + # TODO: install + + # Disable all families except for the ones we explicitly want to see + family_states = [ + "imagesequence", + "render2d", + "plate", + "render", + "mov", + "clip" + ] + avalon.data["familiesStateDefault"] = False + avalon.data["familiesStateToggled"] = family_states + + log.info("openpype.hosts.flame installed") + + pyblish.register_host("flame") + pyblish.register_plugin_path(PUBLISH_PATH) + log.info("Registering Flame plug-ins..") + + avalon.register_plugin_path(avalon.Loader, LOAD_PATH) + avalon.register_plugin_path(avalon.Creator, CREATE_PATH) + avalon.register_plugin_path(avalon.InventoryAction, INVENTORY_PATH) + + # register callback for switching publishable + pyblish.register_callback("instanceToggled", on_pyblish_instance_toggled) + + +def uninstall(): + from .. import ( + PUBLISH_PATH, + LOAD_PATH, + CREATE_PATH, + INVENTORY_PATH + ) + + # TODO: uninstall + pyblish.deregister_host("flame") + pyblish.deregister_plugin_path(PUBLISH_PATH) + log.info("Deregistering DaVinci Resovle plug-ins..") + + avalon.deregister_plugin_path(avalon.Loader, LOAD_PATH) + avalon.deregister_plugin_path(avalon.Creator, CREATE_PATH) + avalon.deregister_plugin_path(avalon.InventoryAction, INVENTORY_PATH) + + # register callback for switching publishable + pyblish.deregister_callback("instanceToggled", on_pyblish_instance_toggled) + + +def containerise(tl_segment, + name, + namespace, + context, + loader=None, + data=None): + # TODO: containerise + pass + + +def ls(): + """List available containers. + """ + # TODO: ls + pass + + +def parse_container(tl_segment, validate=True): + """Return container data from timeline_item's openpype tag. + """ + # TODO: parse_container + pass + + +def update_container(tl_segment, data=None): + """Update container data to input timeline_item's openpype tag. + """ + # TODO: update_container + pass + + +@contextlib.contextmanager +def maintained_selection(): + """Maintain selection during context + + Example: + >>> with maintained_selection(): + ... node['selected'].setValue(True) + >>> print(node['selected'].value()) + False + """ + # TODO: maintained_selection + remove undo steps + + try: + # do the operation + yield + finally: + pass + + +def reset_selection(): + """Deselect all selected nodes + """ + pass + + +def on_pyblish_instance_toggled(instance, old_value, new_value): + """Toggle node passthrough states on instance toggles.""" + + log.info("instance toggle: {}, old_value: {}, new_value:{} ".format( + instance, old_value, new_value)) + + # from openpype.hosts.resolve import ( + # set_publish_attribute + # ) + + # # Whether instances should be passthrough based on new value + # timeline_item = instance.data["item"] + # set_publish_attribute(timeline_item, new_value) + + +def remove_instance(instance): + """Remove instance marker from track item.""" + # TODO: remove_instance + pass + + +def list_instances(): + """List all created instances from current workfile.""" + # TODO: list_instances + pass + + +def imprint(item, data=None): + # TODO: imprint + pass diff --git a/openpype/hosts/flame/api/plugin.py b/openpype/hosts/flame/api/plugin.py new file mode 100644 index 0000000000..2a28a20a75 --- /dev/null +++ b/openpype/hosts/flame/api/plugin.py @@ -0,0 +1,3 @@ +# Creator plugin functions +# Publishing plugin functions +# Loader plugin functions diff --git a/openpype/hosts/flame/api/utils.py b/openpype/hosts/flame/api/utils.py new file mode 100644 index 0000000000..a750046362 --- /dev/null +++ b/openpype/hosts/flame/api/utils.py @@ -0,0 +1,108 @@ +""" +Flame utils for syncing scripts +""" + +import os +import shutil +from openpype.api import Logger +log = Logger().get_logger(__name__) + + +def _sync_utility_scripts(env=None): + """ Synchronizing basic utlility scripts for flame. + + To be able to run start OpenPype within Flame we have to copy + all utility_scripts and additional FLAME_SCRIPT_DIR into + `/opt/Autodesk/shared/python`. This will be always synchronizing those + folders. + """ + from .. import HOST_DIR + + env = env or os.environ + + # initiate inputs + scripts = {} + fsd_env = env.get("FLAME_SCRIPT_DIRS", "") + flame_shared_dir = "/opt/Autodesk/shared/python" + + fsd_paths = [os.path.join( + HOST_DIR, + "utility_scripts" + )] + + # collect script dirs + log.info("FLAME_SCRIPT_DIRS: `{fsd_env}`".format(**locals())) + log.info("fsd_paths: `{fsd_paths}`".format(**locals())) + + # add application environment setting for FLAME_SCRIPT_DIR + # to script path search + for _dirpath in fsd_env.split(os.pathsep): + if not os.path.isdir(_dirpath): + log.warning("Path is not a valid dir: `{_dirpath}`".format( + **locals())) + continue + fsd_paths.append(_dirpath) + + # collect scripts from dirs + for path in fsd_paths: + scripts.update({path: os.listdir(path)}) + + remove_black_list = [] + for _k, s_list in scripts.items(): + remove_black_list += s_list + + log.info("remove_black_list: `{remove_black_list}`".format(**locals())) + log.info("Additional Flame script paths: `{fsd_paths}`".format(**locals())) + log.info("Flame Scripts: `{scripts}`".format(**locals())) + + # make sure no script file is in folder + if next(iter(os.listdir(flame_shared_dir)), None): + for _itm in os.listdir(flame_shared_dir): + skip = False + + # skip all scripts and folders which are not maintained + if _itm not in remove_black_list: + skip = True + + # do not skyp if pyc in extension + if not os.path.isdir(_itm) and "pyc" in os.path.splitext(_itm)[-1]: + skip = False + + # continue if skip in true + if skip: + continue + + path = os.path.join(flame_shared_dir, _itm) + log.info("Removing `{path}`...".format(**locals())) + if os.path.isdir(path): + shutil.rmtree(path, onerror=None) + else: + os.remove(path) + + # copy scripts into Resolve's utility scripts dir + for dirpath, scriptlist in scripts.items(): + # directory and scripts list + for _script in scriptlist: + # script in script list + src = os.path.join(dirpath, _script) + dst = os.path.join(flame_shared_dir, _script) + log.info("Copying `{src}` to `{dst}`...".format(**locals())) + if os.path.isdir(src): + shutil.copytree( + src, dst, symlinks=False, + ignore=None, ignore_dangling_symlinks=False + ) + else: + shutil.copy2(src, dst) + + +def setup(env=None): + """ Wrapper installer started from + `flame/hooks/pre_flame_setup.py` + """ + env = env or os.environ + + # synchronize resolve utility scripts + _sync_utility_scripts(env) + + log.info("Flame OpenPype wrapper has been installed") diff --git a/openpype/hosts/flame/api/workio.py b/openpype/hosts/flame/api/workio.py new file mode 100644 index 0000000000..d2e2408798 --- /dev/null +++ b/openpype/hosts/flame/api/workio.py @@ -0,0 +1,37 @@ +"""Host API required Work Files tool""" + +import os +from openpype.api import Logger +# from .. import ( +# get_project_manager, +# get_current_project +# ) + + +log = Logger().get_logger(__name__) + +exported_projet_ext = ".otoc" + + +def file_extensions(): + return [exported_projet_ext] + + +def has_unsaved_changes(): + pass + + +def save_file(filepath): + pass + + +def open_file(filepath): + pass + + +def current_file(): + pass + + +def work_root(session): + return os.path.normpath(session["AVALON_WORKDIR"]).replace("\\", "/") diff --git a/openpype/hosts/flame/hooks/pre_flame_setup.py b/openpype/hosts/flame/hooks/pre_flame_setup.py new file mode 100644 index 0000000000..368a70f395 --- /dev/null +++ b/openpype/hosts/flame/hooks/pre_flame_setup.py @@ -0,0 +1,132 @@ +import os +import json +import tempfile +import contextlib +from openpype.lib import ( + PreLaunchHook, get_openpype_username) +from openpype.hosts import flame as opflame +import openpype +from pprint import pformat + + +class FlamePrelaunch(PreLaunchHook): + """ Flame prelaunch hook + + Will make sure flame_script_dirs are coppied to user's folder defined + in environment var FLAME_SCRIPT_DIR. + """ + app_groups = ["flame"] + + # todo: replace version number with avalon launch app version + flame_python_exe = "/opt/Autodesk/python/2021/bin/python2.7" + + wtc_script_path = os.path.join( + opflame.HOST_DIR, "scripts", "wiretap_com.py") + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + self.signature = "( {} )".format(self.__class__.__name__) + + def execute(self): + """Hook entry method.""" + project_doc = self.data["project_doc"] + user_name = get_openpype_username() + + self.log.debug("Collected user \"{}\"".format(user_name)) + self.log.info(pformat(project_doc)) + _db_p_data = project_doc["data"] + width = _db_p_data["resolutionWidth"] + height = _db_p_data["resolutionHeight"] + fps = int(_db_p_data["fps"]) + + project_data = { + "Name": project_doc["name"], + "Nickname": _db_p_data["code"], + "Description": "Created by OpenPype", + "SetupDir": project_doc["name"], + "FrameWidth": int(width), + "FrameHeight": int(height), + "AspectRatio": float((width / height) * _db_p_data["pixelAspect"]), + "FrameRate": "{} fps".format(fps), + "FrameDepth": "16-bit fp", + "FieldDominance": "PROGRESSIVE" + } + + data_to_script = { + # from settings + "host_name": "localhost", + "volume_name": "stonefs", + "group_name": "staff", + "color_policy": "ACES 1.1", + + # from project + "project_name": project_doc["name"], + "user_name": user_name, + "project_data": project_data + } + app_arguments = self._get_launch_arguments(data_to_script) + + self.log.info(pformat(dict(self.launch_context.env))) + + opflame.setup(self.launch_context.env) + + self.launch_context.launch_args.extend(app_arguments) + + def _get_launch_arguments(self, script_data): + # Dump data to string + dumped_script_data = json.dumps(script_data) + + with make_temp_file(dumped_script_data) as tmp_json_path: + # Prepare subprocess arguments + args = [ + self.flame_python_exe, + self.wtc_script_path, + tmp_json_path + ] + self.log.info("Executing: {}".format(" ".join(args))) + + process_kwargs = { + "logger": self.log, + "env": {} + } + + openpype.api.run_subprocess(args, **process_kwargs) + + # process returned json file to pass launch args + return_json_data = open(tmp_json_path).read() + returned_data = json.loads(return_json_data) + app_args = returned_data.get("app_args") + self.log.info("____ app_args: `{}`".format(app_args)) + + if not app_args: + RuntimeError("App arguments were not solved") + + return app_args + + +@contextlib.contextmanager +def make_temp_file(data): + try: + # Store dumped json to temporary file + temporary_json_file = tempfile.NamedTemporaryFile( + mode="w", suffix=".json", delete=False + ) + temporary_json_file.write(data) + temporary_json_file.close() + temporary_json_filepath = temporary_json_file.name.replace( + "\\", "/" + ) + + yield temporary_json_filepath + + except IOError as _error: + raise IOError( + "Not able to create temp json file: {}".format( + _error + ) + ) + + finally: + # Remove the temporary json + os.remove(temporary_json_filepath) diff --git a/openpype/hosts/flame/scripts/wiretap_com.py b/openpype/hosts/flame/scripts/wiretap_com.py new file mode 100644 index 0000000000..d8dc1884cf --- /dev/null +++ b/openpype/hosts/flame/scripts/wiretap_com.py @@ -0,0 +1,490 @@ +#!/usr/bin/env python2.7 +# -*- coding: utf-8 -*- + +from __future__ import absolute_import +import os +import sys +import subprocess +import json +import xml.dom.minidom as minidom +from copy import deepcopy +import datetime + +try: + from libwiretapPythonClientAPI import ( + WireTapClientInit) +except ImportError: + flame_python_path = "/opt/Autodesk/flame_2021/python" + flame_exe_path = ( + "/opt/Autodesk/flame_2021/bin/flame.app" + "/Contents/MacOS/startApp") + + sys.path.append(flame_python_path) + + from libwiretapPythonClientAPI import ( + WireTapClientInit, + WireTapClientUninit, + WireTapNodeHandle, + WireTapServerHandle, + WireTapInt, + WireTapStr + ) + + +class WireTapCom(object): + """ + Comunicator class wrapper for talking to WireTap db. + + This way we are able to set new project with settings and + correct colorspace policy. Also we are able to create new user + or get actuall user with similar name (users are usually cloning + their profiles and adding date stamp into suffix). + """ + + def __init__(self, host_name=None, volume_name=None, group_name=None): + """Initialisation of WireTap communication class + + Args: + host_name (str, optional): Name of host server. Defaults to None. + volume_name (str, optional): Name of volume. Defaults to None. + group_name (str, optional): Name of user group. Defaults to None. + """ + # set main attributes of server + # if there are none set the default installation + self.host_name = host_name or "localhost" + self.volume_name = volume_name or "stonefs" + self.group_name = group_name or "staff" + + # initialize WireTap client + WireTapClientInit() + + # add the server to shared variable + self._server = WireTapServerHandle("{}:IFFFS".format(self.host_name)) + print("WireTap connected at '{}'...".format( + self.host_name)) + + def close(self): + self._server = None + WireTapClientUninit() + print("WireTap closed...") + + def get_launch_args( + self, project_name, project_data, user_name, *args, **kwargs): + """Forming launch arguments for OpenPype launcher. + + Args: + project_name (str): name of project + project_data (dict): Flame compatible project data + user_name (str): name of user + + Returns: + list: arguments + """ + + workspace_name = kwargs.get("workspace_name") + color_policy = kwargs.get("color_policy") + + self._project_prep(project_name) + self._set_project_settings(project_name, project_data) + self._set_project_colorspace(project_name, color_policy) + user_name = self._user_prep(user_name) + + if workspace_name is None: + # default workspace + print("Using a default workspace") + return [ + "--start-project={}".format(project_name), + "--start-user={}".format(user_name), + "--create-workspace" + ] + + else: + print( + "Using a custom workspace '{}'".format(workspace_name)) + + self._workspace_prep(project_name, workspace_name) + return [ + "--start-project={}".format(project_name), + "--start-user={}".format(user_name), + "--create-workspace", + "--start-workspace={}".format(workspace_name) + ] + + def _workspace_prep(self, project_name, workspace_name): + """Preparing a workspace + + In case it doesn not exists it will create one + + Args: + project_name (str): project name + workspace_name (str): workspace name + + Raises: + AttributeError: unable to create workspace + """ + workspace_exists = self._child_is_in_parent_path( + "/projects/{}".format(project_name), workspace_name, "WORKSPACE" + ) + if not workspace_exists: + project = WireTapNodeHandle( + self._server, "/projects/{}".format(project_name)) + + workspace_node = WireTapNodeHandle() + created_workspace = project.createNode( + workspace_name, "WORKSPACE", workspace_node) + + if not created_workspace: + raise AttributeError( + "Cannot create workspace `{}` in " + "project `{}`: `{}`".format( + workspace_name, project_name, project.lastError()) + ) + + print( + "Workspace `{}` is successfully created".format(workspace_name)) + + def _project_prep(self, project_name): + """Preparing a project + + In case it doesn not exists it will create one + + Args: + project_name (str): project name + + Raises: + AttributeError: unable to create project + """ + # test if projeft exists + project_exists = self._child_is_in_parent_path( + "/projects", project_name, "PROJECT") + + if not project_exists: + volumes = self._get_all_volumes() + + if len(volumes) == 0: + raise AttributeError( + "Not able to create new project. No Volumes existing" + ) + + # check if volumes exists + if self.volume_name not in volumes: + raise AttributeError( + ("Volume '{}' does not exist '{}'").format( + self.volume_name, volumes) + ) + + # form cmd arguments + project_create_cmd = [ + os.path.join( + "/opt/Autodesk/", + "wiretap", + "tools", + "2021", + "wiretap_create_node", + ), + '-n', + os.path.join("/volumes", self.volume_name), + '-d', + project_name, + '-g', + ] + + project_create_cmd.append(self.group_name) + + print(project_create_cmd) + + exit_code = subprocess.call( + project_create_cmd, + cwd=os.path.expanduser('~')) + + if exit_code != 0: + RuntimeError("Cannot create project in flame db") + + print( + "A new project '{}' is created.".format(project_name)) + + def _get_all_volumes(self): + """Request all available volumens from WireTap + + Returns: + list: all available volumes in server + + Rises: + AttributeError: unable to get any volumes childs from server + """ + root = WireTapNodeHandle(self._server, "/volumes") + children_num = WireTapInt(0) + + get_children_num = root.getNumChildren(children_num) + if not get_children_num: + raise AttributeError( + "Cannot get number of volumes: {}".format(root.lastError()) + ) + + volumes = [] + + # go trough all children and get volume names + child_obj = WireTapNodeHandle() + for child_idx in range(children_num): + + # get a child + if not root.getChild(child_idx, child_obj): + raise AttributeError( + "Unable to get child: {}".format(root.lastError())) + + node_name = WireTapStr() + get_children_name = child_obj.getDisplayName(node_name) + + if not get_children_name: + raise AttributeError( + "Unable to get child name: {}".format( + child_obj.lastError()) + ) + + volumes.append(node_name.c_str()) + + return volumes + + def _user_prep(self, user_name): + """Ensuring user does exists in user's stack + + Args: + user_name (str): name of a user + + Raises: + AttributeError: unable to create user + """ + + # get all used usernames in db + used_names = self._get_usernames() + print(">> used_names: {}".format(used_names)) + + # filter only those which are sharing input user name + filtered_users = [user for user in used_names if user_name in user] + + if filtered_users: + # todo: need to find lastly created following regex patern for + # date used in name + return filtered_users.pop() + + # create new user name with date in suffix + now = datetime.datetime.now() # current date and time + date = now.strftime("%Y%m%d") + new_user_name = "{}_{}".format(user_name, date) + print(new_user_name) + + if not self._child_is_in_parent_path("/users", new_user_name, "USER"): + # Create the new user + users = WireTapNodeHandle(self._server, "/users") + + user_node = WireTapNodeHandle() + created_user = users.createNode(new_user_name, "USER", user_node) + if not created_user: + raise AttributeError( + "User {} cannot be created: {}".format( + new_user_name, users.lastError()) + ) + + print("User `{}` is created".format(new_user_name)) + return new_user_name + + def _get_usernames(self): + """Requesting all available users from WireTap + + Returns: + list: all available user names + + Raises: + AttributeError: there are no users in server + """ + root = WireTapNodeHandle(self._server, "/users") + children_num = WireTapInt(0) + + get_children_num = root.getNumChildren(children_num) + if not get_children_num: + raise AttributeError( + "Cannot get number of volumes: {}".format(root.lastError()) + ) + + usernames = [] + + # go trough all children and get volume names + child_obj = WireTapNodeHandle() + for child_idx in range(children_num): + + # get a child + if not root.getChild(child_idx, child_obj): + raise AttributeError( + "Unable to get child: {}".format(root.lastError())) + + node_name = WireTapStr() + get_children_name = child_obj.getDisplayName(node_name) + + if not get_children_name: + raise AttributeError( + "Unable to get child name: {}".format( + child_obj.lastError()) + ) + + usernames.append(node_name.c_str()) + + return usernames + + def _child_is_in_parent_path(self, parent_path, child_name, child_type): + """Checking if a given child is in parent path. + + Args: + parent_path (str): db path to parent + child_name (str): name of child + child_type (str): type of child + + Raises: + AttributeError: Not able to get number of children + AttributeError: Not able to get children form parent + AttributeError: Not able to get children name + AttributeError: Not able to get children type + + Returns: + bool: True if child is in parent path + """ + parent = WireTapNodeHandle(self._server, parent_path) + + # iterate number of children + children_num = WireTapInt(0) + requested = parent.getNumChildren(children_num) + if not requested: + raise AttributeError(( + "Error: Cannot request number of " + "childrens from the node {}. Make sure your " + "wiretap service is running: {}").format( + parent_path, parent.lastError()) + ) + + # iterate children + child_obj = WireTapNodeHandle() + for child_idx in range(children_num): + if not parent.getChild(child_idx, child_obj): + raise AttributeError( + "Cannot get child: {}".format( + parent.lastError())) + + node_name = WireTapStr() + node_type = WireTapStr() + + if not child_obj.getDisplayName(node_name): + raise AttributeError( + "Unable to get child name: %s" % child_obj.lastError() + ) + if not child_obj.getNodeTypeStr(node_type): + raise AttributeError( + "Unable to obtain child type: %s" % child_obj.lastError() + ) + + if (node_name.c_str() == child_name) and ( + node_type.c_str() == child_type): + return True + + return False + + def _set_project_settings(self, project_name, project_data): + """Setting project attributes. + + Args: + project_name (str): name of project + project_data (dict): data with project attributes + (flame compatible) + + Raises: + AttributeError: Not able to set project attributes + """ + # generated xml from project_data dict + _xml = "" + for key, value in project_data.items(): + _xml += "<{}>{}".format(key, value, key) + _xml += "" + + pretty_xml = minidom.parseString(_xml).toprettyxml() + print("__ xml: {}".format(pretty_xml)) + + # set project data to wiretap + project_node = WireTapNodeHandle( + self._server, "/projects/{}".format(project_name)) + + if not project_node.setMetaData("XML", _xml): + raise AttributeError( + "Not able to set project attributes {}. Error: {}".format( + project_name, project_node.lastError()) + ) + + print("Project settings successfully set.") + + def _set_project_colorspace(self, project_name, color_policy): + """Set project's colorspace policy. + + Args: + project_name (str): name of project + color_policy (str): name of policy + + Raises: + RuntimeError: Not able to set colorspace policy + """ + color_policy = color_policy or "Legacy" + project_colorspace_cmd = [ + os.path.join( + "/opt/Autodesk/", + "wiretap", + "tools", + "2021", + "wiretap_duplicate_node", + ), + "-s", + "/syncolor/policies/Autodesk/{}".format(color_policy), + "-n", + "/projects/{}/syncolor".format(project_name) + ] + + print(project_colorspace_cmd) + + exit_code = subprocess.call( + project_colorspace_cmd, + cwd=os.path.expanduser('~')) + + if exit_code != 0: + RuntimeError("Cannot set colorspace {} on project {}".format( + color_policy, project_name + )) + + +if __name__ == "__main__": + # get json exchange data + json_path = sys.argv[-1] + json_data = open(json_path).read() + in_data = json.loads(json_data) + out_data = deepcopy(in_data) + + # get main server attributes + host_name = in_data.pop("host_name") + volume_name = in_data.pop("volume_name") + group_name = in_data.pop("group_name") + + # initialize class + wiretap_handler = WireTapCom(host_name, volume_name, group_name) + + try: + app_args = wiretap_handler.get_launch_args( + project_name=in_data.pop("project_name"), + project_data=in_data.pop("project_data"), + user_name=in_data.pop("user_name"), + **in_data + ) + finally: + wiretap_handler.close() + + # set returned args back to out data + out_data.update({ + "app_args": app_args + }) + + # write it out back to the exchange json file + with open(json_path, "w") as file_stream: + json.dump(out_data, file_stream, indent=4) diff --git a/openpype/hosts/flame/utility_scripts/openpype_in_flame.py b/openpype/hosts/flame/utility_scripts/openpype_in_flame.py new file mode 100644 index 0000000000..c5fa881f3c --- /dev/null +++ b/openpype/hosts/flame/utility_scripts/openpype_in_flame.py @@ -0,0 +1,191 @@ +from __future__ import print_function +import sys +from Qt import QtWidgets +from pprint import pformat +import atexit +import openpype +import avalon +import openpype.hosts.flame as opflame + +flh = sys.modules[__name__] +flh._project = None + + +def openpype_install(): + """Registering OpenPype in context + """ + openpype.install() + avalon.api.install(opflame) + print("Avalon registred hosts: {}".format( + avalon.api.registered_host())) + + +# Exception handler +def exeption_handler(exctype, value, _traceback): + """Exception handler for improving UX + + Args: + exctype (str): type of exception + value (str): exception value + tb (str): traceback to show + """ + import traceback + msg = "OpenPype: Python exception {} in {}".format(value, exctype) + mbox = QtWidgets.QMessageBox() + mbox.setText(msg) + mbox.setDetailedText( + pformat(traceback.format_exception(exctype, value, _traceback))) + mbox.setStyleSheet('QLabel{min-width: 800px;}') + mbox.exec_() + sys.__excepthook__(exctype, value, _traceback) + + +# add exception handler into sys module +sys.excepthook = exeption_handler + + +# register clean up logic to be called at Flame exit +def cleanup(): + """Cleaning up Flame framework context + """ + if opflame.apps: + print('`{}` cleaning up apps:\n {}\n'.format( + __file__, pformat(opflame.apps))) + while len(opflame.apps): + app = opflame.apps.pop() + print('`{}` removing : {}'.format(__file__, app.name)) + del app + opflame.apps = [] + + if opflame.app_framework: + print('PYTHON\t: %s cleaning up' % opflame.app_framework.bundle_name) + opflame.app_framework.save_prefs() + opflame.app_framework = None + + +atexit.register(cleanup) + + +def load_apps(): + """Load available apps into Flame framework + """ + opflame.apps.append(opflame.FlameMenuProjectConnect(opflame.app_framework)) + opflame.apps.append(opflame.FlameMenuTimeline(opflame.app_framework)) + opflame.app_framework.log.info("Apps are loaded") + + +def project_changed_dict(info): + """Hook for project change action + + Args: + info (str): info text + """ + cleanup() + + +def app_initialized(parent=None): + """Inicialization of Framework + + Args: + parent (obj, optional): Parent object. Defaults to None. + """ + opflame.app_framework = opflame.FlameAppFramework() + + print("{} initializing".format( + opflame.app_framework.bundle_name)) + + load_apps() + + +""" +Initialisation of the hook is starting from here + +First it needs to test if it can import the flame modul. +This will happen only in case a project has been loaded. +Then `app_initialized` will load main Framework which will load +all menu objects as apps. +""" + +try: + import flame # noqa + app_initialized(parent=None) +except ImportError: + print("!!!! not able to import flame module !!!!") + + +def rescan_hooks(): + import flame # noqa + flame.execute_shortcut('Rescan Python Hooks') + + +def _build_app_menu(app_name): + """Flame menu object generator + + Args: + app_name (str): name of menu object app + + Returns: + list: menu object + """ + menu = [] + + # first find the relative appname + app = None + for _app in opflame.apps: + if _app.__class__.__name__ == app_name: + app = _app + + if app: + menu.append(app.build_menu()) + + if opflame.app_framework: + menu_auto_refresh = opflame.app_framework.prefs_global.get( + 'menu_auto_refresh', {}) + if menu_auto_refresh.get('timeline_menu', True): + try: + import flame # noqa + flame.schedule_idle_event(rescan_hooks) + except ImportError: + print("!-!!! not able to import flame module !!!!") + + return menu + + +""" Flame hooks are starting here +""" + + +def project_saved(project_name, save_time, is_auto_save): + """Hook to activate when project is saved + + Args: + project_name (str): name of project + save_time (str): time when it was saved + is_auto_save (bool): autosave is on or off + """ + if opflame.app_framework: + opflame.app_framework.save_prefs() + + +def get_main_menu_custom_ui_actions(): + """Hook to create submenu in start menu + + Returns: + list: menu object + """ + # install openpype and the host + openpype_install() + + return _build_app_menu("FlameMenuProjectConnect") + + +def get_timeline_custom_ui_actions(): + """Hook to create submenu in timeline + + Returns: + list: menu object + """ + # install openpype and the host + openpype_install() + + return _build_app_menu("FlameMenuTimeline") diff --git a/openpype/hosts/houdini/api/plugin.py b/openpype/hosts/houdini/api/plugin.py index efdaa60084..63d9bba470 100644 --- a/openpype/hosts/houdini/api/plugin.py +++ b/openpype/hosts/houdini/api/plugin.py @@ -1,6 +1,7 @@ # -*- coding: utf-8 -*- """Houdini specific Avalon/Pyblish plugin definitions.""" import sys +from avalon.api import CreatorError from avalon import houdini import six @@ -8,7 +9,7 @@ import hou from openpype.api import PypeCreatorMixin -class OpenPypeCreatorError(Exception): +class OpenPypeCreatorError(CreatorError): pass diff --git a/openpype/hosts/houdini/api/usd.py b/openpype/hosts/houdini/api/usd.py index 850ffb60e5..6f808779ea 100644 --- a/openpype/hosts/houdini/api/usd.py +++ b/openpype/hosts/houdini/api/usd.py @@ -4,8 +4,8 @@ import contextlib import logging from Qt import QtCore, QtGui -from avalon.tools.widgets import AssetWidget -from avalon import style +from openpype.tools.utils.widgets import AssetWidget +from avalon import style, io from pxr import Sdf @@ -31,7 +31,7 @@ def pick_asset(node): # Construct the AssetWidget as a frameless popup so it automatically # closes when clicked outside of it. global tool - tool = AssetWidget(silo_creatable=False) + tool = AssetWidget(io) tool.setContentsMargins(5, 5, 5, 5) tool.setWindowTitle("Pick Asset") tool.setStyleSheet(style.load_stylesheet()) @@ -41,8 +41,6 @@ def pick_asset(node): # Select the current asset if there is any name = parm.eval() if name: - from avalon import io - db_asset = io.find_one({"name": name, "type": "asset"}) if db_asset: silo = db_asset.get("silo") diff --git a/openpype/hosts/houdini/plugins/create/create_hda.py b/openpype/hosts/houdini/plugins/create/create_hda.py new file mode 100644 index 0000000000..2af1e4a257 --- /dev/null +++ b/openpype/hosts/houdini/plugins/create/create_hda.py @@ -0,0 +1,96 @@ +# -*- coding: utf-8 -*- +from openpype.hosts.houdini.api import plugin +from avalon.houdini import lib +from avalon import io +import hou + + +class CreateHDA(plugin.Creator): + """Publish Houdini Digital Asset file.""" + + name = "hda" + label = "Houdini Digital Asset (Hda)" + family = "hda" + icon = "gears" + maintain_selection = False + + def __init__(self, *args, **kwargs): + super(CreateHDA, self).__init__(*args, **kwargs) + self.data.pop("active", None) + + def _check_existing(self, subset_name): + # type: (str) -> bool + """Check if existing subset name versions already exists.""" + # Get all subsets of the current asset + asset_id = io.find_one({"name": self.data["asset"], "type": "asset"}, + projection={"_id": True})['_id'] + subset_docs = io.find( + { + "type": "subset", + "parent": asset_id + }, {"name": 1} + ) + existing_subset_names = set(subset_docs.distinct("name")) + existing_subset_names_low = { + _name.lower() for _name in existing_subset_names + } + return subset_name.lower() in existing_subset_names_low + + def _process(self, instance): + subset_name = self.data["subset"] + # get selected nodes + out = hou.node("/obj") + self.nodes = hou.selectedNodes() + + if (self.options or {}).get("useSelection") and self.nodes: + # if we have `use selection` enabled and we have some + # selected nodes ... + to_hda = self.nodes[0] + if len(self.nodes) > 1: + # if there is more then one node, create subnet first + subnet = out.createNode( + "subnet", node_name="{}_subnet".format(self.name)) + to_hda = subnet + else: + # in case of no selection, just create subnet node + subnet = out.createNode( + "subnet", node_name="{}_subnet".format(self.name)) + subnet.moveToGoodPosition() + to_hda = subnet + + if not to_hda.type().definition(): + # if node type has not its definition, it is not user + # created hda. We test if hda can be created from the node. + if not to_hda.canCreateDigitalAsset(): + raise Exception( + "cannot create hda from node {}".format(to_hda)) + + hda_node = to_hda.createDigitalAsset( + name=subset_name, + hda_file_name="$HIP/{}.hda".format(subset_name) + ) + hou.moveNodesTo(self.nodes, hda_node) + hda_node.layoutChildren() + else: + if self._check_existing(subset_name): + raise plugin.OpenPypeCreatorError( + ("subset {} is already published with different HDA" + "definition.").format(subset_name)) + hda_node = to_hda + + hda_node.setName(subset_name) + + # delete node created by Avalon in /out + # this needs to be addressed in future Houdini workflow refactor. + + hou.node("/out/{}".format(subset_name)).destroy() + + try: + lib.imprint(hda_node, self.data) + except hou.OperationFailed: + raise plugin.OpenPypeCreatorError( + ("Cannot set metadata on asset. Might be that it already is " + "OpenPype asset.") + ) + + return hda_node diff --git a/openpype/hosts/houdini/plugins/load/load_hda.py b/openpype/hosts/houdini/plugins/load/load_hda.py new file mode 100644 index 0000000000..6610d5e513 --- /dev/null +++ b/openpype/hosts/houdini/plugins/load/load_hda.py @@ -0,0 +1,62 @@ +# -*- coding: utf-8 -*- +from avalon import api + +from avalon.houdini import pipeline + + +class HdaLoader(api.Loader): + """Load Houdini Digital Asset file.""" + + families = ["hda"] + label = "Load Hda" + representations = ["hda"] + order = -10 + icon = "code-fork" + color = "orange" + + def load(self, context, name=None, namespace=None, data=None): + import os + import hou + + # Format file name, Houdini only wants forward slashes + file_path = os.path.normpath(self.fname) + file_path = file_path.replace("\\", "/") + + # Get the root node + obj = hou.node("/obj") + + # Create a unique name + counter = 1 + namespace = namespace or context["asset"]["name"] + formatted = "{}_{}".format(namespace, name) if namespace else name + node_name = "{0}_{1:03d}".format(formatted, counter) + + hou.hda.installFile(file_path) + hda_node = obj.createNode(name, node_name) + + self[:] = [hda_node] + + return pipeline.containerise( + node_name, + namespace, + [hda_node], + context, + self.__class__.__name__, + suffix="", + ) + + def update(self, container, representation): + import hou + + hda_node = container["node"] + file_path = api.get_representation_path(representation) + file_path = file_path.replace("\\", "/") + hou.hda.installFile(file_path) + defs = hda_node.type().allInstalledDefinitions() + def_paths = [d.libraryFilePath() for d in defs] + new = def_paths.index(file_path) + defs[new].setIsPreferred(True) + + def remove(self, container): + node = container["node"] + node.destroy() diff --git a/openpype/hosts/houdini/plugins/publish/collect_active_state.py b/openpype/hosts/houdini/plugins/publish/collect_active_state.py index 1193f0cd19..862d5720e1 100644 --- a/openpype/hosts/houdini/plugins/publish/collect_active_state.py +++ b/openpype/hosts/houdini/plugins/publish/collect_active_state.py @@ -23,8 +23,10 @@ class CollectInstanceActiveState(pyblish.api.InstancePlugin): return # Check bypass state and reverse + active = True node = instance[0] - active = not node.isBypassed() + if hasattr(node, "isBypassed"): + active = not node.isBypassed() # Set instance active state instance.data.update( diff --git a/openpype/hosts/houdini/plugins/publish/collect_instances.py b/openpype/hosts/houdini/plugins/publish/collect_instances.py index 1b36526783..ac081ac297 100644 --- a/openpype/hosts/houdini/plugins/publish/collect_instances.py +++ b/openpype/hosts/houdini/plugins/publish/collect_instances.py @@ -31,6 +31,7 @@ class CollectInstances(pyblish.api.ContextPlugin): def process(self, context): nodes = hou.node("/out").children() + nodes += hou.node("/obj").children() # Include instances in USD stage only when it exists so it # remains backwards compatible with version before houdini 18 @@ -49,9 +50,12 @@ class CollectInstances(pyblish.api.ContextPlugin): has_family = node.evalParm("family") assert has_family, "'%s' is missing 'family'" % node.name() + self.log.info("processing {}".format(node)) + data = lib.read(node) # Check bypass state and reverse - data.update({"active": not node.isBypassed()}) + if hasattr(node, "isBypassed"): + data.update({"active": not node.isBypassed()}) # temporarily translation of `active` to `publish` till issue has # been resolved, https://github.com/pyblish/pyblish-base/issues/307 diff --git a/openpype/hosts/houdini/plugins/publish/extract_hda.py b/openpype/hosts/houdini/plugins/publish/extract_hda.py new file mode 100644 index 0000000000..301dd4e297 --- /dev/null +++ b/openpype/hosts/houdini/plugins/publish/extract_hda.py @@ -0,0 +1,43 @@ +# -*- coding: utf-8 -*- +import os + +from pprint import pformat + +import pyblish.api +import openpype.api + + +class ExtractHDA(openpype.api.Extractor): + + order = pyblish.api.ExtractorOrder + label = "Extract HDA" + hosts = ["houdini"] + families = ["hda"] + + def process(self, instance): + self.log.info(pformat(instance.data)) + hda_node = instance[0] + hda_def = hda_node.type().definition() + hda_options = hda_def.options() + hda_options.setSaveInitialParmsAndContents(True) + + next_version = instance.data["anatomyData"]["version"] + self.log.info("setting version: {}".format(next_version)) + hda_def.setVersion(str(next_version)) + hda_def.setOptions(hda_options) + hda_def.save(hda_def.libraryFilePath(), hda_node, hda_options) + + if "representations" not in instance.data: + instance.data["representations"] = [] + + file = os.path.basename(hda_def.libraryFilePath()) + staging_dir = os.path.dirname(hda_def.libraryFilePath()) + self.log.info("Using HDA from {}".format(hda_def.libraryFilePath())) + + representation = { + 'name': 'hda', + 'ext': 'hda', + 'files': file, + "stagingDir": staging_dir, + } + instance.data["representations"].append(representation) diff --git a/openpype/hosts/houdini/plugins/publish/validate_bypass.py b/openpype/hosts/houdini/plugins/publish/validate_bypass.py index 79c67c3008..fc4e18f701 100644 --- a/openpype/hosts/houdini/plugins/publish/validate_bypass.py +++ b/openpype/hosts/houdini/plugins/publish/validate_bypass.py @@ -35,5 +35,5 @@ class ValidateBypassed(pyblish.api.InstancePlugin): def get_invalid(cls, instance): rop = instance[0] - if rop.isBypassed(): + if hasattr(rop, "isBypassed") and rop.isBypassed(): return [rop] diff --git a/openpype/hosts/maya/api/__init__.py b/openpype/hosts/maya/api/__init__.py index d1c13b04d5..e330904abf 100644 --- a/openpype/hosts/maya/api/__init__.py +++ b/openpype/hosts/maya/api/__init__.py @@ -13,6 +13,7 @@ from pyblish import api as pyblish from openpype.lib import any_outdated import openpype.hosts.maya from openpype.hosts.maya.lib import copy_workspace_mel +from openpype.lib.path_tools import HostDirmap from . import menu, lib log = logging.getLogger("openpype.hosts.maya") @@ -30,7 +31,8 @@ def install(): project_settings = get_project_settings(os.getenv("AVALON_PROJECT")) # process path mapping - process_dirmap(project_settings) + dirmap_processor = MayaDirmap("maya", project_settings) + dirmap_processor.process_dirmap() pyblish.register_plugin_path(PUBLISH_PATH) avalon.register_plugin_path(avalon.Loader, LOAD_PATH) @@ -60,110 +62,6 @@ def install(): avalon.data["familiesStateToggled"] = ["imagesequence"] -def process_dirmap(project_settings): - # type: (dict) -> None - """Go through all paths in Settings and set them using `dirmap`. - - If artists has Site Sync enabled, take dirmap mapping directly from - Local Settings when artist is syncing workfile locally. - - Args: - project_settings (dict): Settings for current project. - - """ - local_mapping = _get_local_sync_dirmap(project_settings) - if not project_settings["maya"].get("maya-dirmap") and not local_mapping: - return - - mapping = local_mapping or \ - project_settings["maya"]["maya-dirmap"]["paths"] \ - or {} - mapping_enabled = project_settings["maya"]["maya-dirmap"]["enabled"] \ - or bool(local_mapping) - - if not mapping or not mapping_enabled: - return - if mapping.get("source-path") and mapping_enabled is True: - log.info("Processing directory mapping ...") - cmds.dirmap(en=True) - for k, sp in enumerate(mapping["source-path"]): - try: - print("{} -> {}".format(sp, mapping["destination-path"][k])) - cmds.dirmap(m=(sp, mapping["destination-path"][k])) - cmds.dirmap(m=(mapping["destination-path"][k], sp)) - except IndexError: - # missing corresponding destination path - log.error(("invalid dirmap mapping, missing corresponding" - " destination directory.")) - break - except RuntimeError: - log.error("invalid path {} -> {}, mapping not registered".format( - sp, mapping["destination-path"][k] - )) - continue - - -def _get_local_sync_dirmap(project_settings): - """ - Returns dirmap if synch to local project is enabled. - - Only valid mapping is from roots of remote site to local site set in - Local Settings. - - Args: - project_settings (dict) - Returns: - dict : { "source-path": [XXX], "destination-path": [YYYY]} - """ - import json - mapping = {} - - if not project_settings["global"]["sync_server"]["enabled"]: - log.debug("Site Sync not enabled") - return mapping - - from openpype.settings.lib import get_site_local_overrides - from openpype.modules import ModulesManager - - manager = ModulesManager() - sync_module = manager.modules_by_name["sync_server"] - - project_name = os.getenv("AVALON_PROJECT") - sync_settings = sync_module.get_sync_project_setting( - os.getenv("AVALON_PROJECT"), exclude_locals=False, cached=False) - log.debug(json.dumps(sync_settings, indent=4)) - - active_site = sync_module.get_local_normalized_site( - sync_module.get_active_site(project_name)) - remote_site = sync_module.get_local_normalized_site( - sync_module.get_remote_site(project_name)) - log.debug("active {} - remote {}".format(active_site, remote_site)) - - if active_site == "local" \ - and project_name in sync_module.get_enabled_projects()\ - and active_site != remote_site: - overrides = get_site_local_overrides(os.getenv("AVALON_PROJECT"), - active_site) - for root_name, value in overrides.items(): - if os.path.isdir(value): - try: - mapping["destination-path"] = [value] - mapping["source-path"] = [sync_settings["sites"]\ - [remote_site]\ - ["root"]\ - [root_name]] - except IndexError: - # missing corresponding destination path - log.debug("overrides".format(overrides)) - log.error( - ("invalid dirmap mapping, missing corresponding" - " destination directory.")) - break - - log.debug("local sync mapping:: {}".format(mapping)) - return mapping - - def uninstall(): pyblish.deregister_plugin_path(PUBLISH_PATH) avalon.deregister_plugin_path(avalon.Loader, LOAD_PATH) @@ -275,8 +173,7 @@ def on_open(_): # Show outdated pop-up def _on_show_inventory(): - import avalon.tools.sceneinventory as tool - tool.show(parent=parent) + host_tools.show_scene_inventory(parent=parent) dialog = popup.Popup(parent=parent) dialog.setWindowTitle("Maya scene has outdated content") @@ -327,3 +224,12 @@ def before_workfile_save(workfile_path): workdir = os.path.dirname(workfile_path) copy_workspace_mel(workdir) + + +class MayaDirmap(HostDirmap): + def on_enable_dirmap(self): + cmds.dirmap(en=True) + + def dirmap_routine(self, source_path, destination_path): + cmds.dirmap(m=(source_path, destination_path)) + cmds.dirmap(m=(destination_path, source_path)) diff --git a/openpype/hosts/maya/api/lib.py b/openpype/hosts/maya/api/lib.py index b24235447f..4074aa7fa8 100644 --- a/openpype/hosts/maya/api/lib.py +++ b/openpype/hosts/maya/api/lib.py @@ -2,6 +2,7 @@ import re import os +import platform import uuid import math @@ -22,6 +23,7 @@ import avalon.maya.lib import avalon.maya.interactive from openpype import lib +from openpype.api import get_anatomy_settings log = logging.getLogger(__name__) @@ -437,7 +439,8 @@ def empty_sets(sets, force=False): cmds.connectAttr(src, dest) # Restore original members - for origin_set, members in original.iteritems(): + _iteritems = getattr(original, "iteritems", original.items) + for origin_set, members in _iteritems(): cmds.sets(members, forceElement=origin_set) @@ -581,7 +584,7 @@ def get_shader_assignments_from_shapes(shapes, components=True): # Build a mapping from parent to shapes to include in lookup. transforms = {shape.rsplit("|", 1)[0]: shape for shape in shapes} - lookup = set(shapes + transforms.keys()) + lookup = set(shapes) | set(transforms.keys()) component_assignments = defaultdict(list) for shading_group in assignments.keys(): @@ -669,7 +672,8 @@ def displaySmoothness(nodes, yield finally: # Revert state - for node, state in originals.iteritems(): + _iteritems = getattr(originals, "iteritems", originals.items) + for node, state in _iteritems(): if state: cmds.displaySmoothness(node, **state) @@ -712,7 +716,8 @@ def no_display_layers(nodes): yield finally: # Restore original members - for layer, members in original.iteritems(): + _iteritems = getattr(original, "iteritems", original.items) + for layer, members in _iteritems(): cmds.editDisplayLayerMembers(layer, members, noRecurse=True) @@ -1819,7 +1824,7 @@ def set_scene_fps(fps, update=True): cmds.file(modified=True) -def set_scene_resolution(width, height): +def set_scene_resolution(width, height, pixelAspect): """Set the render resolution Args: @@ -1847,6 +1852,36 @@ def set_scene_resolution(width, height): cmds.setAttr("%s.width" % control_node, width) cmds.setAttr("%s.height" % control_node, height) + deviceAspectRatio = ((float(width) / float(height)) * float(pixelAspect)) + cmds.setAttr("%s.deviceAspectRatio" % control_node, deviceAspectRatio) + cmds.setAttr("%s.pixelAspect" % control_node, pixelAspect) + + +def reset_scene_resolution(): + """Apply the scene resolution from the project definition + + scene resolution can be overwritten by an asset if the asset.data contains + any information regarding scene resolution . + + Returns: + None + """ + + project_doc = io.find_one({"type": "project"}) + project_data = project_doc["data"] + asset_data = lib.get_asset()["data"] + + # Set project resolution + width_key = "resolutionWidth" + height_key = "resolutionHeight" + pixelAspect_key = "pixelAspect" + + width = asset_data.get(width_key, project_data.get(width_key, 1920)) + height = asset_data.get(height_key, project_data.get(height_key, 1080)) + pixelAspect = asset_data.get(pixelAspect_key, + project_data.get(pixelAspect_key, 1)) + + set_scene_resolution(width, height, pixelAspect) def set_context_settings(): """Apply the project settings from the project definition @@ -1873,18 +1908,14 @@ def set_context_settings(): api.Session["AVALON_FPS"] = str(fps) set_scene_fps(fps) - # Set project resolution - width_key = "resolutionWidth" - height_key = "resolutionHeight" - - width = asset_data.get(width_key, project_data.get(width_key, 1920)) - height = asset_data.get(height_key, project_data.get(height_key, 1080)) - - set_scene_resolution(width, height) + reset_scene_resolution() # Set frame range. avalon.maya.interactive.reset_frame_range() + # Set colorspace + set_colorspace() + # Valid FPS def validate_fps(): @@ -2152,10 +2183,11 @@ def load_capture_preset(data=None): for key in preset['Display Options']: if key.startswith('background'): disp_options[key] = preset['Display Options'][key] - disp_options[key][0] = (float(disp_options[key][0])/255) - disp_options[key][1] = (float(disp_options[key][1])/255) - disp_options[key][2] = (float(disp_options[key][2])/255) - disp_options[key].pop() + if len(disp_options[key]) == 4: + disp_options[key][0] = (float(disp_options[key][0])/255) + disp_options[key][1] = (float(disp_options[key][1])/255) + disp_options[key][2] = (float(disp_options[key][2])/255) + disp_options[key].pop() else: disp_options['displayGradient'] = True @@ -2740,3 +2772,49 @@ def iter_shader_edits(relationships, shader_nodes, nodes_by_id, label=None): "uuid": data["uuid"], "nodes": nodes, "attributes": attr_value} + + +def set_colorspace(): + """Set Colorspace from project configuration + """ + project_name = os.getenv("AVALON_PROJECT") + imageio = get_anatomy_settings(project_name)["imageio"]["maya"] + root_dict = imageio["colorManagementPreference"] + + if not isinstance(root_dict, dict): + msg = "set_colorspace(): argument should be dictionary" + log.error(msg) + + log.debug(">> root_dict: {}".format(root_dict)) + + # first enable color management + cmds.colorManagementPrefs(e=True, cmEnabled=True) + cmds.colorManagementPrefs(e=True, ocioRulesEnabled=True) + + # second set config path + if root_dict.get("configFilePath"): + unresolved_path = root_dict["configFilePath"] + ocio_paths = unresolved_path[platform.system().lower()] + + resolved_path = None + for ocio_p in ocio_paths: + resolved_path = str(ocio_p).format(**os.environ) + if not os.path.exists(resolved_path): + continue + + if resolved_path: + filepath = str(resolved_path).replace("\\", "/") + cmds.colorManagementPrefs(e=True, configFilePath=filepath) + cmds.colorManagementPrefs(e=True, cmConfigFileEnabled=True) + log.debug("maya '{}' changed to: {}".format( + "configFilePath", resolved_path)) + root_dict.pop("configFilePath") + else: + cmds.colorManagementPrefs(e=True, cmConfigFileEnabled=False) + cmds.colorManagementPrefs(e=True, configFilePath="" ) + + # third set rendering space and view transform + renderSpace = root_dict["renderSpace"] + cmds.colorManagementPrefs(e=True, renderingSpaceName=renderSpace) + viewTransform = root_dict["viewTransform"] + cmds.colorManagementPrefs(e=True, viewTransformName=viewTransform) diff --git a/openpype/hosts/maya/api/menu.py b/openpype/hosts/maya/api/menu.py index 4f0966abfd..df5058dfd5 100644 --- a/openpype/hosts/maya/api/menu.py +++ b/openpype/hosts/maya/api/menu.py @@ -11,6 +11,7 @@ from avalon.maya import pipeline from openpype.api import BuildWorkfile from openpype.settings import get_project_settings from openpype.tools.utils import host_tools +from openpype.hosts.maya.api import lib log = logging.getLogger(__name__) @@ -21,10 +22,8 @@ def _get_menu(menu_name=None): if menu_name is None: menu_name = pipeline._menu - widgets = dict(( - w.objectName(), w) for w in QtWidgets.QApplication.allWidgets()) - menu = widgets.get(menu_name) - return menu + widgets = {w.objectName(): w for w in QtWidgets.QApplication.allWidgets()} + return widgets.get(menu_name) def deferred(): @@ -46,6 +45,43 @@ def deferred(): ) ) + def add_experimental_item(): + cmds.menuItem( + "Experimental tools...", + parent=pipeline._menu, + command=lambda *args: host_tools.show_experimental_tools_dialog( + pipeline._parent + ) + ) + + def add_scripts_menu(): + try: + import scriptsmenu.launchformaya as launchformaya + except ImportError: + log.warning( + "Skipping studio.menu install, because " + "'scriptsmenu' module seems unavailable." + ) + return + + # load configuration of custom menu + project_settings = get_project_settings(os.getenv("AVALON_PROJECT")) + config = project_settings["maya"]["scriptsmenu"]["definition"] + _menu = project_settings["maya"]["scriptsmenu"]["name"] + + if not config: + log.warning("Skipping studio menu, no definition found.") + return + + # run the launcher for Maya menu + studio_menu = launchformaya.main( + title=_menu.title(), + objectName=_menu.title().lower().replace(" ", "_") + ) + + # apply configuration + studio_menu.build_from_configuration(studio_menu, config) + def modify_workfiles(): # Find the pipeline menu top_menu = _get_menu() @@ -75,6 +111,35 @@ def deferred(): if workfile_action: top_menu.removeAction(workfile_action) + def modify_resolution(): + # Find the pipeline menu + top_menu = _get_menu() + + # Try to find resolution tool action in the menu + resolution_action = None + for action in top_menu.actions(): + if action.text() == "Reset Resolution": + resolution_action = action + break + + # Add at the top of menu if "Work Files" action was not found + after_action = "" + if resolution_action: + # Use action's object name for `insertAfter` argument + after_action = resolution_action.objectName() + + # Insert action to menu + cmds.menuItem( + "Reset Resolution", + parent=pipeline._menu, + command=lambda *args: lib.reset_scene_resolution(), + insertAfter=after_action + ) + + # Remove replaced action + if resolution_action: + top_menu.removeAction(resolution_action) + def remove_project_manager(): top_menu = _get_menu() @@ -99,40 +164,42 @@ def deferred(): if project_manager_action is not None: system_menu.menu().removeAction(project_manager_action) + def add_colorspace(): + # Find the pipeline menu + top_menu = _get_menu() + + # Try to find workfile tool action in the menu + workfile_action = None + for action in top_menu.actions(): + if action.text() == "Reset Resolution": + workfile_action = action + break + + # Add at the top of menu if "Work Files" action was not found + after_action = "" + if workfile_action: + # Use action's object name for `insertAfter` argument + after_action = workfile_action.objectName() + + # Insert action to menu + cmds.menuItem( + "Set Colorspace", + parent=pipeline._menu, + command=lambda *args: lib.set_colorspace(), + insertAfter=after_action + ) + log.info("Attempting to install scripts menu ...") + # add_scripts_menu() add_build_workfiles_item() add_look_assigner_item() + add_experimental_item() modify_workfiles() + modify_resolution() remove_project_manager() - - try: - import scriptsmenu.launchformaya as launchformaya - import scriptsmenu.scriptsmenu as scriptsmenu - except ImportError: - log.warning( - "Skipping studio.menu install, because " - "'scriptsmenu' module seems unavailable." - ) - return - - # load configuration of custom menu - project_settings = get_project_settings(os.getenv("AVALON_PROJECT")) - config = project_settings["maya"]["scriptsmenu"]["definition"] - _menu = project_settings["maya"]["scriptsmenu"]["name"] - - if not config: - log.warning("Skipping studio menu, no definition found.") - return - - # run the launcher for Maya menu - studio_menu = launchformaya.main( - title=_menu.title(), - objectName=_menu.title().lower().replace(" ", "_") - ) - - # apply configuration - studio_menu.build_from_configuration(studio_menu, config) + add_colorspace() + add_scripts_menu() def uninstall(): @@ -153,7 +220,7 @@ def install(): return # Allow time for uninstallation to finish. - cmds.evalDeferred(deferred) + cmds.evalDeferred(deferred, lowestPriority=True) def popup(): diff --git a/openpype/hosts/maya/api/setdress.py b/openpype/hosts/maya/api/setdress.py index be26572039..3537fa3837 100644 --- a/openpype/hosts/maya/api/setdress.py +++ b/openpype/hosts/maya/api/setdress.py @@ -5,6 +5,7 @@ import os import contextlib import copy +import six from maya import cmds from avalon import api, io @@ -69,7 +70,8 @@ def unlocked(nodes): yield finally: # Reapply original states - for uuid, state in states.iteritems(): + _iteritems = getattr(states, "iteritems", states.items) + for uuid, state in _iteritems(): nodes_from_id = cmds.ls(uuid, long=True) if nodes_from_id: node = nodes_from_id[0] @@ -94,7 +96,7 @@ def load_package(filepath, name, namespace=None): # Define a unique namespace for the package namespace = os.path.basename(filepath).split(".")[0] unique_namespace(namespace) - assert isinstance(namespace, basestring) + assert isinstance(namespace, six.string_types) # Load the setdress package data with open(filepath, "r") as fp: diff --git a/openpype/hosts/maya/plugins/publish/extract_fbx.py b/openpype/hosts/maya/plugins/publish/extract_fbx.py index e5f3b0cda4..720a61b0a7 100644 --- a/openpype/hosts/maya/plugins/publish/extract_fbx.py +++ b/openpype/hosts/maya/plugins/publish/extract_fbx.py @@ -183,7 +183,8 @@ class ExtractFBX(openpype.api.Extractor): # Apply the FBX overrides through MEL since the commands # only work correctly in MEL according to online # available discussions on the topic - for option, value in options.iteritems(): + _iteritems = getattr(options, "iteritems", options.items) + for option, value in _iteritems(): key = option[0].upper() + option[1:] # uppercase first letter # Boolean must be passed as lower-case strings diff --git a/openpype/hosts/maya/plugins/publish/extract_playblast.py b/openpype/hosts/maya/plugins/publish/extract_playblast.py index 57e3f478f1..b233a57453 100644 --- a/openpype/hosts/maya/plugins/publish/extract_playblast.py +++ b/openpype/hosts/maya/plugins/publish/extract_playblast.py @@ -45,9 +45,12 @@ class ExtractPlayblast(openpype.api.Extractor): # get cameras camera = instance.data['review_camera'] + override_viewport_options = ( + self.capture_preset['Viewport Options'] + ['override_viewport_options'] + ) preset = lib.load_capture_preset(data=self.capture_preset) - preset['camera'] = camera preset['start_frame'] = start preset['end_frame'] = end @@ -92,6 +95,12 @@ class ExtractPlayblast(openpype.api.Extractor): self.log.info('using viewport preset: {}'.format(preset)) + # Update preset with current panel setting + # if override_viewport_options is turned off + if not override_viewport_options: + panel_preset = capture.parse_active_view() + preset.update(panel_preset) + path = capture.capture(**preset) self.log.debug("playblast path {}".format(path)) diff --git a/openpype/hosts/maya/plugins/publish/extract_thumbnail.py b/openpype/hosts/maya/plugins/publish/extract_thumbnail.py index aa8adc3986..c2cefc56f1 100644 --- a/openpype/hosts/maya/plugins/publish/extract_thumbnail.py +++ b/openpype/hosts/maya/plugins/publish/extract_thumbnail.py @@ -32,6 +32,9 @@ class ExtractThumbnail(openpype.api.Extractor): capture_preset = ( instance.context.data["project_settings"]['maya']['publish']['ExtractPlayblast']['capture_preset'] ) + override_viewport_options = ( + capture_preset['Viewport Options']['override_viewport_options'] + ) try: preset = lib.load_capture_preset(data=capture_preset) @@ -86,6 +89,12 @@ class ExtractThumbnail(openpype.api.Extractor): # playblast and viewer preset['viewer'] = False + # Update preset with current panel setting + # if override_viewport_options is turned off + if not override_viewport_options: + panel_preset = capture.parse_active_view() + preset.update(panel_preset) + path = capture.capture(**preset) playblast = self._fix_playblast_output_path(path) diff --git a/openpype/hosts/maya/plugins/publish/submit_maya_muster.py b/openpype/hosts/maya/plugins/publish/submit_maya_muster.py index 1c97f0faf7..207cf56cfe 100644 --- a/openpype/hosts/maya/plugins/publish/submit_maya_muster.py +++ b/openpype/hosts/maya/plugins/publish/submit_maya_muster.py @@ -383,7 +383,7 @@ class MayaSubmitMuster(pyblish.api.InstancePlugin): "attributes": { "environmental_variables": { "value": ", ".join("{!s}={!r}".format(k, v) - for (k, v) in env.iteritems()), + for (k, v) in env.items()), "state": True, "subst": False diff --git a/openpype/hosts/maya/plugins/publish/validate_instance_subset.py b/openpype/hosts/maya/plugins/publish/validate_instance_subset.py index a8c16425d6..539f3f9d3c 100644 --- a/openpype/hosts/maya/plugins/publish/validate_instance_subset.py +++ b/openpype/hosts/maya/plugins/publish/validate_instance_subset.py @@ -2,6 +2,8 @@ import pyblish.api import openpype.api import string +import six + # Allow only characters, numbers and underscore allowed = set(string.ascii_lowercase + string.ascii_uppercase + @@ -29,7 +31,7 @@ class ValidateSubsetName(pyblish.api.InstancePlugin): raise RuntimeError("Instance is missing subset " "name: {0}".format(subset)) - if not isinstance(subset, basestring): + if not isinstance(subset, six.string_types): raise TypeError("Instance subset name must be string, " "got: {0} ({1})".format(subset, type(subset))) diff --git a/openpype/hosts/maya/plugins/publish/validate_mesh_ngons.py b/openpype/hosts/maya/plugins/publish/validate_mesh_ngons.py new file mode 100644 index 0000000000..e8cc019b52 --- /dev/null +++ b/openpype/hosts/maya/plugins/publish/validate_mesh_ngons.py @@ -0,0 +1,53 @@ +from maya import cmds + +import pyblish.api +import openpype.api +import openpype.hosts.maya.api.action +from avalon import maya +from openpype.hosts.maya.api import lib + + +def polyConstraint(objects, *args, **kwargs): + kwargs.pop('mode', None) + + with lib.no_undo(flush=False): + with maya.maintained_selection(): + with lib.reset_polySelectConstraint(): + cmds.select(objects, r=1, noExpand=True) + # Acting as 'polyCleanupArgList' for n-sided polygon selection + cmds.polySelectConstraint(*args, mode=3, **kwargs) + result = cmds.ls(selection=True) + cmds.select(clear=True) + + return result + + +class ValidateMeshNgons(pyblish.api.Validator): + """Ensure that meshes don't have ngons + + Ngon are faces with more than 4 sides. + + To debug the problem on the meshes you can use Maya's modeling + tool: "Mesh > Cleanup..." + + """ + + order = openpype.api.ValidateContentsOrder + hosts = ["maya"] + families = ["model"] + label = "Mesh ngons" + actions = [openpype.hosts.maya.api.action.SelectInvalidAction] + + @staticmethod + def get_invalid(instance): + + meshes = cmds.ls(instance, type='mesh') + return polyConstraint(meshes, type=8, size=3) + + def process(self, instance): + """Process all the nodes in the instance "objectSet""" + + invalid = self.get_invalid(instance) + if invalid: + raise ValueError("Meshes found with n-gons" + "values: {0}".format(invalid)) diff --git a/openpype/hosts/maya/plugins/publish/validate_node_ids_unique.py b/openpype/hosts/maya/plugins/publish/validate_node_ids_unique.py index 39bb148911..ed9ef526d6 100644 --- a/openpype/hosts/maya/plugins/publish/validate_node_ids_unique.py +++ b/openpype/hosts/maya/plugins/publish/validate_node_ids_unique.py @@ -52,7 +52,8 @@ class ValidateNodeIdsUnique(pyblish.api.InstancePlugin): # Take only the ids with more than one member invalid = list() - for _ids, members in ids.iteritems(): + _iteritems = getattr(ids, "iteritems", ids.items) + for _ids, members in _iteritems(): if len(members) > 1: cls.log.error("ID found on multiple nodes: '%s'" % members) invalid.extend(members) diff --git a/openpype/hosts/maya/plugins/publish/validate_node_no_ghosting.py b/openpype/hosts/maya/plugins/publish/validate_node_no_ghosting.py index 671c744a22..38f3ab1e68 100644 --- a/openpype/hosts/maya/plugins/publish/validate_node_no_ghosting.py +++ b/openpype/hosts/maya/plugins/publish/validate_node_no_ghosting.py @@ -32,7 +32,10 @@ class ValidateNodeNoGhosting(pyblish.api.InstancePlugin): nodes = cmds.ls(instance, long=True, type=['transform', 'shape']) invalid = [] for node in nodes: - for attr, required_value in cls._attributes.iteritems(): + _iteritems = getattr( + cls._attributes, "iteritems", cls._attributes.items + ) + for attr, required_value in _iteritems(): if cmds.attributeQuery(attr, node=node, exists=True): value = cmds.getAttr('{0}.{1}'.format(node, attr)) diff --git a/openpype/hosts/maya/plugins/publish/validate_shape_render_stats.py b/openpype/hosts/maya/plugins/publish/validate_shape_render_stats.py index 667a1f13be..714451bb98 100644 --- a/openpype/hosts/maya/plugins/publish/validate_shape_render_stats.py +++ b/openpype/hosts/maya/plugins/publish/validate_shape_render_stats.py @@ -33,7 +33,8 @@ class ValidateShapeRenderStats(pyblish.api.Validator): shapes = cmds.ls(instance, long=True, type='surfaceShape') invalid = [] for shape in shapes: - for attr, default_value in cls.defaults.iteritems(): + _iteritems = getattr(cls.defaults, "iteritems", cls.defaults.items) + for attr, default_value in _iteritems(): if cmds.attributeQuery(attr, node=shape, exists=True): value = cmds.getAttr('{}.{}'.format(shape, attr)) if value != default_value: @@ -52,7 +53,8 @@ class ValidateShapeRenderStats(pyblish.api.Validator): @classmethod def repair(cls, instance): for shape in cls.get_invalid(instance): - for attr, default_value in cls.defaults.iteritems(): + _iteritems = getattr(cls.defaults, "iteritems", cls.defaults.items) + for attr, default_value in _iteritems(): if cmds.attributeQuery(attr, node=shape, exists=True): plug = '{0}.{1}'.format(shape, attr) diff --git a/openpype/hosts/maya/plugins/publish/validate_shape_zero.py b/openpype/hosts/maya/plugins/publish/validate_shape_zero.py new file mode 100644 index 0000000000..2c594ef5f3 --- /dev/null +++ b/openpype/hosts/maya/plugins/publish/validate_shape_zero.py @@ -0,0 +1,59 @@ +from maya import cmds + +import pyblish.api +import openpype.api +import openpype.hosts.maya.api.action + + +class ValidateShapeZero(pyblish.api.Validator): + """shape can't have any values + + To solve this issue, try freezing the shapes. So long + as the translation, rotation and scaling values are zero, + you're all good. + + """ + + order = openpype.api.ValidateContentsOrder + hosts = ["maya"] + families = ["model"] + label = "Shape Zero (Freeze)" + actions = [ + openpype.hosts.maya.api.action.SelectInvalidAction, + openpype.api.RepairAction + ] + + @staticmethod + def get_invalid(instance): + """Returns the invalid shapes in the instance. + + This is the same as checking: + - all(pnt == [0,0,0] for pnt in shape.pnts[:]) + + Returns: + list: Shape with non freezed vertex + + """ + + shapes = cmds.ls(instance, type="shape") + + invalid = [] + for shape in shapes: + if cmds.polyCollapseTweaks(shape, q=True, hasVertexTweaks=True): + invalid.append(shape) + + return invalid + + @classmethod + def repair(cls, instance): + invalid_shapes = cls.get_invalid(instance) + for shape in invalid_shapes: + cmds.polyCollapseTweaks(shape) + + def process(self, instance): + """Process all the nodes in the instance "objectSet""" + + invalid = self.get_invalid(instance) + if invalid: + raise ValueError("Nodes found with shape or vertices not freezed" + "values: {0}".format(invalid)) diff --git a/openpype/hosts/nuke/api/lib.py b/openpype/hosts/nuke/api/lib.py index 9ee3a4464b..6d593ca588 100644 --- a/openpype/hosts/nuke/api/lib.py +++ b/openpype/hosts/nuke/api/lib.py @@ -24,6 +24,10 @@ from openpype.api import ( ApplicationManager ) from openpype.tools.utils import host_tools +from openpype.lib.path_tools import HostDirmap +from openpype.settings import get_project_settings +from openpype.modules import ModulesManager + import nuke from .utils import set_context_favorites @@ -1795,3 +1799,69 @@ def recreate_instance(origin_node, avalon_data=None): dn.setInput(0, new_node) return new_node + + +class NukeDirmap(HostDirmap): + def __init__(self, host_name, project_settings, sync_module, file_name): + """ + Args: + host_name (str): Nuke + project_settings (dict): settings of current project + sync_module (SyncServerModule): to limit reinitialization + file_name (str): full path of referenced file from workfiles + """ + self.host_name = host_name + self.project_settings = project_settings + self.file_name = file_name + self.sync_module = sync_module + + self._mapping = None # cache mapping + + def on_enable_dirmap(self): + pass + + def dirmap_routine(self, source_path, destination_path): + log.debug("{}: {}->{}".format(self.file_name, + source_path, destination_path)) + source_path = source_path.lower().replace(os.sep, '/') + destination_path = destination_path.lower().replace(os.sep, '/') + if platform.system().lower() == "windows": + self.file_name = self.file_name.lower().replace( + source_path, destination_path) + else: + self.file_name = self.file_name.replace( + source_path, destination_path) + + +class DirmapCache: + """Caching class to get settings and sync_module easily and only once.""" + _project_settings = None + _sync_module = None + + @classmethod + def project_settings(cls): + if cls._project_settings is None: + cls._project_settings = get_project_settings( + os.getenv("AVALON_PROJECT")) + return cls._project_settings + + @classmethod + def sync_module(cls): + if cls._sync_module is None: + cls._sync_module = ModulesManager().modules_by_name["sync_server"] + return cls._sync_module + + +def dirmap_file_name_filter(file_name): + """Nuke callback function with single full path argument. + + Checks project settings for potential mapping from source to dest. + """ + dirmap_processor = NukeDirmap("nuke", + DirmapCache.project_settings(), + DirmapCache.sync_module(), + file_name) + dirmap_processor.process_dirmap() + if os.path.exists(dirmap_processor.file_name): + return dirmap_processor.file_name + return file_name diff --git a/openpype/hosts/nuke/api/menu.py b/openpype/hosts/nuke/api/menu.py index 87990c5e92..3e74893589 100644 --- a/openpype/hosts/nuke/api/menu.py +++ b/openpype/hosts/nuke/api/menu.py @@ -84,6 +84,12 @@ def install(): ) log.debug("Adding menu item: {}".format(name)) + # Add experimental tools action + menu.addSeparator() + menu.addCommand( + "Experimental tools...", + host_tools.show_experimental_tools_dialog + ) # adding shortcuts add_shortcuts_from_presets() diff --git a/openpype/hosts/nuke/startup/menu.py b/openpype/hosts/nuke/startup/menu.py index c452acb709..b7ed35b3b4 100644 --- a/openpype/hosts/nuke/startup/menu.py +++ b/openpype/hosts/nuke/startup/menu.py @@ -6,10 +6,10 @@ from openpype.hosts.nuke.api.lib import ( import nuke from openpype.api import Logger +from openpype.hosts.nuke.api.lib import dirmap_file_name_filter log = Logger().get_logger(__name__) - # fix ffmpeg settings on script nuke.addOnScriptLoad(on_script_load) @@ -20,4 +20,6 @@ nuke.addOnScriptSave(check_inventory_versions) # # set apply all workfile settings on script load and save nuke.addOnScriptLoad(WorkfileSettings().set_context_settings) +nuke.addFilenameFilter(dirmap_file_name_filter) + log.info('Automatic syncing of write file knob to script version') diff --git a/openpype/hosts/photoshop/plugins/load/load_image.py b/openpype/hosts/photoshop/plugins/load/load_image.py index d043323768..981a1ed204 100644 --- a/openpype/hosts/photoshop/plugins/load/load_image.py +++ b/openpype/hosts/photoshop/plugins/load/load_image.py @@ -6,7 +6,6 @@ from openpype.hosts.photoshop.plugins.lib import get_unique_layer_name stub = photoshop.stub() - class ImageLoader(api.Loader): """Load images @@ -21,7 +20,7 @@ class ImageLoader(api.Loader): context["asset"]["name"], name) with photoshop.maintained_selection(): - layer = stub.import_smart_object(self.fname, layer_name) + layer = self.import_layer(self.fname, layer_name) self[:] = [layer] namespace = namespace or layer_name @@ -45,8 +44,9 @@ class ImageLoader(api.Loader): layer_name = "{}_{}".format(context["asset"], context["subset"]) # switching assets if namespace_from_container != layer_name: - layer_name = self._get_unique_layer_name(context["asset"], - context["subset"]) + layer_name = get_unique_layer_name(stub.get_layers(), + context["asset"], + context["subset"]) else: # switching version - keep same name layer_name = container["namespace"] @@ -72,3 +72,6 @@ class ImageLoader(api.Loader): def switch(self, container, representation): self.update(container, representation) + + def import_layer(self, file_name, layer_name): + return stub.import_smart_object(file_name, layer_name) diff --git a/openpype/hosts/photoshop/plugins/load/load_reference.py b/openpype/hosts/photoshop/plugins/load/load_reference.py new file mode 100644 index 0000000000..0cb4e4a69f --- /dev/null +++ b/openpype/hosts/photoshop/plugins/load/load_reference.py @@ -0,0 +1,82 @@ +import re + +from avalon import api, photoshop + +from openpype.hosts.photoshop.plugins.lib import get_unique_layer_name + +stub = photoshop.stub() + + +class ReferenceLoader(api.Loader): + """Load reference images + + Stores the imported asset in a container named after the asset. + + Inheriting from 'load_image' didn't work because of + "Cannot write to closing transport", possible refactor. + """ + + families = ["image", "render"] + representations = ["*"] + + def load(self, context, name=None, namespace=None, data=None): + layer_name = get_unique_layer_name(stub.get_layers(), + context["asset"]["name"], + name) + with photoshop.maintained_selection(): + layer = self.import_layer(self.fname, layer_name) + + self[:] = [layer] + namespace = namespace or layer_name + + return photoshop.containerise( + name, + namespace, + layer, + context, + self.__class__.__name__ + ) + + def update(self, container, representation): + """ Switch asset or change version """ + layer = container.pop("layer") + + context = representation.get("context", {}) + + namespace_from_container = re.sub(r'_\d{3}$', '', + container["namespace"]) + layer_name = "{}_{}".format(context["asset"], context["subset"]) + # switching assets + if namespace_from_container != layer_name: + layer_name = get_unique_layer_name(stub.get_layers(), + context["asset"], + context["subset"]) + else: # switching version - keep same name + layer_name = container["namespace"] + + path = api.get_representation_path(representation) + with photoshop.maintained_selection(): + stub.replace_smart_object( + layer, path, layer_name + ) + + stub.imprint( + layer, {"representation": str(representation["_id"])} + ) + + def remove(self, container): + """ + Removes element from scene: deletes layer + removes from Headline + Args: + container (dict): container to be removed - used to get layer_id + """ + layer = container.pop("layer") + stub.imprint(layer, {}) + stub.delete_layer(layer.id) + + def switch(self, container, representation): + self.update(container, representation) + + def import_layer(self, file_name, layer_name): + return stub.import_smart_object(file_name, layer_name, + as_reference=True) diff --git a/openpype/hosts/photoshop/plugins/publish/closePS.py b/openpype/hosts/photoshop/plugins/publish/closePS.py new file mode 100644 index 0000000000..2f0eab0ee5 --- /dev/null +++ b/openpype/hosts/photoshop/plugins/publish/closePS.py @@ -0,0 +1,29 @@ +# -*- coding: utf-8 -*- +"""Close PS after publish. For Webpublishing only.""" +import os + +import pyblish.api + +from avalon import photoshop + + +class ClosePS(pyblish.api.ContextPlugin): + """Close PS after publish. For Webpublishing only. + """ + + order = pyblish.api.IntegratorOrder + 14 + label = "Close PS" + optional = True + active = True + + hosts = ["photoshop"] + targets = ["remotepublish"] + + def process(self, context): + self.log.info("ClosePS") + + stub = photoshop.stub() + self.log.info("Shutting down PS") + stub.save() + stub.close() + self.log.info("PS closed") diff --git a/openpype/hosts/photoshop/plugins/publish/collect_remote_instances.py b/openpype/hosts/photoshop/plugins/publish/collect_remote_instances.py new file mode 100644 index 0000000000..c76e15484e --- /dev/null +++ b/openpype/hosts/photoshop/plugins/publish/collect_remote_instances.py @@ -0,0 +1,133 @@ +import pyblish.api +import os +import re + +from avalon import photoshop +from openpype.lib import prepare_template_data +from openpype.lib.plugin_tools import parse_json + + +class CollectRemoteInstances(pyblish.api.ContextPlugin): + """Gather instances configured color code of a layer. + + Used in remote publishing when artists marks publishable layers by color- + coding. + + Identifier: + id (str): "pyblish.avalon.instance" + """ + order = pyblish.api.CollectorOrder + 0.100 + + label = "Instances" + order = pyblish.api.CollectorOrder + hosts = ["photoshop"] + targets = ["remotepublish"] + + # configurable by Settings + color_code_mapping = [] + + def process(self, context): + self.log.info("CollectRemoteInstances") + self.log.info("mapping:: {}".format(self.color_code_mapping)) + + # parse variant if used in webpublishing, comes from webpublisher batch + batch_dir = os.environ.get("OPENPYPE_PUBLISH_DATA") + variant = "Main" + if batch_dir and os.path.exists(batch_dir): + # TODO check if batch manifest is same as tasks manifests + task_data = parse_json(os.path.join(batch_dir, + "manifest.json")) + if not task_data: + raise ValueError( + "Cannot parse batch meta in {} folder".format(batch_dir)) + variant = task_data["variant"] + + stub = photoshop.stub() + layers = stub.get_layers() + + instance_names = [] + for layer in layers: + self.log.info("Layer:: {}".format(layer)) + resolved_family, resolved_subset_template = self._resolve_mapping( + layer + ) + self.log.info("resolved_family {}".format(resolved_family)) + self.log.info("resolved_subset_template {}".format( + resolved_subset_template)) + + if not resolved_subset_template or not resolved_family: + self.log.debug("!!! Not marked, skip") + continue + + if layer.parents: + self.log.debug("!!! Not a top layer, skip") + continue + + instance = context.create_instance(layer.name) + instance.append(layer) + instance.data["family"] = resolved_family + instance.data["publish"] = layer.visible + instance.data["asset"] = context.data["assetEntity"]["name"] + instance.data["task"] = context.data["taskType"] + + fill_pairs = { + "variant": variant, + "family": instance.data["family"], + "task": instance.data["task"], + "layer": layer.name + } + subset = resolved_subset_template.format( + **prepare_template_data(fill_pairs)) + instance.data["subset"] = subset + + instance_names.append(layer.name) + + # Produce diagnostic message for any graphical + # user interface interested in visualising it. + self.log.info("Found: \"%s\" " % instance.data["name"]) + self.log.info("instance: {} ".format(instance.data)) + + if len(instance_names) != len(set(instance_names)): + self.log.warning("Duplicate instances found. " + + "Remove unwanted via SubsetManager") + + def _resolve_mapping(self, layer): + """Matches 'layer' color code and name to mapping. + + If both color code AND name regex is configured, BOTH must be valid + If layer matches to multiple mappings, only first is used! + """ + family_list = [] + family = None + subset_name_list = [] + resolved_subset_template = None + for mapping in self.color_code_mapping: + if mapping["color_code"] and \ + layer.color_code not in mapping["color_code"]: + continue + + if mapping["layer_name_regex"] and \ + not any(re.search(pattern, layer.name) + for pattern in mapping["layer_name_regex"]): + continue + + family_list.append(mapping["family"]) + subset_name_list.append(mapping["subset_template_name"]) + + if len(subset_name_list) > 1: + self.log.warning("Multiple mappings found for '{}'". + format(layer.name)) + self.log.warning("Only first subset name template used!") + subset_name_list[:] = subset_name_list[0] + + if len(family_list) > 1: + self.log.warning("Multiple mappings found for '{}'". + format(layer.name)) + self.log.warning("Only first family used!") + family_list[:] = family_list[0] + if subset_name_list: + resolved_subset_template = subset_name_list.pop() + if family_list: + family = family_list.pop() + + return family, resolved_subset_template diff --git a/openpype/hosts/photoshop/plugins/publish/extract_image.py b/openpype/hosts/photoshop/plugins/publish/extract_image.py index 87574d1269..ae9892e290 100644 --- a/openpype/hosts/photoshop/plugins/publish/extract_image.py +++ b/openpype/hosts/photoshop/plugins/publish/extract_image.py @@ -12,7 +12,7 @@ class ExtractImage(openpype.api.Extractor): label = "Extract Image" hosts = ["photoshop"] - families = ["image"] + families = ["image", "background"] formats = ["png", "jpg"] def process(self, instance): diff --git a/openpype/hosts/standalonepublisher/plugins/publish/collect_bulk_mov_instances.py b/openpype/hosts/standalonepublisher/plugins/publish/collect_bulk_mov_instances.py index a5177335b3..9f075d66cf 100644 --- a/openpype/hosts/standalonepublisher/plugins/publish/collect_bulk_mov_instances.py +++ b/openpype/hosts/standalonepublisher/plugins/publish/collect_bulk_mov_instances.py @@ -3,7 +3,7 @@ import json import pyblish.api from avalon import io -from openpype.lib import get_subset_name +from openpype.lib import get_subset_name_with_asset_doc class CollectBulkMovInstances(pyblish.api.InstancePlugin): @@ -26,16 +26,10 @@ class CollectBulkMovInstances(pyblish.api.InstancePlugin): context = instance.context asset_name = instance.data["asset"] - asset_doc = io.find_one( - { - "type": "asset", - "name": asset_name - }, - { - "_id": 1, - "data.tasks": 1 - } - ) + asset_doc = io.find_one({ + "type": "asset", + "name": asset_name + }) if not asset_doc: raise AssertionError(( "Couldn't find Asset document with name \"{}\"" @@ -53,11 +47,11 @@ class CollectBulkMovInstances(pyblish.api.InstancePlugin): task_name = available_task_names[_task_name_low] break - subset_name = get_subset_name( + subset_name = get_subset_name_with_asset_doc( self.new_instance_family, self.subset_name_variant, task_name, - asset_doc["_id"], + asset_doc, io.Session["AVALON_PROJECT"] ) instance_name = f"{asset_name}_{subset_name}" diff --git a/openpype/hosts/standalonepublisher/plugins/publish/validate_sources.py b/openpype/hosts/standalonepublisher/plugins/publish/validate_sources.py new file mode 100644 index 0000000000..eec675e97f --- /dev/null +++ b/openpype/hosts/standalonepublisher/plugins/publish/validate_sources.py @@ -0,0 +1,37 @@ +import pyblish.api +import openpype.api + +import os + + +class ValidateSources(pyblish.api.InstancePlugin): + """Validates source files. + + Loops through all 'files' in 'stagingDir' if actually exist. They might + got deleted between starting of SP and now. + + """ + + order = openpype.api.ValidateContentsOrder + label = "Check source files" + + optional = True # only for unforeseeable cases + + hosts = ["standalonepublisher"] + + def process(self, instance): + self.log.info("instance {}".format(instance.data)) + + for repre in instance.data.get("representations") or []: + files = [] + if isinstance(repre["files"], str): + files.append(repre["files"]) + else: + files = list(repre["files"]) + + for file_name in files: + source_file = os.path.join(repre["stagingDir"], + file_name) + + if not os.path.exists(source_file): + raise ValueError("File {} not found".format(source_file)) diff --git a/openpype/hosts/testhost/README.md b/openpype/hosts/testhost/README.md new file mode 100644 index 0000000000..f69e02a3b3 --- /dev/null +++ b/openpype/hosts/testhost/README.md @@ -0,0 +1,16 @@ +# What is `testhost` +Host `testhost` was created to fake running host for testing of publisher. + +Does not have any proper launch mechanism at the moment. There is python script `./run_publish.py` which will show publisher window. The script requires to set few variables to run. Execution will register host `testhost`, register global publish plugins and register creator and publish plugins from `./plugins`. + +## Data +Created instances and context data are stored into json files inside `./api` folder. Can be easily modified to save them to a different place. + +## Plugins +Test host has few plugins to be able test publishing. + +### Creators +They are just example plugins using functions from `api` to create/remove/update data. One of them is auto creator which means that is triggered on each reset of create context. Others are manual creators both creating the same family. + +### Publishers +Collectors are example plugin to use `get_attribute_defs` to define attributes for specific families or for context. Validators are to test `PublishValidationError`. diff --git a/openpype/hosts/testhost/__init__.py b/openpype/hosts/testhost/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/openpype/hosts/testhost/api/__init__.py b/openpype/hosts/testhost/api/__init__.py new file mode 100644 index 0000000000..7840b25892 --- /dev/null +++ b/openpype/hosts/testhost/api/__init__.py @@ -0,0 +1,43 @@ +import os +import logging +import pyblish.api +import avalon.api +from openpype.pipeline import BaseCreator + +from .pipeline import ( + ls, + list_instances, + update_instances, + remove_instances, + get_context_data, + update_context_data, + get_context_title +) + + +HOST_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +PLUGINS_DIR = os.path.join(HOST_DIR, "plugins") +PUBLISH_PATH = os.path.join(PLUGINS_DIR, "publish") +CREATE_PATH = os.path.join(PLUGINS_DIR, "create") + +log = logging.getLogger(__name__) + + +def install(): + log.info("OpenPype - Installing TestHost integration") + pyblish.api.register_host("testhost") + pyblish.api.register_plugin_path(PUBLISH_PATH) + avalon.api.register_plugin_path(BaseCreator, CREATE_PATH) + + +__all__ = ( + "ls", + "list_instances", + "update_instances", + "remove_instances", + "get_context_data", + "update_context_data", + "get_context_title", + + "install" +) diff --git a/openpype/hosts/testhost/api/context.json b/openpype/hosts/testhost/api/context.json new file mode 100644 index 0000000000..0967ef424b --- /dev/null +++ b/openpype/hosts/testhost/api/context.json @@ -0,0 +1 @@ +{} diff --git a/openpype/hosts/testhost/api/instances.json b/openpype/hosts/testhost/api/instances.json new file mode 100644 index 0000000000..84021eff91 --- /dev/null +++ b/openpype/hosts/testhost/api/instances.json @@ -0,0 +1,108 @@ +[ + { + "id": "pyblish.avalon.instance", + "active": true, + "family": "test", + "subset": "testMyVariant", + "version": 1, + "asset": "sq01_sh0010", + "task": "Compositing", + "variant": "myVariant", + "uuid": "a485f148-9121-46a5-8157-aa64df0fb449", + "creator_attributes": { + "number_key": 10, + "ha": 10 + }, + "publish_attributes": { + "CollectFtrackApi": { + "add_ftrack_family": false + } + }, + "creator_identifier": "test_one" + }, + { + "id": "pyblish.avalon.instance", + "active": true, + "family": "test", + "subset": "testMyVariant2", + "version": 1, + "asset": "sq01_sh0010", + "task": "Compositing", + "variant": "myVariant2", + "uuid": "a485f148-9121-46a5-8157-aa64df0fb444", + "creator_attributes": {}, + "publish_attributes": { + "CollectFtrackApi": { + "add_ftrack_family": true + } + }, + "creator_identifier": "test_one" + }, + { + "id": "pyblish.avalon.instance", + "active": true, + "family": "test", + "subset": "testMain", + "version": 1, + "asset": "sq01_sh0010", + "task": "Compositing", + "variant": "Main", + "uuid": "3607bc95-75f6-4648-a58d-e699f413d09f", + "creator_attributes": {}, + "publish_attributes": { + "CollectFtrackApi": { + "add_ftrack_family": true + } + }, + "creator_identifier": "test_two" + }, + { + "id": "pyblish.avalon.instance", + "active": true, + "family": "test", + "subset": "testMain2", + "version": 1, + "asset": "sq01_sh0020", + "task": "Compositing", + "variant": "Main2", + "uuid": "4ccf56f6-9982-4837-967c-a49695dbe8eb", + "creator_attributes": {}, + "publish_attributes": { + "CollectFtrackApi": { + "add_ftrack_family": true + } + }, + "creator_identifier": "test_two" + }, + { + "id": "pyblish.avalon.instance", + "family": "test_three", + "subset": "test_threeMain2", + "active": true, + "version": 1, + "asset": "sq01_sh0020", + "task": "Compositing", + "variant": "Main2", + "uuid": "4ccf56f6-9982-4837-967c-a49695dbe8ec", + "creator_attributes": {}, + "publish_attributes": { + "CollectFtrackApi": { + "add_ftrack_family": true + } + } + }, + { + "id": "pyblish.avalon.instance", + "family": "workfile", + "subset": "workfileMain", + "active": true, + "creator_identifier": "workfile", + "version": 1, + "asset": "Alpaca_01", + "task": "modeling", + "variant": "Main", + "uuid": "7c9ddfc7-9f9c-4c1c-b233-38c966735fb6", + "creator_attributes": {}, + "publish_attributes": {} + } +] \ No newline at end of file diff --git a/openpype/hosts/testhost/api/pipeline.py b/openpype/hosts/testhost/api/pipeline.py new file mode 100644 index 0000000000..49f1d3f33d --- /dev/null +++ b/openpype/hosts/testhost/api/pipeline.py @@ -0,0 +1,156 @@ +import os +import json + + +class HostContext: + instances_json_path = None + context_json_path = None + + @classmethod + def get_context_title(cls): + project_name = os.environ.get("AVALON_PROJECT") + if not project_name: + return "TestHost" + + asset_name = os.environ.get("AVALON_ASSET") + if not asset_name: + return project_name + + from avalon import io + + asset_doc = io.find_one( + {"type": "asset", "name": asset_name}, + {"data.parents": 1} + ) + parents = asset_doc.get("data", {}).get("parents") or [] + + hierarchy = [project_name] + hierarchy.extend(parents) + hierarchy.append("{}".format(asset_name)) + task_name = os.environ.get("AVALON_TASK") + if task_name: + hierarchy.append(task_name) + + return "/".join(hierarchy) + + @classmethod + def get_current_dir_filepath(cls, filename): + return os.path.join( + os.path.dirname(os.path.abspath(__file__)), + filename + ) + + @classmethod + def get_instances_json_path(cls): + if cls.instances_json_path is None: + cls.instances_json_path = cls.get_current_dir_filepath( + "instances.json" + ) + return cls.instances_json_path + + @classmethod + def get_context_json_path(cls): + if cls.context_json_path is None: + cls.context_json_path = cls.get_current_dir_filepath( + "context.json" + ) + return cls.context_json_path + + @classmethod + def add_instance(cls, instance): + instances = cls.get_instances() + instances.append(instance) + cls.save_instances(instances) + + @classmethod + def save_instances(cls, instances): + json_path = cls.get_instances_json_path() + with open(json_path, "w") as json_stream: + json.dump(instances, json_stream, indent=4) + + @classmethod + def get_instances(cls): + json_path = cls.get_instances_json_path() + if not os.path.exists(json_path): + instances = [] + with open(json_path, "w") as json_stream: + json.dump(json_stream, instances) + else: + with open(json_path, "r") as json_stream: + instances = json.load(json_stream) + return instances + + @classmethod + def get_context_data(cls): + json_path = cls.get_context_json_path() + if not os.path.exists(json_path): + data = {} + with open(json_path, "w") as json_stream: + json.dump(data, json_stream) + else: + with open(json_path, "r") as json_stream: + data = json.load(json_stream) + return data + + @classmethod + def save_context_data(cls, data): + json_path = cls.get_context_json_path() + with open(json_path, "w") as json_stream: + json.dump(data, json_stream, indent=4) + + +def ls(): + return [] + + +def list_instances(): + return HostContext.get_instances() + + +def update_instances(update_list): + updated_instances = {} + for instance, _changes in update_list: + updated_instances[instance.id] = instance.data_to_store() + + instances = HostContext.get_instances() + for instance_data in instances: + instance_id = instance_data["uuid"] + if instance_id in updated_instances: + new_instance_data = updated_instances[instance_id] + old_keys = set(instance_data.keys()) + new_keys = set(new_instance_data.keys()) + instance_data.update(new_instance_data) + for key in (old_keys - new_keys): + instance_data.pop(key) + + HostContext.save_instances(instances) + + +def remove_instances(instances): + if not isinstance(instances, (tuple, list)): + instances = [instances] + + current_instances = HostContext.get_instances() + for instance in instances: + instance_id = instance.data["uuid"] + found_idx = None + for idx, _instance in enumerate(current_instances): + if instance_id == _instance["uuid"]: + found_idx = idx + break + + if found_idx is not None: + current_instances.pop(found_idx) + HostContext.save_instances(current_instances) + + +def get_context_data(): + return HostContext.get_context_data() + + +def update_context_data(data, changes): + HostContext.save_context_data(data) + + +def get_context_title(): + return HostContext.get_context_title() diff --git a/openpype/hosts/testhost/plugins/create/auto_creator.py b/openpype/hosts/testhost/plugins/create/auto_creator.py new file mode 100644 index 0000000000..0690164ae5 --- /dev/null +++ b/openpype/hosts/testhost/plugins/create/auto_creator.py @@ -0,0 +1,74 @@ +from openpype.hosts.testhost.api import pipeline +from openpype.pipeline import ( + AutoCreator, + CreatedInstance, + lib +) +from avalon import io + + +class MyAutoCreator(AutoCreator): + identifier = "workfile" + family = "workfile" + + def get_attribute_defs(self): + output = [ + lib.NumberDef("number_key", label="Number") + ] + return output + + def collect_instances(self): + for instance_data in pipeline.list_instances(): + creator_id = instance_data.get("creator_identifier") + if creator_id == self.identifier: + subset_name = instance_data["subset"] + instance = CreatedInstance( + self.family, subset_name, instance_data, self + ) + self._add_instance_to_context(instance) + + def update_instances(self, update_list): + pipeline.update_instances(update_list) + + def create(self, options=None): + existing_instance = None + for instance in self.create_context.instances: + if instance.family == self.family: + existing_instance = instance + break + + variant = "Main" + project_name = io.Session["AVALON_PROJECT"] + asset_name = io.Session["AVALON_ASSET"] + task_name = io.Session["AVALON_TASK"] + host_name = io.Session["AVALON_APP"] + + if existing_instance is None: + asset_doc = io.find_one({"type": "asset", "name": asset_name}) + subset_name = self.get_subset_name( + variant, task_name, asset_doc, project_name, host_name + ) + data = { + "asset": asset_name, + "task": task_name, + "variant": variant + } + data.update(self.get_dynamic_data( + variant, task_name, asset_doc, project_name, host_name + )) + + new_instance = CreatedInstance( + self.family, subset_name, data, self + ) + self._add_instance_to_context(new_instance) + + elif ( + existing_instance["asset"] != asset_name + or existing_instance["task"] != task_name + ): + asset_doc = io.find_one({"type": "asset", "name": asset_name}) + subset_name = self.get_subset_name( + variant, task_name, asset_doc, project_name, host_name + ) + existing_instance["asset"] = asset_name + existing_instance["task"] = task_name diff --git a/openpype/hosts/testhost/plugins/create/test_creator_1.py b/openpype/hosts/testhost/plugins/create/test_creator_1.py new file mode 100644 index 0000000000..6ec4d16467 --- /dev/null +++ b/openpype/hosts/testhost/plugins/create/test_creator_1.py @@ -0,0 +1,70 @@ +from openpype import resources +from openpype.hosts.testhost.api import pipeline +from openpype.pipeline import ( + Creator, + CreatedInstance, + lib +) + + +class TestCreatorOne(Creator): + identifier = "test_one" + label = "test" + family = "test" + description = "Testing creator of testhost" + + def get_icon(self): + return resources.get_openpype_splash_filepath() + + def collect_instances(self): + for instance_data in pipeline.list_instances(): + creator_id = instance_data.get("creator_identifier") + if creator_id == self.identifier: + instance = CreatedInstance.from_existing( + instance_data, self + ) + self._add_instance_to_context(instance) + + def update_instances(self, update_list): + pipeline.update_instances(update_list) + + def remove_instances(self, instances): + pipeline.remove_instances(instances) + for instance in instances: + self._remove_instance_from_context(instance) + + def create(self, subset_name, data, options=None): + new_instance = CreatedInstance(self.family, subset_name, data, self) + pipeline.HostContext.add_instance(new_instance.data_to_store()) + self.log.info(new_instance.data) + self._add_instance_to_context(new_instance) + + def get_default_variants(self): + return [ + "myVariant", + "variantTwo", + "different_variant" + ] + + def get_attribute_defs(self): + output = [ + lib.NumberDef("number_key", label="Number") + ] + return output + + def get_detail_description(self): + return """# Relictus funes est Nyseides currusque nunc oblita + +## Causa sed + +Lorem markdownum posito consumptis, *plebe Amorque*, abstitimus rogatus fictaque +gladium Circe, nos? Bos aeternum quae. Utque me, si aliquem cladis, et vestigia +arbor, sic mea ferre lacrimae agantur prospiciens hactenus. Amanti dentes pete, +vos quid laudemque rastrorumque terras in gratantibus **radix** erat cedemus? + +Pudor tu ponderibus verbaque illa; ire ergo iam Venus patris certe longae +cruentum lecta, et quaeque. Sit doce nox. Anteit ad tempora magni plenaque et +videres mersit sibique auctor in tendunt mittit cunctos ventisque gravitate +volucris quemquam Aeneaden. Pectore Mensis somnus; pectora +[ferunt](http://www.mox.org/oculosbracchia)? Fertilitatis bella dulce et suum? + """ diff --git a/openpype/hosts/testhost/plugins/create/test_creator_2.py b/openpype/hosts/testhost/plugins/create/test_creator_2.py new file mode 100644 index 0000000000..4b1430a6a2 --- /dev/null +++ b/openpype/hosts/testhost/plugins/create/test_creator_2.py @@ -0,0 +1,74 @@ +from openpype.hosts.testhost.api import pipeline +from openpype.pipeline import ( + Creator, + CreatedInstance, + lib +) + + +class TestCreatorTwo(Creator): + identifier = "test_two" + label = "test" + family = "test" + description = "A second testing creator" + + def get_icon(self): + return "cube" + + def create(self, subset_name, data, options=None): + new_instance = CreatedInstance(self.family, subset_name, data, self) + pipeline.HostContext.add_instance(new_instance.data_to_store()) + self.log.info(new_instance.data) + self._add_instance_to_context(new_instance) + + def collect_instances(self): + for instance_data in pipeline.list_instances(): + creator_id = instance_data.get("creator_identifier") + if creator_id == self.identifier: + instance = CreatedInstance.from_existing( + instance_data, self + ) + self._add_instance_to_context(instance) + + def update_instances(self, update_list): + pipeline.update_instances(update_list) + + def remove_instances(self, instances): + pipeline.remove_instances(instances) + for instance in instances: + self._remove_instance_from_context(instance) + + def get_attribute_defs(self): + output = [ + lib.NumberDef("number_key"), + lib.TextDef("text_key") + ] + return output + + def get_detail_description(self): + return """# Lorem ipsum, dolor sit amet. [![Awesome](https://cdn.rawgit.com/sindresorhus/awesome/d7305f38d29fed78fa85652e3a63e154dd8e8829/media/badge.svg)](https://github.com/sindresorhus/awesome) + +> A curated list of awesome lorem ipsum generators. + +Inspired by the [awesome](https://github.com/sindresorhus/awesome) list thing. + + +## Table of Contents + +- [Legend](#legend) +- [Practical](#briefcase-practical) +- [Whimsical](#roller_coaster-whimsical) + - [Animals](#rabbit-animals) + - [Eras](#tophat-eras) + - [Famous Individuals](#sunglasses-famous-individuals) + - [Music](#microphone-music) + - [Food and Drink](#pizza-food-and-drink) + - [Geographic and Dialects](#earth_africa-geographic-and-dialects) + - [Literature](#books-literature) + - [Miscellaneous](#cyclone-miscellaneous) + - [Sports and Fitness](#bicyclist-sports-and-fitness) + - [TV and Film](#movie_camera-tv-and-film) +- [Tools, Apps, and Extensions](#wrench-tools-apps-and-extensions) +- [Contribute](#contribute) +- [TODO](#todo) +""" diff --git a/openpype/hosts/testhost/plugins/publish/collect_context.py b/openpype/hosts/testhost/plugins/publish/collect_context.py new file mode 100644 index 0000000000..0ab98fb84b --- /dev/null +++ b/openpype/hosts/testhost/plugins/publish/collect_context.py @@ -0,0 +1,34 @@ +import pyblish.api + +from openpype.pipeline import ( + OpenPypePyblishPluginMixin, + attribute_definitions +) + + +class CollectContextDataTestHost( + pyblish.api.ContextPlugin, OpenPypePyblishPluginMixin +): + """ + Collecting temp json data sent from a host context + and path for returning json data back to hostself. + """ + + label = "Collect Source - Test Host" + order = pyblish.api.CollectorOrder - 0.4 + hosts = ["testhost"] + + @classmethod + def get_attribute_defs(cls): + return [ + attribute_definitions.BoolDef( + "test_bool", + True, + label="Bool input" + ) + ] + + def process(self, context): + # get json paths from os and load them + for instance in context: + instance.data["source"] = "testhost" diff --git a/openpype/hosts/testhost/plugins/publish/collect_instance_1.py b/openpype/hosts/testhost/plugins/publish/collect_instance_1.py new file mode 100644 index 0000000000..3c035eccb6 --- /dev/null +++ b/openpype/hosts/testhost/plugins/publish/collect_instance_1.py @@ -0,0 +1,54 @@ +import json +import pyblish.api + +from openpype.pipeline import ( + OpenPypePyblishPluginMixin, + attribute_definitions +) + + +class CollectInstanceOneTestHost( + pyblish.api.InstancePlugin, OpenPypePyblishPluginMixin +): + """ + Collecting temp json data sent from a host context + and path for returning json data back to hostself. + """ + + label = "Collect Instance 1 - Test Host" + order = pyblish.api.CollectorOrder - 0.3 + hosts = ["testhost"] + + @classmethod + def get_attribute_defs(cls): + return [ + attribute_definitions.NumberDef( + "version", + default=1, + minimum=1, + maximum=999, + decimals=0, + label="Version" + ) + ] + + def process(self, instance): + self._debug_log(instance) + + publish_attributes = instance.data.get("publish_attributes") + if not publish_attributes: + return + + values = publish_attributes.get(self.__class__.__name__) + if not values: + return + + instance.data["version"] = values["version"] + + def _debug_log(self, instance): + def _default_json(value): + return str(value) + + self.log.info( + json.dumps(instance.data, indent=4, default=_default_json) + ) diff --git a/openpype/hosts/testhost/plugins/publish/validate_context_with_error.py b/openpype/hosts/testhost/plugins/publish/validate_context_with_error.py new file mode 100644 index 0000000000..46e996a569 --- /dev/null +++ b/openpype/hosts/testhost/plugins/publish/validate_context_with_error.py @@ -0,0 +1,57 @@ +import pyblish.api +from openpype.pipeline import PublishValidationError + + +class ValidateInstanceAssetRepair(pyblish.api.Action): + """Repair the instance asset.""" + + label = "Repair" + icon = "wrench" + on = "failed" + + def process(self, context, plugin): + pass + + +description = """ +## Publish plugins + +### Validate Scene Settings + +#### Skip Resolution Check for Tasks + +Set regex pattern(s) to look for in a Task name to skip resolution check against values from DB. + +#### Skip Timeline Check for Tasks + +Set regex pattern(s) to look for in a Task name to skip `frameStart`, `frameEnd` check against values from DB. + +### AfterEffects Submit to Deadline + +* `Use Published scene` - Set to True (green) when Deadline should take published scene as a source instead of uploaded local one. +* `Priority` - priority of job on farm +* `Primary Pool` - here is list of pool fetched from server you can select from. +* `Secondary Pool` +* `Frames Per Task` - number of sequence division between individual tasks (chunks) +making one job on farm. +""" + + +class ValidateContextWithError(pyblish.api.ContextPlugin): + """Validate the instance asset is the current selected context asset. + + As it might happen that multiple worfiles are opened, switching + between them would mess with selected context. + In that case outputs might be output under wrong asset! + + Repair action will use Context asset value (from Workfiles or Launcher) + Closing and reopening with Workfiles will refresh Context value. + """ + + label = "Validate Context With Error" + hosts = ["testhost"] + actions = [ValidateInstanceAssetRepair] + order = pyblish.api.ValidatorOrder + + def process(self, context): + raise PublishValidationError("Crashing", "Context error", description) diff --git a/openpype/hosts/testhost/plugins/publish/validate_with_error.py b/openpype/hosts/testhost/plugins/publish/validate_with_error.py new file mode 100644 index 0000000000..5a2888a8b0 --- /dev/null +++ b/openpype/hosts/testhost/plugins/publish/validate_with_error.py @@ -0,0 +1,57 @@ +import pyblish.api +from openpype.pipeline import PublishValidationError + + +class ValidateInstanceAssetRepair(pyblish.api.Action): + """Repair the instance asset.""" + + label = "Repair" + icon = "wrench" + on = "failed" + + def process(self, context, plugin): + pass + + +description = """ +## Publish plugins + +### Validate Scene Settings + +#### Skip Resolution Check for Tasks + +Set regex pattern(s) to look for in a Task name to skip resolution check against values from DB. + +#### Skip Timeline Check for Tasks + +Set regex pattern(s) to look for in a Task name to skip `frameStart`, `frameEnd` check against values from DB. + +### AfterEffects Submit to Deadline + +* `Use Published scene` - Set to True (green) when Deadline should take published scene as a source instead of uploaded local one. +* `Priority` - priority of job on farm +* `Primary Pool` - here is list of pool fetched from server you can select from. +* `Secondary Pool` +* `Frames Per Task` - number of sequence division between individual tasks (chunks) +making one job on farm. +""" + + +class ValidateWithError(pyblish.api.InstancePlugin): + """Validate the instance asset is the current selected context asset. + + As it might happen that multiple worfiles are opened, switching + between them would mess with selected context. + In that case outputs might be output under wrong asset! + + Repair action will use Context asset value (from Workfiles or Launcher) + Closing and reopening with Workfiles will refresh Context value. + """ + + label = "Validate With Error" + hosts = ["testhost"] + actions = [ValidateInstanceAssetRepair] + order = pyblish.api.ValidatorOrder + + def process(self, instance): + raise PublishValidationError("Crashing", "Instance error", description) diff --git a/openpype/hosts/testhost/run_publish.py b/openpype/hosts/testhost/run_publish.py new file mode 100644 index 0000000000..44860a30e4 --- /dev/null +++ b/openpype/hosts/testhost/run_publish.py @@ -0,0 +1,70 @@ +import os +import sys + +mongo_url = "" +project_name = "" +asset_name = "" +task_name = "" +ftrack_url = "" +ftrack_username = "" +ftrack_api_key = "" + + +def multi_dirname(path, times=1): + for _ in range(times): + path = os.path.dirname(path) + return path + + +host_name = "testhost" +current_file = os.path.abspath(__file__) +openpype_dir = multi_dirname(current_file, 4) + +os.environ["OPENPYPE_MONGO"] = mongo_url +os.environ["OPENPYPE_ROOT"] = openpype_dir +os.environ["AVALON_MONGO"] = mongo_url +os.environ["AVALON_PROJECT"] = project_name +os.environ["AVALON_ASSET"] = asset_name +os.environ["AVALON_TASK"] = task_name +os.environ["AVALON_APP"] = host_name +os.environ["OPENPYPE_DATABASE_NAME"] = "openpype" +os.environ["AVALON_CONFIG"] = "openpype" +os.environ["AVALON_TIMEOUT"] = "1000" +os.environ["AVALON_DB"] = "avalon" +os.environ["FTRACK_SERVER"] = ftrack_url +os.environ["FTRACK_API_USER"] = ftrack_username +os.environ["FTRACK_API_KEY"] = ftrack_api_key +for path in [ + openpype_dir, + r"{}\repos\avalon-core".format(openpype_dir), + r"{}\.venv\Lib\site-packages".format(openpype_dir) +]: + sys.path.append(path) + +from Qt import QtWidgets, QtCore + +from openpype.tools.publisher.window import PublisherWindow + + +def main(): + """Main function for testing purposes.""" + import avalon.api + import pyblish.api + from openpype.modules import ModulesManager + from openpype.hosts.testhost import api as testhost + + manager = ModulesManager() + for plugin_path in manager.collect_plugin_paths()["publish"]: + pyblish.api.register_plugin_path(plugin_path) + + avalon.api.install(testhost) + + QtWidgets.QApplication.setAttribute(QtCore.Qt.AA_EnableHighDpiScaling) + app = QtWidgets.QApplication([]) + window = PublisherWindow() + window.show() + app.exec_() + + +if __name__ == "__main__": + main() diff --git a/openpype/hosts/tvpaint/plugins/publish/collect_instances.py b/openpype/hosts/tvpaint/plugins/publish/collect_instances.py index dfa8f17ee9..1d7a48e389 100644 --- a/openpype/hosts/tvpaint/plugins/publish/collect_instances.py +++ b/openpype/hosts/tvpaint/plugins/publish/collect_instances.py @@ -4,7 +4,7 @@ import copy import pyblish.api from avalon import io -from openpype.lib import get_subset_name +from openpype.lib import get_subset_name_with_asset_doc class CollectInstances(pyblish.api.ContextPlugin): @@ -70,16 +70,10 @@ class CollectInstances(pyblish.api.ContextPlugin): # - not sure if it's good idea to require asset id in # get_subset_name? asset_name = context.data["workfile_context"]["asset"] - asset_doc = io.find_one( - { - "type": "asset", - "name": asset_name - }, - {"_id": 1} - ) - asset_id = None - if asset_doc: - asset_id = asset_doc["_id"] + asset_doc = io.find_one({ + "type": "asset", + "name": asset_name + }) # Project name from workfile context project_name = context.data["workfile_context"]["project"] @@ -88,11 +82,11 @@ class CollectInstances(pyblish.api.ContextPlugin): # Use empty variant value variant = "" task_name = io.Session["AVALON_TASK"] - new_subset_name = get_subset_name( + new_subset_name = get_subset_name_with_asset_doc( family, variant, task_name, - asset_id, + asset_doc, project_name, host_name ) diff --git a/openpype/hosts/tvpaint/plugins/publish/collect_workfile.py b/openpype/hosts/tvpaint/plugins/publish/collect_workfile.py index 65e38ea258..68ba350a85 100644 --- a/openpype/hosts/tvpaint/plugins/publish/collect_workfile.py +++ b/openpype/hosts/tvpaint/plugins/publish/collect_workfile.py @@ -3,7 +3,7 @@ import json import pyblish.api from avalon import io -from openpype.lib import get_subset_name +from openpype.lib import get_subset_name_with_asset_doc class CollectWorkfile(pyblish.api.ContextPlugin): @@ -28,16 +28,10 @@ class CollectWorkfile(pyblish.api.ContextPlugin): # get_subset_name? family = "workfile" asset_name = context.data["workfile_context"]["asset"] - asset_doc = io.find_one( - { - "type": "asset", - "name": asset_name - }, - {"_id": 1} - ) - asset_id = None - if asset_doc: - asset_id = asset_doc["_id"] + asset_doc = io.find_one({ + "type": "asset", + "name": asset_name + }) # Project name from workfile context project_name = context.data["workfile_context"]["project"] @@ -46,11 +40,11 @@ class CollectWorkfile(pyblish.api.ContextPlugin): # Use empty variant value variant = "" task_name = io.Session["AVALON_TASK"] - subset_name = get_subset_name( + subset_name = get_subset_name_with_asset_doc( family, variant, task_name, - asset_id, + asset_doc, project_name, host_name ) diff --git a/openpype/hosts/unreal/api/lib.py b/openpype/hosts/unreal/api/lib.py index 7e34c3ff15..c0fafbb667 100644 --- a/openpype/hosts/unreal/api/lib.py +++ b/openpype/hosts/unreal/api/lib.py @@ -253,6 +253,7 @@ def create_unreal_project(project_name: str, "Plugins": [ {"Name": "PythonScriptPlugin", "Enabled": True}, {"Name": "EditorScriptingUtilities", "Enabled": True}, + {"Name": "SequencerScripting", "Enabled": True}, {"Name": "Avalon", "Enabled": True} ] } diff --git a/openpype/hosts/unreal/hooks/pre_workfile_preparation.py b/openpype/hosts/unreal/hooks/pre_workfile_preparation.py index 01b8b6bc05..880dba5cfb 100644 --- a/openpype/hosts/unreal/hooks/pre_workfile_preparation.py +++ b/openpype/hosts/unreal/hooks/pre_workfile_preparation.py @@ -6,7 +6,9 @@ from pathlib import Path from openpype.lib import ( PreLaunchHook, ApplicationLaunchFailed, - ApplicationNotFound + ApplicationNotFound, + get_workdir_data, + get_workfile_template_key ) from openpype.hosts.unreal.api import lib as unreal_lib @@ -25,13 +27,46 @@ class UnrealPrelaunchHook(PreLaunchHook): self.signature = "( {} )".format(self.__class__.__name__) + def _get_work_filename(self): + # Use last workfile if was found + if self.data.get("last_workfile_path"): + last_workfile = Path(self.data.get("last_workfile_path")) + if last_workfile and last_workfile.exists(): + return last_workfile.name + + # Prepare data for fill data and for getting workfile template key + task_name = self.data["task_name"] + anatomy = self.data["anatomy"] + asset_doc = self.data["asset_doc"] + project_doc = self.data["project_doc"] + + asset_tasks = asset_doc.get("data", {}).get("tasks") or {} + task_info = asset_tasks.get(task_name) or {} + task_type = task_info.get("type") + + workdir_data = get_workdir_data( + project_doc, asset_doc, task_name, self.host_name + ) + # QUESTION raise exception if version is part of filename template? + workdir_data["version"] = 1 + workdir_data["ext"] = "uproject" + + # Get workfile template key for current context + workfile_template_key = get_workfile_template_key( + task_type, + self.host_name, + project_name=project_doc["name"] + ) + # Fill templates + filled_anatomy = anatomy.format(workdir_data) + + # Return filename + return filled_anatomy[workfile_template_key]["file"] + def execute(self): """Hook entry method.""" - asset_name = self.data["asset_name"] - task_name = self.data["task_name"] workdir = self.launch_context.env["AVALON_WORKDIR"] engine_version = self.app_name.split("/")[-1].replace("-", ".") - unreal_project_name = f"{asset_name}_{task_name}" try: if int(engine_version.split(".")[0]) < 4 and \ int(engine_version.split(".")[1]) < 26: @@ -45,6 +80,8 @@ class UnrealPrelaunchHook(PreLaunchHook): # so lets keep it quite. ... + unreal_project_filename = self._get_work_filename() + unreal_project_name = os.path.splitext(unreal_project_filename)[0] # Unreal is sensitive about project names longer then 20 chars if len(unreal_project_name) > 20: self.log.warning(( @@ -89,10 +126,10 @@ class UnrealPrelaunchHook(PreLaunchHook): ue4_path = unreal_lib.get_editor_executable_path( Path(detected[engine_version])) - self.launch_context.launch_args.append(ue4_path.as_posix()) + self.launch_context.launch_args = [ue4_path.as_posix()] project_path.mkdir(parents=True, exist_ok=True) - project_file = project_path / f"{unreal_project_name}.uproject" + project_file = project_path / unreal_project_filename if not project_file.is_file(): engine_path = detected[engine_version] self.log.info(( diff --git a/openpype/hosts/unreal/plugins/create/create_camera.py b/openpype/hosts/unreal/plugins/create/create_camera.py new file mode 100644 index 0000000000..eda2b52be3 --- /dev/null +++ b/openpype/hosts/unreal/plugins/create/create_camera.py @@ -0,0 +1,43 @@ +import unreal +from unreal import EditorAssetLibrary as eal +from unreal import EditorLevelLibrary as ell + +from openpype.hosts.unreal.api.plugin import Creator +from avalon.unreal import ( + instantiate, +) + + +class CreateCamera(Creator): + """Layout output for character rigs""" + + name = "layoutMain" + label = "Camera" + family = "camera" + icon = "cubes" + + root = "/Game/Avalon/Instances" + suffix = "_INS" + + def __init__(self, *args, **kwargs): + super(CreateCamera, self).__init__(*args, **kwargs) + + def process(self): + data = self.data + + name = data["subset"] + + data["level"] = ell.get_editor_world().get_path_name() + + if not eal.does_directory_exist(self.root): + eal.make_directory(self.root) + + factory = unreal.LevelSequenceFactoryNew() + tools = unreal.AssetToolsHelpers().get_asset_tools() + tools.create_asset(name, f"{self.root}/{name}", None, factory) + + asset_name = f"{self.root}/{name}/{name}.{name}" + + data["members"] = [asset_name] + + instantiate(f"{self.root}", name, data, None, self.suffix) diff --git a/openpype/hosts/unreal/plugins/load/load_camera.py b/openpype/hosts/unreal/plugins/load/load_camera.py new file mode 100644 index 0000000000..b2b25eec73 --- /dev/null +++ b/openpype/hosts/unreal/plugins/load/load_camera.py @@ -0,0 +1,206 @@ +import os + +from avalon import api, io, pipeline +from avalon.unreal import lib +from avalon.unreal import pipeline as unreal_pipeline +import unreal + + +class CameraLoader(api.Loader): + """Load Unreal StaticMesh from FBX""" + + families = ["camera"] + label = "Load Camera" + representations = ["fbx"] + icon = "cube" + color = "orange" + + def load(self, context, name, namespace, data): + """ + Load and containerise representation into Content Browser. + + This is two step process. First, import FBX to temporary path and + then call `containerise()` on it - this moves all content to new + directory and then it will create AssetContainer there and imprint it + with metadata. This will mark this path as container. + + Args: + context (dict): application context + name (str): subset name + namespace (str): in Unreal this is basically path to container. + This is not passed here, so namespace is set + by `containerise()` because only then we know + real path. + data (dict): Those would be data to be imprinted. This is not used + now, data are imprinted by `containerise()`. + + Returns: + list(str): list of container content + """ + + # Create directory for asset and avalon container + root = "/Game/Avalon/Assets" + asset = context.get('asset').get('name') + suffix = "_CON" + if asset: + asset_name = "{}_{}".format(asset, name) + else: + asset_name = "{}".format(name) + + tools = unreal.AssetToolsHelpers().get_asset_tools() + + unique_number = 1 + + if unreal.EditorAssetLibrary.does_directory_exist(f"{root}/{asset}"): + asset_content = unreal.EditorAssetLibrary.list_assets( + f"{root}/{asset}", recursive=False, include_folder=True + ) + + # Get highest number to make a unique name + folders = [a for a in asset_content + if a[-1] == "/" and f"{name}_" in a] + f_numbers = [] + for f in folders: + # Get number from folder name. Splits the string by "_" and + # removes the last element (which is a "/"). + f_numbers.append(int(f.split("_")[-1][:-1])) + f_numbers.sort() + if not f_numbers: + unique_number = 1 + else: + unique_number = f_numbers[-1] + 1 + + asset_dir, container_name = tools.create_unique_asset_name( + f"{root}/{asset}/{name}_{unique_number:02d}", suffix="") + + container_name += suffix + + unreal.EditorAssetLibrary.make_directory(asset_dir) + + sequence = tools.create_asset( + asset_name=asset_name, + package_path=asset_dir, + asset_class=unreal.LevelSequence, + factory=unreal.LevelSequenceFactoryNew() + ) + + io_asset = io.Session["AVALON_ASSET"] + asset_doc = io.find_one({ + "type": "asset", + "name": io_asset + }) + + data = asset_doc.get("data") + + if data: + sequence.set_display_rate(unreal.FrameRate(data.get("fps"), 1.0)) + sequence.set_playback_start(data.get("frameStart")) + sequence.set_playback_end(data.get("frameEnd")) + + settings = unreal.MovieSceneUserImportFBXSettings() + settings.set_editor_property('reduce_keys', False) + + unreal.SequencerTools.import_fbx( + unreal.EditorLevelLibrary.get_editor_world(), + sequence, + sequence.get_bindings(), + settings, + self.fname + ) + + # Create Asset Container + lib.create_avalon_container(container=container_name, path=asset_dir) + + data = { + "schema": "openpype:container-2.0", + "id": pipeline.AVALON_CONTAINER_ID, + "asset": asset, + "namespace": asset_dir, + "container_name": container_name, + "asset_name": asset_name, + "loader": str(self.__class__.__name__), + "representation": context["representation"]["_id"], + "parent": context["representation"]["parent"], + "family": context["representation"]["context"]["family"] + } + unreal_pipeline.imprint( + "{}/{}".format(asset_dir, container_name), data) + + asset_content = unreal.EditorAssetLibrary.list_assets( + asset_dir, recursive=True, include_folder=True + ) + + for a in asset_content: + unreal.EditorAssetLibrary.save_asset(a) + + return asset_content + + def update(self, container, representation): + path = container["namespace"] + + ar = unreal.AssetRegistryHelpers.get_asset_registry() + tools = unreal.AssetToolsHelpers().get_asset_tools() + + asset_content = unreal.EditorAssetLibrary.list_assets( + path, recursive=False, include_folder=False + ) + asset_name = "" + for a in asset_content: + asset = ar.get_asset_by_object_path(a) + if a.endswith("_CON"): + loaded_asset = unreal.EditorAssetLibrary.load_asset(a) + unreal.EditorAssetLibrary.set_metadata_tag( + loaded_asset, "representation", str(representation["_id"]) + ) + unreal.EditorAssetLibrary.set_metadata_tag( + loaded_asset, "parent", str(representation["parent"]) + ) + asset_name = unreal.EditorAssetLibrary.get_metadata_tag( + loaded_asset, "asset_name" + ) + elif asset.asset_class == "LevelSequence": + unreal.EditorAssetLibrary.delete_asset(a) + + sequence = tools.create_asset( + asset_name=asset_name, + package_path=path, + asset_class=unreal.LevelSequence, + factory=unreal.LevelSequenceFactoryNew() + ) + + io_asset = io.Session["AVALON_ASSET"] + asset_doc = io.find_one({ + "type": "asset", + "name": io_asset + }) + + data = asset_doc.get("data") + + if data: + sequence.set_display_rate(unreal.FrameRate(data.get("fps"), 1.0)) + sequence.set_playback_start(data.get("frameStart")) + sequence.set_playback_end(data.get("frameEnd")) + + settings = unreal.MovieSceneUserImportFBXSettings() + settings.set_editor_property('reduce_keys', False) + + unreal.SequencerTools.import_fbx( + unreal.EditorLevelLibrary.get_editor_world(), + sequence, + sequence.get_bindings(), + settings, + str(representation["data"]["path"]) + ) + + def remove(self, container): + path = container["namespace"] + parent_path = os.path.dirname(path) + + unreal.EditorAssetLibrary.delete_directory(path) + + asset_content = unreal.EditorAssetLibrary.list_assets( + parent_path, recursive=False, include_folder=True + ) + + if len(asset_content) == 0: + unreal.EditorAssetLibrary.delete_directory(parent_path) diff --git a/openpype/hosts/unreal/plugins/publish/extract_camera.py b/openpype/hosts/unreal/plugins/publish/extract_camera.py new file mode 100644 index 0000000000..10862fc0ef --- /dev/null +++ b/openpype/hosts/unreal/plugins/publish/extract_camera.py @@ -0,0 +1,54 @@ +import os + +import unreal +from unreal import EditorAssetLibrary as eal +from unreal import EditorLevelLibrary as ell + +import openpype.api + + +class ExtractCamera(openpype.api.Extractor): + """Extract a camera.""" + + label = "Extract Camera" + hosts = ["unreal"] + families = ["camera"] + optional = True + + def process(self, instance): + # Define extract output file path + stagingdir = self.staging_dir(instance) + fbx_filename = "{}.fbx".format(instance.name) + + # Perform extraction + self.log.info("Performing extraction..") + + # Check if the loaded level is the same of the instance + current_level = ell.get_editor_world().get_path_name() + assert current_level == instance.data.get("level"), \ + "Wrong level loaded" + + for member in instance[:]: + data = eal.find_asset_data(member) + if data.asset_class == "LevelSequence": + ar = unreal.AssetRegistryHelpers.get_asset_registry() + sequence = ar.get_asset_by_object_path(member).get_asset() + unreal.SequencerTools.export_fbx( + ell.get_editor_world(), + sequence, + sequence.get_bindings(), + unreal.FbxExportOption(), + os.path.join(stagingdir, fbx_filename) + ) + break + + if "representations" not in instance.data: + instance.data["representations"] = [] + + fbx_representation = { + 'name': 'fbx', + 'ext': 'fbx', + 'files': fbx_filename, + "stagingDir": stagingdir, + } + instance.data["representations"].append(fbx_representation) diff --git a/openpype/hosts/webpublisher/plugins/publish/collect_batch_data.py b/openpype/hosts/webpublisher/plugins/publish/collect_batch_data.py new file mode 100644 index 0000000000..a710fcb3e8 --- /dev/null +++ b/openpype/hosts/webpublisher/plugins/publish/collect_batch_data.py @@ -0,0 +1,84 @@ +"""Loads batch context from json and continues in publish process. + +Provides: + context -> Loaded batch file. +""" + +import os + +import pyblish.api +from avalon import io +from openpype.lib.plugin_tools import ( + parse_json, + get_batch_asset_task_info +) +from openpype.lib.remote_publish import get_webpublish_conn + + +class CollectBatchData(pyblish.api.ContextPlugin): + """Collect batch data from json stored in 'OPENPYPE_PUBLISH_DATA' env dir. + + The directory must contain 'manifest.json' file where batch data should be + stored. + """ + # must be really early, context values are only in json file + order = pyblish.api.CollectorOrder - 0.495 + label = "Collect batch data" + host = ["webpublisher"] + + def process(self, context): + batch_dir = os.environ.get("OPENPYPE_PUBLISH_DATA") + + assert batch_dir, ( + "Missing `OPENPYPE_PUBLISH_DATA`") + + assert os.path.exists(batch_dir), \ + "Folder {} doesn't exist".format(batch_dir) + + project_name = os.environ.get("AVALON_PROJECT") + if project_name is None: + raise AssertionError( + "Environment `AVALON_PROJECT` was not found." + "Could not set project `root` which may cause issues." + ) + + batch_data = parse_json(os.path.join(batch_dir, "manifest.json")) + + context.data["batchDir"] = batch_dir + context.data["batchData"] = batch_data + + asset_name, task_name, task_type = get_batch_asset_task_info( + batch_data["context"] + ) + + os.environ["AVALON_ASSET"] = asset_name + io.Session["AVALON_ASSET"] = asset_name + os.environ["AVALON_TASK"] = task_name + io.Session["AVALON_TASK"] = task_name + + context.data["asset"] = asset_name + context.data["task"] = task_name + context.data["taskType"] = task_type + + self._set_ctx_path(batch_data) + + def _set_ctx_path(self, batch_data): + dbcon = get_webpublish_conn() + + batch_id = batch_data["batch"] + ctx_path = batch_data["context"]["path"] + self.log.info("ctx_path: {}".format(ctx_path)) + self.log.info("batch_id: {}".format(batch_id)) + if ctx_path and batch_id: + self.log.info("Updating log record") + dbcon.update_one( + { + "batch_id": batch_id, + "status": "in_progress" + }, + { + "$set": { + "path": ctx_path + } + } + ) diff --git a/openpype/hosts/webpublisher/plugins/publish/collect_fps.py b/openpype/hosts/webpublisher/plugins/publish/collect_fps.py index 79fe53176a..b5e665c761 100644 --- a/openpype/hosts/webpublisher/plugins/publish/collect_fps.py +++ b/openpype/hosts/webpublisher/plugins/publish/collect_fps.py @@ -20,9 +20,8 @@ class CollectFPS(pyblish.api.InstancePlugin): hosts = ["webpublisher"] def process(self, instance): - fps = instance.context.data["fps"] + instance_fps = instance.data.get("fps") + if instance_fps is None: + instance.data["fps"] = instance.context.data["fps"] - instance.data.update({ - "fps": fps - }) self.log.debug(f"instance.data: {pformat(instance.data)}") diff --git a/openpype/hosts/webpublisher/plugins/publish/collect_published_files.py b/openpype/hosts/webpublisher/plugins/publish/collect_published_files.py index 7e9b98956a..d2754b3df3 100644 --- a/openpype/hosts/webpublisher/plugins/publish/collect_published_files.py +++ b/openpype/hosts/webpublisher/plugins/publish/collect_published_files.py @@ -1,20 +1,19 @@ -"""Loads publishing context from json and continues in publish process. +"""Create instances from batch data and continues in publish process. Requires: - anatomy -> context["anatomy"] *(pyblish.api.CollectorOrder - 0.11) + CollectBatchData Provides: context, instances -> All data from previous publishing process. """ import os -import json import clique import tempfile - -import pyblish.api from avalon import io +import pyblish.api from openpype.lib import prepare_template_data +from openpype.lib.plugin_tools import parse_json class CollectPublishedFiles(pyblish.api.ContextPlugin): @@ -27,51 +26,28 @@ class CollectPublishedFiles(pyblish.api.ContextPlugin): order = pyblish.api.CollectorOrder - 0.490 label = "Collect rendered frames" host = ["webpublisher"] - - _context = None + targets = ["filespublish"] # from Settings task_type_to_family = {} - def _load_json(self, path): - path = path.strip('\"') - assert os.path.isfile(path), ( - "Path to json file doesn't exist. \"{}\"".format(path) - ) - data = None - with open(path, "r") as json_file: - try: - data = json.load(json_file) - except Exception as exc: - self.log.error( - "Error loading json: " - "{} - Exception: {}".format(path, exc) - ) - return data + def process(self, context): + batch_dir = context.data["batchDir"] + task_subfolders = [] + for folder_name in os.listdir(batch_dir): + full_path = os.path.join(batch_dir, folder_name) + if os.path.isdir(full_path): + task_subfolders.append(full_path) - def _process_batch(self, dir_url): - task_subfolders = [ - os.path.join(dir_url, o) - for o in os.listdir(dir_url) - if os.path.isdir(os.path.join(dir_url, o))] self.log.info("task_sub:: {}".format(task_subfolders)) - for task_dir in task_subfolders: - task_data = self._load_json(os.path.join(task_dir, - "manifest.json")) - self.log.info("task_data:: {}".format(task_data)) - ctx = task_data["context"] - task_type = "default_task_type" - task_name = None - if ctx["type"] == "task": - items = ctx["path"].split('/') - asset = items[-2] - os.environ["AVALON_TASK"] = ctx["name"] - task_name = ctx["name"] - task_type = ctx["attributes"]["type"] - else: - asset = ctx["name"] - os.environ["AVALON_TASK"] = "" + asset_name = context.data["asset"] + task_name = context.data["task"] + task_type = context.data["taskType"] + for task_dir in task_subfolders: + task_data = parse_json(os.path.join(task_dir, + "manifest.json")) + self.log.info("task_data:: {}".format(task_data)) is_sequence = len(task_data["files"]) > 1 @@ -82,26 +58,20 @@ class CollectPublishedFiles(pyblish.api.ContextPlugin): is_sequence, extension.replace(".", '')) - subset = self._get_subset_name(family, subset_template, task_name, - task_data["variant"]) + subset = self._get_subset_name( + family, subset_template, task_name, task_data["variant"] + ) + version = self._get_last_version(asset_name, subset) + 1 - os.environ["AVALON_ASSET"] = asset - io.Session["AVALON_ASSET"] = asset - - instance = self._context.create_instance(subset) - instance.data["asset"] = asset + instance = context.create_instance(subset) + instance.data["asset"] = asset_name instance.data["subset"] = subset instance.data["family"] = family instance.data["families"] = families - instance.data["version"] = \ - self._get_last_version(asset, subset) + 1 + instance.data["version"] = version instance.data["stagingDir"] = tempfile.mkdtemp() instance.data["source"] = "webpublisher" - # to store logging info into DB openpype.webpublishes - instance.data["ctx_path"] = ctx["path"] - instance.data["batch_id"] = task_data["batch"] - # to convert from email provided into Ftrack username instance.data["user_email"] = task_data["user"] @@ -252,23 +222,3 @@ class CollectPublishedFiles(pyblish.api.ContextPlugin): return version[0].get("version") or 0 else: return 0 - - def process(self, context): - self._context = context - - batch_dir = os.environ.get("OPENPYPE_PUBLISH_DATA") - - assert batch_dir, ( - "Missing `OPENPYPE_PUBLISH_DATA`") - - assert batch_dir, \ - "Folder {} doesn't exist".format(batch_dir) - - project_name = os.environ.get("AVALON_PROJECT") - if project_name is None: - raise AssertionError( - "Environment `AVALON_PROJECT` was not found." - "Could not set project `root` which may cause issues." - ) - - self._process_batch(batch_dir) diff --git a/openpype/hosts/webpublisher/plugins/publish/integrate_context_to_log.py b/openpype/hosts/webpublisher/plugins/publish/integrate_context_to_log.py deleted file mode 100644 index 419c065e16..0000000000 --- a/openpype/hosts/webpublisher/plugins/publish/integrate_context_to_log.py +++ /dev/null @@ -1,38 +0,0 @@ -import os - -import pyblish.api -from openpype.lib import OpenPypeMongoConnection - - -class IntegrateContextToLog(pyblish.api.ContextPlugin): - """ Adds context information to log document for displaying in front end""" - - label = "Integrate Context to Log" - order = pyblish.api.IntegratorOrder - 0.1 - hosts = ["webpublisher"] - - def process(self, context): - self.log.info("Integrate Context to Log") - - mongo_client = OpenPypeMongoConnection.get_mongo_client() - database_name = os.environ["OPENPYPE_DATABASE_NAME"] - dbcon = mongo_client[database_name]["webpublishes"] - - for instance in context: - self.log.info("ctx_path: {}".format(instance.data.get("ctx_path"))) - self.log.info("batch_id: {}".format(instance.data.get("batch_id"))) - if instance.data.get("ctx_path") and instance.data.get("batch_id"): - self.log.info("Updating log record") - dbcon.update_one( - { - "batch_id": instance.data.get("batch_id"), - "status": "in_progress" - }, - {"$set": - { - "path": instance.data.get("ctx_path") - - }} - ) - - return diff --git a/openpype/hosts/webpublisher/webserver_service/webpublish_routes.py b/openpype/hosts/webpublisher/webserver_service/webpublish_routes.py index 0014d1b344..d474c96ff9 100644 --- a/openpype/hosts/webpublisher/webserver_service/webpublish_routes.py +++ b/openpype/hosts/webpublisher/webserver_service/webpublish_routes.py @@ -11,6 +11,8 @@ from avalon.api import AvalonMongoDB from openpype.lib import OpenPypeMongoConnection from openpype_modules.avalon_apps.rest_api import _RestApiEndpoint +from openpype.lib.remote_publish import get_task_data +from openpype.settings import get_project_settings from openpype.lib import PypeLogger @@ -19,11 +21,16 @@ log = PypeLogger.get_logger("WebServer") class RestApiResource: """Resource carrying needed info and Avalon DB connection for publish.""" - def __init__(self, server_manager, executable, upload_dir): + def __init__(self, server_manager, executable, upload_dir, + studio_task_queue=None): self.server_manager = server_manager self.upload_dir = upload_dir self.executable = executable + if studio_task_queue is None: + studio_task_queue = collections.deque().dequeu + self.studio_task_queue = studio_task_queue + self.dbcon = AvalonMongoDB() self.dbcon.install() @@ -33,6 +40,8 @@ class RestApiResource: return value.isoformat() if isinstance(value, ObjectId): return str(value) + if isinstance(value, set): + return list(value) raise TypeError(value) @classmethod @@ -175,40 +184,95 @@ class TaskNode(Node): class WebpublisherBatchPublishEndpoint(_RestApiEndpoint): """Triggers headless publishing of batch.""" async def post(self, request) -> Response: - output = {} - log.info("WebpublisherBatchPublishEndpoint called") - content = await request.json() - - batch_path = os.path.join(self.resource.upload_dir, - content["batch"]) - + # Validate existence of openpype executable openpype_app = self.resource.executable - args = [ - openpype_app, - 'remotepublish', - batch_path - ] - if not openpype_app or not os.path.exists(openpype_app): msg = "Non existent OpenPype executable {}".format(openpype_app) raise RuntimeError(msg) + log.info("WebpublisherBatchPublishEndpoint called") + content = await request.json() + + # Each filter have extensions which are checked on first task item + # - first filter with extensions that are on first task is used + # - filter defines command and can extend arguments dictionary + # This is used only if 'studio_processing' is enabled on batch + studio_processing_filters = [ + # Photoshop filter + { + "extensions": [".psd", ".psb"], + "command": "remotepublishfromapp", + "arguments": { + # Command 'remotepublishfromapp' requires --host argument + "host": "photoshop", + # Make sure targets are set to None for cases that default + # would change + # - targets argument is not used in 'remotepublishfromapp' + "targets": ["remotepublish"] + }, + # does publish need to be handled by a queue, eg. only + # single process running concurrently? + "add_to_queue": True + } + ] + + batch_dir = os.path.join(self.resource.upload_dir, content["batch"]) + + # Default command and arguments + command = "remotepublish" add_args = { - "host": "webpublisher", + # All commands need 'project' and 'user' "project": content["project_name"], - "user": content["user"] + "user": content["user"], + + "targets": ["filespublish"] } + add_to_queue = False + if content.get("studio_processing"): + log.info("Post processing called for {}".format(batch_dir)) + + task_data = get_task_data(batch_dir) + + for process_filter in studio_processing_filters: + filter_extensions = process_filter.get("extensions") or [] + for file_name in task_data["files"]: + file_ext = os.path.splitext(file_name)[-1].lower() + if file_ext in filter_extensions: + # Change command + command = process_filter["command"] + # Update arguments + add_args.update( + process_filter.get("arguments") or {} + ) + add_to_queue = process_filter["add_to_queue"] + break + + args = [ + openpype_app, + command, + batch_dir + ] + for key, value in add_args.items(): - args.append("--{}".format(key)) - args.append(value) + # Skip key values where value is None + if value is not None: + args.append("--{}".format(key)) + # Extend list into arguments (targets can be a list) + if isinstance(value, (tuple, list)): + args.extend(value) + else: + args.append(value) log.info("args:: {}".format(args)) + if add_to_queue: + log.debug("Adding to queue") + self.resource.studio_task_queue.append(args) + else: + subprocess.call(args) - subprocess.call(args) return Response( status=200, - body=self.resource.encode(output), content_type="application/json" ) @@ -245,3 +309,36 @@ class PublishesStatusEndpoint(_RestApiEndpoint): body=self.resource.encode(output), content_type="application/json" ) + + +class ConfiguredExtensionsEndpoint(_RestApiEndpoint): + """Returns dict of extensions which have mapping to family. + + Returns: + { + "file_exts": [], + "sequence_exts": [] + } + """ + async def get(self, project_name=None) -> Response: + sett = get_project_settings(project_name) + + configured = { + "file_exts": set(), + "sequence_exts": set(), + # workfiles that could have "Studio Procesing" hardcoded for now + "studio_exts": set(["psd", "psb", "tvpp", "tvp"]) + } + collect_conf = sett["webpublisher"]["publish"]["CollectPublishedFiles"] + for _, mapping in collect_conf.get("task_type_to_family", {}).items(): + for _family, config in mapping.items(): + if config["is_sequence"]: + configured["sequence_exts"].update(config["extensions"]) + else: + configured["file_exts"].update(config["extensions"]) + + return Response( + status=200, + body=self.resource.encode(dict(configured)), + content_type="application/json" + ) diff --git a/openpype/hosts/webpublisher/webserver_service/webserver_cli.py b/openpype/hosts/webpublisher/webserver_service/webserver_cli.py index d00d269059..c96ad8e110 100644 --- a/openpype/hosts/webpublisher/webserver_service/webserver_cli.py +++ b/openpype/hosts/webpublisher/webserver_service/webserver_cli.py @@ -1,8 +1,10 @@ +import collections import time import os from datetime import datetime import requests import json +import subprocess from openpype.lib import PypeLogger @@ -14,7 +16,8 @@ from .webpublish_routes import ( WebpublisherHiearchyEndpoint, WebpublisherProjectsEndpoint, BatchStatusEndpoint, - PublishesStatusEndpoint + PublishesStatusEndpoint, + ConfiguredExtensionsEndpoint ) @@ -31,10 +34,13 @@ def run_webserver(*args, **kwargs): port = kwargs.get("port") or 8079 server_manager = webserver_module.create_new_server_manager(port, host) webserver_url = server_manager.url + # queue for remotepublishfromapp tasks + studio_task_queue = collections.deque() resource = RestApiResource(server_manager, upload_dir=kwargs["upload_dir"], - executable=kwargs["executable"]) + executable=kwargs["executable"], + studio_task_queue=studio_task_queue) projects_endpoint = WebpublisherProjectsEndpoint(resource) server_manager.add_route( "GET", @@ -49,6 +55,13 @@ def run_webserver(*args, **kwargs): hiearchy_endpoint.dispatch ) + configured_ext_endpoint = ConfiguredExtensionsEndpoint(resource) + server_manager.add_route( + "GET", + "/api/webpublish/configured_ext/{project_name}", + configured_ext_endpoint.dispatch + ) + # triggers publish webpublisher_task_publish_endpoint = \ WebpublisherBatchPublishEndpoint(resource) @@ -88,6 +101,10 @@ def run_webserver(*args, **kwargs): if time.time() - last_reprocessed > 20: reprocess_failed(kwargs["upload_dir"], webserver_url) last_reprocessed = time.time() + if studio_task_queue: + args = studio_task_queue.popleft() + subprocess.call(args) # blocking call + time.sleep(1.0) diff --git a/openpype/lib/__init__.py b/openpype/lib/__init__.py index 74004a1239..ee4821b80d 100644 --- a/openpype/lib/__init__.py +++ b/openpype/lib/__init__.py @@ -130,6 +130,7 @@ from .applications import ( from .plugin_tools import ( TaskNotSetError, get_subset_name, + get_subset_name_with_asset_doc, prepare_template_data, filter_pyblish_plugins, set_plugin_attributes_from_settings, @@ -249,6 +250,7 @@ __all__ = [ "TaskNotSetError", "get_subset_name", + "get_subset_name_with_asset_doc", "filter_pyblish_plugins", "set_plugin_attributes_from_settings", "source_hash", diff --git a/openpype/lib/applications.py b/openpype/lib/applications.py index cc8cb8e7be..b9bcecd3a0 100644 --- a/openpype/lib/applications.py +++ b/openpype/lib/applications.py @@ -461,13 +461,8 @@ class ApplicationExecutable: # On MacOS check if exists path to executable when ends with `.app` # - it is common that path will lead to "/Applications/Blender" but # real path is "/Applications/Blender.app" - if ( - platform.system().lower() == "darwin" - and not os.path.exists(executable) - ): - _executable = executable + ".app" - if os.path.exists(_executable): - executable = _executable + if platform.system().lower() == "darwin": + executable = self.macos_executable_prep(executable) self.executable_path = executable @@ -477,6 +472,45 @@ class ApplicationExecutable: def __repr__(self): return "<{}> {}".format(self.__class__.__name__, self.executable_path) + @staticmethod + def macos_executable_prep(executable): + """Try to find full path to executable file. + + Real executable is stored in '*.app/Contents/MacOS/'. + + Having path to '*.app' gives ability to read it's plist info and + use "CFBundleExecutable" key from plist to know what is "executable." + + Plist is stored in '*.app/Contents/Info.plist'. + + This is because some '*.app' directories don't have same permissions + as real executable. + """ + # Try to find if there is `.app` file + if not os.path.exists(executable): + _executable = executable + ".app" + if os.path.exists(_executable): + executable = _executable + + # Try to find real executable if executable has `Contents` subfolder + contents_dir = os.path.join(executable, "Contents") + if os.path.exists(contents_dir): + executable_filename = None + # Load plist file and check for bundle executable + plist_filepath = os.path.join(contents_dir, "Info.plist") + if os.path.exists(plist_filepath): + import plistlib + + parsed_plist = plistlib.readPlist(plist_filepath) + executable_filename = parsed_plist.get("CFBundleExecutable") + + if executable_filename: + executable = os.path.join( + contents_dir, "MacOS", executable_filename + ) + + return executable + def as_args(self): return [self.executable_path] diff --git a/openpype/lib/delivery.py b/openpype/lib/delivery.py index 5735cbc99d..c89e2e7ae0 100644 --- a/openpype/lib/delivery.py +++ b/openpype/lib/delivery.py @@ -245,6 +245,27 @@ def process_sequence( report_items["Source file was not found"].append(msg) return report_items, 0 + delivery_templates = anatomy.templates.get("delivery") or {} + delivery_template = delivery_templates.get(template_name) + if delivery_template is None: + msg = ( + "Delivery template \"{}\" in anatomy of project \"{}\"" + " was not found" + ).format(template_name, anatomy.project_name) + report_items[""].append(msg) + return report_items, 0 + + # Check if 'frame' key is available in template which is required + # for sequence delivery + if "{frame" not in delivery_template: + msg = ( + "Delivery template \"{}\" in anatomy of project \"{}\"" + "does not contain '{{frame}}' key to fill. Delivery of sequence" + " can't be processed." + ).format(template_name, anatomy.project_name) + report_items[""].append(msg) + return report_items, 0 + dir_path, file_name = os.path.split(str(src_path)) context = repre["context"] diff --git a/openpype/lib/local_settings.py b/openpype/lib/local_settings.py index 66dad279de..97e99b4b5a 100644 --- a/openpype/lib/local_settings.py +++ b/openpype/lib/local_settings.py @@ -522,6 +522,11 @@ def get_local_site_id(): Identifier is created if does not exists yet. """ + # override local id from environment + # used for background syncing + if os.environ.get("OPENPYPE_LOCAL_ID"): + return os.environ["OPENPYPE_LOCAL_ID"] + registry = OpenPypeSettingsRegistry() try: return registry.get_item("localId") diff --git a/openpype/lib/path_tools.py b/openpype/lib/path_tools.py index 048bf0eda0..6fd0ad0dfe 100644 --- a/openpype/lib/path_tools.py +++ b/openpype/lib/path_tools.py @@ -2,6 +2,8 @@ import json import logging import os import re +import abc +import six from .anatomy import Anatomy @@ -196,3 +198,159 @@ def get_project_basic_paths(project_name): if isinstance(folder_structure, str): folder_structure = json.loads(folder_structure) return _list_path_items(folder_structure) + + +@six.add_metaclass(abc.ABCMeta) +class HostDirmap: + """ + Abstract class for running dirmap on a workfile in a host. + + Dirmap is used to translate paths inside of host workfile from one + OS to another. (Eg. arstist created workfile on Win, different artists + opens same file on Linux.) + + Expects methods to be implemented inside of host: + on_dirmap_enabled: run host code for enabling dirmap + do_dirmap: run host code to do actual remapping + """ + def __init__(self, host_name, project_settings, sync_module=None): + self.host_name = host_name + self.project_settings = project_settings + self.sync_module = sync_module # to limit reinit of Modules + + self._mapping = None # cache mapping + + @abc.abstractmethod + def on_enable_dirmap(self): + """ + Run host dependent operation for enabling dirmap if necessary. + """ + + @abc.abstractmethod + def dirmap_routine(self, source_path, destination_path): + """ + Run host dependent remapping from source_path to destination_path + """ + + def process_dirmap(self): + # type: (dict) -> None + """Go through all paths in Settings and set them using `dirmap`. + + If artists has Site Sync enabled, take dirmap mapping directly from + Local Settings when artist is syncing workfile locally. + + Args: + project_settings (dict): Settings for current project. + + """ + if not self._mapping: + self._mapping = self.get_mappings(self.project_settings) + if not self._mapping: + return + + log.info("Processing directory mapping ...") + self.on_enable_dirmap() + log.info("mapping:: {}".format(self._mapping)) + + for k, sp in enumerate(self._mapping["source-path"]): + try: + print("{} -> {}".format(sp, + self._mapping["destination-path"][k])) + self.dirmap_routine(sp, + self._mapping["destination-path"][k]) + except IndexError: + # missing corresponding destination path + log.error(("invalid dirmap mapping, missing corresponding" + " destination directory.")) + break + except RuntimeError: + log.error("invalid path {} -> {}, mapping not registered".format( # noqa: E501 + sp, self._mapping["destination-path"][k] + )) + continue + + def get_mappings(self, project_settings): + """Get translation from source-path to destination-path. + + It checks if Site Sync is enabled and user chose to use local + site, in that case configuration in Local Settings takes precedence + """ + local_mapping = self._get_local_sync_dirmap(project_settings) + dirmap_label = "{}-dirmap".format(self.host_name) + if not self.project_settings[self.host_name].get(dirmap_label) and \ + not local_mapping: + return [] + mapping = local_mapping or \ + self.project_settings[self.host_name][dirmap_label]["paths"] or {} + enbled = self.project_settings[self.host_name][dirmap_label]["enabled"] + mapping_enabled = enbled or bool(local_mapping) + + if not mapping or not mapping_enabled or \ + not mapping.get("destination-path") or \ + not mapping.get("source-path"): + return [] + return mapping + + def _get_local_sync_dirmap(self, project_settings): + """ + Returns dirmap if synch to local project is enabled. + + Only valid mapping is from roots of remote site to local site set + in Local Settings. + + Args: + project_settings (dict) + Returns: + dict : { "source-path": [XXX], "destination-path": [YYYY]} + """ + import json + mapping = {} + + if not project_settings["global"]["sync_server"]["enabled"]: + log.debug("Site Sync not enabled") + return mapping + + from openpype.settings.lib import get_site_local_overrides + + if not self.sync_module: + from openpype.modules import ModulesManager + manager = ModulesManager() + self.sync_module = manager.modules_by_name["sync_server"] + + project_name = os.getenv("AVALON_PROJECT") + + active_site = self.sync_module.get_local_normalized_site( + self.sync_module.get_active_site(project_name)) + remote_site = self.sync_module.get_local_normalized_site( + self.sync_module.get_remote_site(project_name)) + log.debug("active {} - remote {}".format(active_site, remote_site)) + + if active_site == "local" \ + and project_name in self.sync_module.get_enabled_projects()\ + and active_site != remote_site: + + sync_settings = self.sync_module.get_sync_project_setting( + os.getenv("AVALON_PROJECT"), exclude_locals=False, + cached=False) + + active_overrides = get_site_local_overrides( + os.getenv("AVALON_PROJECT"), active_site) + remote_overrides = get_site_local_overrides( + os.getenv("AVALON_PROJECT"), remote_site) + + log.debug("local overrides".format(active_overrides)) + log.debug("remote overrides".format(remote_overrides)) + for root_name, active_site_dir in active_overrides.items(): + remote_site_dir = remote_overrides.get(root_name) or\ + sync_settings["sites"][remote_site]["root"][root_name] + if os.path.isdir(active_site_dir): + if not mapping.get("destination-path"): + mapping["destination-path"] = [] + mapping["destination-path"].append(active_site_dir) + + if not mapping.get("source-path"): + mapping["source-path"] = [] + mapping["source-path"].append(remote_site_dir) + + log.debug("local sync mapping:: {}".format(mapping)) + return mapping diff --git a/openpype/lib/plugin_tools.py b/openpype/lib/plugin_tools.py index 4eabb4d1ca..aa9e0c9b57 100644 --- a/openpype/lib/plugin_tools.py +++ b/openpype/lib/plugin_tools.py @@ -28,17 +28,44 @@ class TaskNotSetError(KeyError): super(TaskNotSetError, self).__init__(msg) -def get_subset_name( +def get_subset_name_with_asset_doc( family, variant, task_name, - asset_id, + asset_doc, project_name=None, host_name=None, default_template=None, - dynamic_data=None, - dbcon=None + dynamic_data=None ): + """Calculate subset name based on passed context and OpenPype settings. + + Subst name templates are defined in `project_settings/global/tools/creator + /subset_name_profiles` where are profiles with host name, family, task name + and task type filters. If context does not match any profile then + `DEFAULT_SUBSET_TEMPLATE` is used as default template. + + That's main reason why so many arguments are required to calculate subset + name. + + Args: + family (str): Instance family. + variant (str): In most of cases it is user input during creation. + task_name (str): Task name on which context is instance created. + asset_doc (dict): Queried asset document with it's tasks in data. + Used to get task type. + project_name (str): Name of project on which is instance created. + Important for project settings that are loaded. + host_name (str): One of filtering criteria for template profile + filters. + default_template (str): Default template if any profile does not match + passed context. Constant 'DEFAULT_SUBSET_TEMPLATE' is used if + is not passed. + dynamic_data (dict): Dynamic data specific for a creator which creates + instance. + dbcon (AvalonMongoDB): Mongo connection to be able query asset document + if 'asset_doc' is not passed. + """ if not family: return "" @@ -53,25 +80,6 @@ def get_subset_name( project_name = avalon.api.Session["AVALON_PROJECT"] - # Function should expect asset document instead of asset id - # - that way `dbcon` is not needed - if dbcon is None: - from avalon.api import AvalonMongoDB - - dbcon = AvalonMongoDB() - dbcon.Session["AVALON_PROJECT"] = project_name - - dbcon.install() - - asset_doc = dbcon.find_one( - { - "type": "asset", - "_id": asset_id - }, - { - "data.tasks": True - } - ) asset_tasks = asset_doc.get("data", {}).get("tasks") or {} task_info = asset_tasks.get(task_name) or {} task_type = task_info.get("type") @@ -113,6 +121,49 @@ def get_subset_name( return template.format(**prepare_template_data(fill_pairs)) +def get_subset_name( + family, + variant, + task_name, + asset_id, + project_name=None, + host_name=None, + default_template=None, + dynamic_data=None, + dbcon=None +): + """Calculate subset name using OpenPype settings. + + This variant of function expects asset id as argument. + + This is legacy function should be replaced with + `get_subset_name_with_asset_doc` where asset document is expected. + """ + if dbcon is None: + from avalon.api import AvalonMongoDB + + dbcon = AvalonMongoDB() + dbcon.Session["AVALON_PROJECT"] = project_name + + dbcon.install() + + asset_doc = dbcon.find_one( + {"_id": asset_id}, + {"data.tasks": True} + ) or {} + + return get_subset_name_with_asset_doc( + family, + variant, + task_name, + asset_doc, + project_name, + host_name, + default_template, + dynamic_data + ) + + def prepare_template_data(fill_pairs): """ Prepares formatted data for filling template. @@ -487,3 +538,48 @@ def should_decompress(file_url): "compression: \"dwab\"" in output return False + + +def parse_json(path): + """Parses json file at 'path' location + + Returns: + (dict) or None if unparsable + Raises: + AsssertionError if 'path' doesn't exist + """ + path = path.strip('\"') + assert os.path.isfile(path), ( + "Path to json file doesn't exist. \"{}\"".format(path) + ) + data = None + with open(path, "r") as json_file: + try: + data = json.load(json_file) + except Exception as exc: + log.error( + "Error loading json: " + "{} - Exception: {}".format(path, exc) + ) + return data + + +def get_batch_asset_task_info(ctx): + """Parses context data from webpublisher's batch metadata + + Returns: + (tuple): asset, task_name (Optional), task_type + """ + task_type = "default_task_type" + task_name = None + asset = None + + if ctx["type"] == "task": + items = ctx["path"].split('/') + asset = items[-2] + task_name = ctx["name"] + task_type = ctx["attributes"]["type"] + else: + asset = ctx["name"] + + return asset, task_name, task_type diff --git a/openpype/lib/python_2_comp.py b/openpype/lib/python_2_comp.py new file mode 100644 index 0000000000..d7137dbe9c --- /dev/null +++ b/openpype/lib/python_2_comp.py @@ -0,0 +1,41 @@ +import weakref + + +class _weak_callable: + def __init__(self, obj, func): + self.im_self = obj + self.im_func = func + + def __call__(self, *args, **kws): + if self.im_self is None: + return self.im_func(*args, **kws) + else: + return self.im_func(self.im_self, *args, **kws) + + +class WeakMethod: + """ Wraps a function or, more importantly, a bound method in + a way that allows a bound method's object to be GCed, while + providing the same interface as a normal weak reference. """ + + def __init__(self, fn): + try: + self._obj = weakref.ref(fn.im_self) + self._meth = fn.im_func + except AttributeError: + # It's not a bound method + self._obj = None + self._meth = fn + + def __call__(self): + if self._dead(): + return None + return _weak_callable(self._getobj(), self._meth) + + def _dead(self): + return self._obj is not None and self._obj() is None + + def _getobj(self): + if self._obj is None: + return None + return self._obj() diff --git a/openpype/lib/python_module_tools.py b/openpype/lib/python_module_tools.py index cb5f285ddd..69da4cc661 100644 --- a/openpype/lib/python_module_tools.py +++ b/openpype/lib/python_module_tools.py @@ -22,6 +22,9 @@ def import_filepath(filepath, module_name=None): if module_name is None: module_name = os.path.splitext(os.path.basename(filepath))[0] + # Make sure it is not 'unicode' in Python 2 + module_name = str(module_name) + # Prepare module object where content of file will be parsed module = types.ModuleType(module_name) diff --git a/openpype/lib/remote_publish.py b/openpype/lib/remote_publish.py new file mode 100644 index 0000000000..d7db4d1ab9 --- /dev/null +++ b/openpype/lib/remote_publish.py @@ -0,0 +1,186 @@ +import os +from datetime import datetime +import sys +from bson.objectid import ObjectId + +import pyblish.util +import pyblish.api + +from openpype import uninstall +from openpype.lib.mongo import OpenPypeMongoConnection +from openpype.lib.plugin_tools import parse_json + + +def get_webpublish_conn(): + """Get connection to OP 'webpublishes' collection.""" + mongo_client = OpenPypeMongoConnection.get_mongo_client() + database_name = os.environ["OPENPYPE_DATABASE_NAME"] + return mongo_client[database_name]["webpublishes"] + + +def start_webpublish_log(dbcon, batch_id, user): + """Start new log record for 'batch_id' + + Args: + dbcon (OpenPypeMongoConnection) + batch_id (str) + user (str) + Returns + (ObjectId) from DB + """ + return dbcon.insert_one({ + "batch_id": batch_id, + "start_date": datetime.now(), + "user": user, + "status": "in_progress", + "progress": 0.0 + }).inserted_id + + +def publish_and_log(dbcon, _id, log, close_plugin_name=None): + """Loops through all plugins, logs ok and fails into OP DB. + + Args: + dbcon (OpenPypeMongoConnection) + _id (str) + log (OpenPypeLogger) + close_plugin_name (str): name of plugin with responsibility to + close host app + """ + # Error exit as soon as any error occurs. + error_format = "Failed {plugin.__name__}: {error} -- {error.traceback}" + + close_plugin = _get_close_plugin(close_plugin_name, log) + + if isinstance(_id, str): + _id = ObjectId(_id) + + log_lines = [] + for result in pyblish.util.publish_iter(): + for record in result["records"]: + log_lines.append("{}: {}".format( + result["plugin"].label, record.msg)) + + if result["error"]: + log.error(error_format.format(**result)) + uninstall() + log_lines.append(error_format.format(**result)) + dbcon.update_one( + {"_id": _id}, + {"$set": + { + "finish_date": datetime.now(), + "status": "error", + "log": os.linesep.join(log_lines) + + }} + ) + if close_plugin: # close host app explicitly after error + context = pyblish.api.Context() + close_plugin().process(context) + sys.exit(1) + else: + dbcon.update_one( + {"_id": _id}, + {"$set": + { + "progress": max(result["progress"], 0.95), + "log": os.linesep.join(log_lines) + }} + ) + + # final update + dbcon.update_one( + {"_id": _id}, + {"$set": + { + "finish_date": datetime.now(), + "status": "finished_ok", + "progress": 1, + "log": os.linesep.join(log_lines) + }} + ) + + +def fail_batch(_id, batches_in_progress, dbcon): + """Set current batch as failed as there are some stuck batches.""" + running_batches = [str(batch["_id"]) + for batch in batches_in_progress + if batch["_id"] != _id] + msg = "There are still running batches {}\n". \ + format("\n".join(running_batches)) + msg += "Ask admin to check them and reprocess current batch" + dbcon.update_one( + {"_id": _id}, + {"$set": + { + "finish_date": datetime.now(), + "status": "error", + "log": msg + + }} + ) + raise ValueError(msg) + + +def find_variant_key(application_manager, host): + """Searches for latest installed variant for 'host' + + Args: + application_manager (ApplicationManager) + host (str) + Returns + (string) (optional) + Raises: + (ValueError) if no variant found + """ + app_group = application_manager.app_groups.get(host) + if not app_group or not app_group.enabled: + raise ValueError("No application {} configured".format(host)) + + found_variant_key = None + # finds most up-to-date variant if any installed + for variant_key, variant in app_group.variants.items(): + for executable in variant.executables: + if executable.exists(): + found_variant_key = variant_key + + if not found_variant_key: + raise ValueError("No executable for {} found".format(host)) + + return found_variant_key + + +def _get_close_plugin(close_plugin_name, log): + if close_plugin_name: + plugins = pyblish.api.discover() + for plugin in plugins: + if plugin.__name__ == close_plugin_name: + return plugin + + log.warning("Close plugin not found, app might not close.") + + +def get_task_data(batch_dir): + """Return parsed data from first task manifest.json + + Used for `remotepublishfromapp` command where batch contains only + single task with publishable workfile. + + Returns: + (dict) + Throws: + (ValueError) if batch or task manifest not found or broken + """ + batch_data = parse_json(os.path.join(batch_dir, "manifest.json")) + if not batch_data: + raise ValueError( + "Cannot parse batch meta in {} folder".format(batch_dir)) + task_dir_name = batch_data["tasks"][0] + task_data = parse_json(os.path.join(batch_dir, task_dir_name, + "manifest.json")) + if not task_data: + raise ValueError( + "Cannot parse batch meta in {} folder".format(task_data)) + + return task_data diff --git a/openpype/modules/README.md b/openpype/modules/README.md index 5716324365..86afdb9d91 100644 --- a/openpype/modules/README.md +++ b/openpype/modules/README.md @@ -22,6 +22,10 @@ OpenPype modules should contain separated logic of specific kind of implementati - `__init__` should not be overridden and `initialize` should not do time consuming part but only prepare base data about module - also keep in mind that they may be initialized in headless mode - connection with other modules is made with help of interfaces +- `cli` method - add cli commands specific for the module + - command line arguments are handled using `click` python module + - `cli` method should expect single argument which is click group on which can be called any group specific methods (e.g. `add_command` to add another click group as children see `ExampleAddon`) + - it is possible to add trigger cli commands using `./openpype_console module *args` ## Addon class `OpenPypeAddOn` - inherits from `OpenPypeModule` but is enabled by default and doesn't have to implement `initialize` and `connect_with_modules` methods @@ -140,4 +144,4 @@ class ClockifyModule( ### TrayModulesManager - inherits from `ModulesManager` -- has specific implementation for Pype Tray tool and handle `ITrayModule` methods \ No newline at end of file +- has specific implementation for Pype Tray tool and handle `ITrayModule` methods diff --git a/openpype/modules/base.py b/openpype/modules/base.py index 7779fff6ec..6f9ddb2fd4 100644 --- a/openpype/modules/base.py +++ b/openpype/modules/base.py @@ -107,12 +107,9 @@ class _InterfacesClass(_ModuleClass): if attr_name in ("__path__", "__file__"): return None - # Fake Interface if is not missing - self.__attributes__[attr_name] = type( - attr_name, - (MissingInteface, ), - {} - ) + raise ImportError(( + "cannot import name '{}' from 'openpype_interfaces'" + ).format(attr_name)) return self.__attributes__[attr_name] @@ -212,54 +209,17 @@ def _load_interfaces(): _InterfacesClass(modules_key) ) - log = PypeLogger.get_logger("InterfacesLoader") + from . import interfaces - dirpaths = get_module_dirs() - - interface_paths = [] - interface_paths.append( - os.path.join(get_default_modules_dir(), "interfaces.py") - ) - for dirpath in dirpaths: - if not os.path.exists(dirpath): + for attr_name in dir(interfaces): + attr = getattr(interfaces, attr_name) + if ( + not inspect.isclass(attr) + or attr is OpenPypeInterface + or not issubclass(attr, OpenPypeInterface) + ): continue - - for filename in os.listdir(dirpath): - if filename in ("__pycache__", ): - continue - - full_path = os.path.join(dirpath, filename) - if not os.path.isdir(full_path): - continue - - interfaces_path = os.path.join(full_path, "interfaces.py") - if os.path.exists(interfaces_path): - interface_paths.append(interfaces_path) - - for full_path in interface_paths: - if not os.path.exists(full_path): - continue - - try: - # Prepare module object where content of file will be parsed - module = import_filepath(full_path) - - except Exception: - log.warning( - "Failed to load path: \"{0}\"".format(full_path), - exc_info=True - ) - continue - - for attr_name in dir(module): - attr = getattr(module, attr_name) - if ( - not inspect.isclass(attr) - or attr is OpenPypeInterface - or not issubclass(attr, OpenPypeInterface) - ): - continue - setattr(openpype_interfaces, attr_name, attr) + setattr(openpype_interfaces, attr_name, attr) def load_modules(force=False): @@ -333,6 +293,15 @@ def _load_modules(): # - check manifest and content of manifest try: if os.path.isdir(fullpath): + # Module without init file can't be used as OpenPype module + # because the module class could not be imported + init_file = os.path.join(fullpath, "__init__.py") + if not os.path.exists(init_file): + log.info(( + "Skipping module directory because of" + " missing \"__init__.py\" file. \"{}\"" + ).format(fullpath)) + continue import_module_from_dirpath(dirpath, filename, modules_key) elif ext in (".py", ): @@ -369,14 +338,6 @@ class OpenPypeInterface: pass -class MissingInteface(OpenPypeInterface): - """Class representing missing interface class. - - Used when interface is not available from currently registered paths. - """ - pass - - @six.add_metaclass(ABCMeta) class OpenPypeModule: """Base class of pype module. @@ -431,6 +392,28 @@ class OpenPypeModule: """ return {} + def cli(self, module_click_group): + """Add commands to click group. + + The best practise is to create click group for whole module which is + used to separate commands. + + class MyPlugin(OpenPypeModule): + ... + def cli(self, module_click_group): + module_click_group.add_command(cli_main) + + + @click.group(, help="") + def cli_main(): + pass + + @cli_main.command() + def mycommand(): + print("my_command") + """ + pass + class OpenPypeAddOn(OpenPypeModule): # Enable Addon by default diff --git a/openpype/modules/default_modules/avalon_apps/avalon_app.py b/openpype/modules/default_modules/avalon_apps/avalon_app.py index d21b37e520..9e650a097e 100644 --- a/openpype/modules/default_modules/avalon_apps/avalon_app.py +++ b/openpype/modules/default_modules/avalon_apps/avalon_app.py @@ -1,6 +1,5 @@ import os import openpype -from openpype import resources from openpype.modules import OpenPypeModule from openpype_interfaces import ITrayModule @@ -52,16 +51,12 @@ class AvalonModule(OpenPypeModule, ITrayModule): def tray_init(self): # Add library tool try: - from Qt import QtGui - from avalon import style from openpype.tools.libraryloader import LibraryLoaderWindow self.libraryloader = LibraryLoaderWindow( - icon=QtGui.QIcon(resources.get_openpype_icon_filepath()), show_projects=True, show_libraries=True ) - self.libraryloader.setStyleSheet(style.load_stylesheet()) except Exception: self.log.warning( "Couldn't load Library loader tool for tray.", @@ -70,6 +65,9 @@ class AvalonModule(OpenPypeModule, ITrayModule): # Definition of Tray menu def tray_menu(self, tray_menu): + if self.libraryloader is None: + return + from Qt import QtWidgets # Actions action_library_loader = QtWidgets.QAction( @@ -87,6 +85,9 @@ class AvalonModule(OpenPypeModule, ITrayModule): return def show_library_loader(self): + if self.libraryloader is None: + return + self.libraryloader.show() # Raise and activate the window diff --git a/openpype/modules/default_modules/ftrack/event_handlers_server/event_sync_to_avalon.py b/openpype/modules/default_modules/ftrack/event_handlers_server/event_sync_to_avalon.py index 93a0404c0b..a4982627ff 100644 --- a/openpype/modules/default_modules/ftrack/event_handlers_server/event_sync_to_avalon.py +++ b/openpype/modules/default_modules/ftrack/event_handlers_server/event_sync_to_avalon.py @@ -1,8 +1,6 @@ -import os import collections import copy import json -import queue import time import datetime import atexit @@ -193,7 +191,10 @@ class SyncToAvalonEvent(BaseEvent): self._avalon_ents_by_ftrack_id = {} proj, ents = self.avalon_entities if proj: - ftrack_id = proj["data"]["ftrackId"] + ftrack_id = proj["data"].get("ftrackId") + if ftrack_id is None: + ftrack_id = self._update_project_ftrack_id() + proj["data"]["ftrackId"] = ftrack_id self._avalon_ents_by_ftrack_id[ftrack_id] = proj for ent in ents: ftrack_id = ent["data"].get("ftrackId") @@ -202,6 +203,16 @@ class SyncToAvalonEvent(BaseEvent): self._avalon_ents_by_ftrack_id[ftrack_id] = ent return self._avalon_ents_by_ftrack_id + def _update_project_ftrack_id(self): + ftrack_id = self.cur_project["id"] + + self.dbcon.update_one( + {"type": "project"}, + {"$set": {"data.ftrackId": ftrack_id}} + ) + + return ftrack_id + @property def avalon_subsets_by_parents(self): if self._avalon_subsets_by_parents is None: @@ -340,13 +351,13 @@ class SyncToAvalonEvent(BaseEvent): self._avalon_archived_by_id[mongo_id] = entity def _bubble_changeability(self, unchangeable_ids): - unchangeable_queue = queue.Queue() + unchangeable_queue = collections.deque() for entity_id in unchangeable_ids: - unchangeable_queue.put((entity_id, False)) + unchangeable_queue.append((entity_id, False)) processed_parents_ids = [] - while not unchangeable_queue.empty(): - entity_id, child_is_archived = unchangeable_queue.get() + while unchangeable_queue: + entity_id, child_is_archived = unchangeable_queue.popleft() # skip if already processed if entity_id in processed_parents_ids: continue @@ -388,7 +399,7 @@ class SyncToAvalonEvent(BaseEvent): parent_id = entity["data"]["visualParent"] if parent_id is None: continue - unchangeable_queue.put((parent_id, child_is_archived)) + unchangeable_queue.append((parent_id, child_is_archived)) def reset_variables(self): """Reset variables so each event callback has clear env.""" @@ -574,6 +585,10 @@ class SyncToAvalonEvent(BaseEvent): continue ftrack_id = ftrack_id[0] + # Skip deleted projects + if action == "remove" and entityType == "show": + return True + # task modified, collect parent id of task, handle separately if entity_type.lower() == "task": changes = ent_info.get("changes") or {} @@ -1050,7 +1065,7 @@ class SyncToAvalonEvent(BaseEvent): key=(lambda entity: len(entity["link"])) ) - children_queue = queue.Queue() + children_queue = collections.deque() for entity in synchronizable_ents: parent_avalon_ent = self.avalon_ents_by_ftrack_id[ entity["parent_id"] @@ -1060,10 +1075,10 @@ class SyncToAvalonEvent(BaseEvent): for child in entity["children"]: if child.entity_type.lower() == "task": continue - children_queue.put(child) + children_queue.append(child) - while not children_queue.empty(): - entity = children_queue.get() + while children_queue: + entity = children_queue.popleft() ftrack_id = entity["id"] name = entity["name"] ent_by_ftrack_id = self.avalon_ents_by_ftrack_id.get(ftrack_id) @@ -1093,7 +1108,7 @@ class SyncToAvalonEvent(BaseEvent): for child in entity["children"]: if child.entity_type.lower() == "task": continue - children_queue.put(child) + children_queue.append(child) def create_entity_in_avalon(self, ftrack_ent, parent_avalon): proj, ents = self.avalon_entities @@ -1278,7 +1293,7 @@ class SyncToAvalonEvent(BaseEvent): "Processing renamed entities: {}".format(str(ent_infos)) ) - changeable_queue = queue.Queue() + changeable_queue = collections.deque() for ftrack_id, ent_info in ent_infos.items(): entity_type = ent_info["entity_type"] if entity_type == "Task": @@ -1306,7 +1321,7 @@ class SyncToAvalonEvent(BaseEvent): mongo_id = avalon_ent["_id"] if self.changeability_by_mongo_id[mongo_id]: - changeable_queue.put((ftrack_id, avalon_ent, new_name)) + changeable_queue.append((ftrack_id, avalon_ent, new_name)) else: ftrack_ent = self.ftrack_ents_by_id[ftrack_id] ftrack_ent["name"] = avalon_ent["name"] @@ -1348,8 +1363,8 @@ class SyncToAvalonEvent(BaseEvent): old_names = [] # Process renaming in Avalon DB - while not changeable_queue.empty(): - ftrack_id, avalon_ent, new_name = changeable_queue.get() + while changeable_queue: + ftrack_id, avalon_ent, new_name = changeable_queue.popleft() mongo_id = avalon_ent["_id"] old_name = avalon_ent["name"] @@ -1390,13 +1405,13 @@ class SyncToAvalonEvent(BaseEvent): # - it's name may be changed in next iteration same_name_ftrack_id = same_name_avalon_ent["data"]["ftrackId"] same_is_unprocessed = False - for item in list(changeable_queue.queue): + for item in changeable_queue: if same_name_ftrack_id == item[0]: same_is_unprocessed = True break if same_is_unprocessed: - changeable_queue.put((ftrack_id, avalon_ent, new_name)) + changeable_queue.append((ftrack_id, avalon_ent, new_name)) continue self.duplicated.append(ftrack_id) @@ -2008,12 +2023,12 @@ class SyncToAvalonEvent(BaseEvent): # ftrack_parenting = collections.defaultdict(list) entities_dict = collections.defaultdict(dict) - children_queue = queue.Queue() - parent_queue = queue.Queue() + children_queue = collections.deque() + parent_queue = collections.deque() for mongo_id in hier_cust_attrs_ids: avalon_ent = self.avalon_ents_by_id[mongo_id] - parent_queue.put(avalon_ent) + parent_queue.append(avalon_ent) ftrack_id = avalon_ent["data"]["ftrackId"] if ftrack_id not in entities_dict: entities_dict[ftrack_id] = { @@ -2040,10 +2055,10 @@ class SyncToAvalonEvent(BaseEvent): entities_dict[_ftrack_id]["parent_id"] = ftrack_id if _ftrack_id not in entities_dict[ftrack_id]["children"]: entities_dict[ftrack_id]["children"].append(_ftrack_id) - children_queue.put(children_ent) + children_queue.append(children_ent) - while not children_queue.empty(): - avalon_ent = children_queue.get() + while children_queue: + avalon_ent = children_queue.popleft() mongo_id = avalon_ent["_id"] ftrack_id = avalon_ent["data"]["ftrackId"] if ftrack_id in cust_attrs_ftrack_ids: @@ -2066,10 +2081,10 @@ class SyncToAvalonEvent(BaseEvent): entities_dict[_ftrack_id]["parent_id"] = ftrack_id if _ftrack_id not in entities_dict[ftrack_id]["children"]: entities_dict[ftrack_id]["children"].append(_ftrack_id) - children_queue.put(children_ent) + children_queue.append(children_ent) - while not parent_queue.empty(): - avalon_ent = parent_queue.get() + while parent_queue: + avalon_ent = parent_queue.popleft() if avalon_ent["type"].lower() == "project": continue @@ -2100,7 +2115,7 @@ class SyncToAvalonEvent(BaseEvent): # if ftrack_id not in ftrack_parenting[parent_ftrack_id]: # ftrack_parenting[parent_ftrack_id].append(ftrack_id) - parent_queue.put(parent_ent) + parent_queue.append(parent_ent) # Prepare values to query configuration_ids = set() @@ -2174,11 +2189,13 @@ class SyncToAvalonEvent(BaseEvent): if value is not None: project_values[key] = value - hier_down_queue = queue.Queue() - hier_down_queue.put((project_values, ftrack_project_id)) + hier_down_queue = collections.deque() + hier_down_queue.append( + (project_values, ftrack_project_id) + ) - while not hier_down_queue.empty(): - hier_values, parent_id = hier_down_queue.get() + while hier_down_queue: + hier_values, parent_id = hier_down_queue.popleft() for child_id in entities_dict[parent_id]["children"]: _hier_values = hier_values.copy() for name in hier_cust_attrs_keys: @@ -2187,7 +2204,7 @@ class SyncToAvalonEvent(BaseEvent): _hier_values[name] = value entities_dict[child_id]["hier_attrs"].update(_hier_values) - hier_down_queue.put((_hier_values, child_id)) + hier_down_queue.append((_hier_values, child_id)) ftrack_mongo_mapping = {} for mongo_id, ftrack_id in mongo_ftrack_mapping.items(): @@ -2302,11 +2319,12 @@ class SyncToAvalonEvent(BaseEvent): """ mongo_changes_bulk = [] for mongo_id, changes in self.updates.items(): - filter = {"_id": mongo_id} avalon_ent = self.avalon_ents_by_id[mongo_id] is_project = avalon_ent["type"] == "project" change_data = avalon_sync.from_dict_to_set(changes, is_project) - mongo_changes_bulk.append(UpdateOne(filter, change_data)) + mongo_changes_bulk.append( + UpdateOne({"_id": mongo_id}, change_data) + ) if not mongo_changes_bulk: return diff --git a/openpype/modules/default_modules/ftrack/event_handlers_user/action_delete_asset.py b/openpype/modules/default_modules/ftrack/event_handlers_user/action_delete_asset.py index f860065b26..d3cc0ad971 100644 --- a/openpype/modules/default_modules/ftrack/event_handlers_user/action_delete_asset.py +++ b/openpype/modules/default_modules/ftrack/event_handlers_user/action_delete_asset.py @@ -1,7 +1,6 @@ import collections import uuid from datetime import datetime -from queue import Queue from bson.objectid import ObjectId from openpype_modules.ftrack.lib import BaseAction, statics_icon @@ -473,12 +472,12 @@ class DeleteAssetSubset(BaseAction): continue ftrack_ids_to_delete.append(ftrack_id) - children_queue = Queue() + children_queue = collections.deque() for mongo_id in assets_to_delete: - children_queue.put(mongo_id) + children_queue.append(mongo_id) - while not children_queue.empty(): - mongo_id = children_queue.get() + while children_queue: + mongo_id = children_queue.popleft() if mongo_id in asset_ids_to_archive: continue @@ -494,7 +493,7 @@ class DeleteAssetSubset(BaseAction): for child in children: child_id = child["_id"] if child_id not in asset_ids_to_archive: - children_queue.put(child_id) + children_queue.append(child_id) # Prepare names of assets in ftrack and ids of subsets in mongo asset_names_to_delete = [] diff --git a/openpype/modules/default_modules/ftrack/ftrack_module.py b/openpype/modules/default_modules/ftrack/ftrack_module.py index 73a4dfee82..8a7525d65b 100644 --- a/openpype/modules/default_modules/ftrack/ftrack_module.py +++ b/openpype/modules/default_modules/ftrack/ftrack_module.py @@ -1,8 +1,10 @@ import os import json import collections -from openpype.modules import OpenPypeModule +import click + +from openpype.modules import OpenPypeModule from openpype_interfaces import ( ITrayModule, IPluginPaths, @@ -224,8 +226,8 @@ class FtrackModule( if not project_name: return - attributes_changes = changes.get("attributes") - if not attributes_changes: + new_attr_values = new_value.get("attributes") + if not new_attr_values: return import ftrack_api @@ -275,7 +277,7 @@ class FtrackModule( failed = {} missing = {} - for key, value in attributes_changes.items(): + for key, value in new_attr_values.items(): if key not in ca_keys: continue @@ -349,12 +351,24 @@ class FtrackModule( if "server_url" not in session_kwargs: session_kwargs["server_url"] = self.ftrack_url - if "api_key" not in session_kwargs or "api_user" not in session_kwargs: + api_key = session_kwargs.get("api_key") + api_user = session_kwargs.get("api_user") + # First look into environments + # - both OpenPype tray and ftrack event server should have set them + # - ftrack event server may crash when credentials are tried to load + # from keyring + if not api_key or not api_user: + api_key = os.environ.get("FTRACK_API_KEY") + api_user = os.environ.get("FTRACK_API_USER") + + if not api_key or not api_user: from .lib import credentials cred = credentials.get_credentials() - session_kwargs["api_user"] = cred.get("username") - session_kwargs["api_key"] = cred.get("api_key") + api_user = cred.get("username") + api_key = cred.get("api_key") + session_kwargs["api_user"] = api_user + session_kwargs["api_key"] = api_key return ftrack_api.Session(**session_kwargs) def tray_init(self): @@ -409,3 +423,62 @@ class FtrackModule( return 0 hours_logged = (task_entity["time_logged"] / 60) / 60 return hours_logged + + def get_credentials(self): + # type: () -> tuple + """Get local Ftrack credentials.""" + from .lib import credentials + + cred = credentials.get_credentials(self.ftrack_url) + return cred.get("username"), cred.get("api_key") + + def cli(self, click_group): + click_group.add_command(cli_main) + + +@click.group(FtrackModule.name, help="Ftrack module related commands.") +def cli_main(): + pass + + +@cli_main.command() +@click.option("-d", "--debug", is_flag=True, help="Print debug messages") +@click.option("--ftrack-url", envvar="FTRACK_SERVER", + help="Ftrack server url") +@click.option("--ftrack-user", envvar="FTRACK_API_USER", + help="Ftrack api user") +@click.option("--ftrack-api-key", envvar="FTRACK_API_KEY", + help="Ftrack api key") +@click.option("--legacy", is_flag=True, + help="run event server without mongo storing") +@click.option("--clockify-api-key", envvar="CLOCKIFY_API_KEY", + help="Clockify API key.") +@click.option("--clockify-workspace", envvar="CLOCKIFY_WORKSPACE", + help="Clockify workspace") +def eventserver( + debug, + ftrack_url, + ftrack_user, + ftrack_api_key, + legacy, + clockify_api_key, + clockify_workspace +): + """Launch ftrack event server. + + This should be ideally used by system service (such us systemd or upstart + on linux and window service). + """ + if debug: + os.environ["OPENPYPE_DEBUG"] = "3" + + from .ftrack_server.event_server_cli import run_event_server + + return run_event_server( + ftrack_url, + ftrack_user, + ftrack_api_key, + legacy, + clockify_api_key, + clockify_workspace + ) diff --git a/openpype/modules/default_modules/ftrack/lib/avalon_sync.py b/openpype/modules/default_modules/ftrack/lib/avalon_sync.py index 2458308af5..1667031f29 100644 --- a/openpype/modules/default_modules/ftrack/lib/avalon_sync.py +++ b/openpype/modules/default_modules/ftrack/lib/avalon_sync.py @@ -6,11 +6,6 @@ import copy import six -if six.PY3: - from queue import Queue -else: - from Queue import Queue - from avalon.api import AvalonMongoDB import avalon @@ -146,11 +141,11 @@ def from_dict_to_set(data, is_project): data.pop("data") result = {"$set": {}} - dict_queue = Queue() - dict_queue.put((None, data)) + dict_queue = collections.deque() + dict_queue.append((None, data)) - while not dict_queue.empty(): - _key, _data = dict_queue.get() + while dict_queue: + _key, _data = dict_queue.popleft() for key, value in _data.items(): new_key = key if _key is not None: @@ -160,7 +155,7 @@ def from_dict_to_set(data, is_project): (isinstance(value, dict) and not bool(value)): # empty dic result["$set"][new_key] = value continue - dict_queue.put((new_key, value)) + dict_queue.append((new_key, value)) if task_changes is not not_set and task_changes_key: result["$set"][task_changes_key] = task_changes @@ -714,7 +709,7 @@ class SyncEntitiesFactory: self.filter_by_duplicate_regex() def filter_by_duplicate_regex(self): - filter_queue = Queue() + filter_queue = collections.deque() failed_regex_msg = "{} - Entity has invalid symbols in the name" duplicate_msg = "There are multiple entities with the name: \"{}\":" @@ -722,18 +717,18 @@ class SyncEntitiesFactory: for id in ids: ent_path = self.get_ent_path(id) self.log.warning(failed_regex_msg.format(ent_path)) - filter_queue.put(id) + filter_queue.append(id) for name, ids in self.duplicates.items(): self.log.warning(duplicate_msg.format(name)) for id in ids: ent_path = self.get_ent_path(id) self.log.warning(ent_path) - filter_queue.put(id) + filter_queue.append(id) filtered_ids = [] - while not filter_queue.empty(): - ftrack_id = filter_queue.get() + while filter_queue: + ftrack_id = filter_queue.popleft() if ftrack_id in filtered_ids: continue @@ -749,7 +744,7 @@ class SyncEntitiesFactory: filtered_ids.append(ftrack_id) for child_id in entity_dict.get("children", []): - filter_queue.put(child_id) + filter_queue.append(child_id) for name, ids in self.tasks_failed_regex.items(): for id in ids: @@ -768,10 +763,10 @@ class SyncEntitiesFactory: ) == "_notset_": return - self.filter_queue = Queue() - self.filter_queue.put((self.ft_project_id, False)) - while not self.filter_queue.empty(): - parent_id, remove = self.filter_queue.get() + filter_queue = collections.deque() + filter_queue.append((self.ft_project_id, False)) + while filter_queue: + parent_id, remove = filter_queue.popleft() if remove: parent_dict = self.entities_dict.pop(parent_id, {}) self.all_filtered_entities[parent_id] = parent_dict @@ -790,7 +785,7 @@ class SyncEntitiesFactory: child_id ) _remove = True - self.filter_queue.put((child_id, _remove)) + filter_queue.append((child_id, _remove)) def filter_by_selection(self, event): # BUGGY!!!! cause that entities are in deleted list @@ -805,47 +800,51 @@ class SyncEntitiesFactory: selected_ids.append(entity["entityId"]) sync_ids = [self.ft_project_id] - parents_queue = Queue() - children_queue = Queue() - for id in selected_ids: + parents_queue = collections.deque() + children_queue = collections.deque() + for selected_id in selected_ids: # skip if already filtered with ignore sync custom attribute - if id in self.filtered_ids: + if selected_id in self.filtered_ids: continue - parents_queue.put(id) - children_queue.put(id) + parents_queue.append(selected_id) + children_queue.append(selected_id) - while not parents_queue.empty(): - id = parents_queue.get() + while parents_queue: + ftrack_id = parents_queue.popleft() while True: # Stops when parent is in sync_ids - if id in self.filtered_ids or id in sync_ids or id is None: + if ( + ftrack_id in self.filtered_ids + or ftrack_id in sync_ids + or ftrack_id is None + ): break - sync_ids.append(id) - id = self.entities_dict[id]["parent_id"] + sync_ids.append(ftrack_id) + ftrack_id = self.entities_dict[ftrack_id]["parent_id"] - while not children_queue.empty(): - parent_id = children_queue.get() + while children_queue: + parent_id = children_queue.popleft() for child_id in self.entities_dict[parent_id]["children"]: if child_id in sync_ids or child_id in self.filtered_ids: continue sync_ids.append(child_id) - children_queue.put(child_id) + children_queue.append(child_id) # separate not selected and to process entities for key, value in self.entities_dict.items(): if key not in sync_ids: self.not_selected_ids.append(key) - for id in self.not_selected_ids: + for ftrack_id in self.not_selected_ids: # pop from entities - value = self.entities_dict.pop(id) + value = self.entities_dict.pop(ftrack_id) # remove entity from parent's children parent_id = value["parent_id"] if parent_id not in sync_ids: continue - self.entities_dict[parent_id]["children"].remove(id) + self.entities_dict[parent_id]["children"].remove(ftrack_id) def _query_custom_attributes(self, session, conf_ids, entity_ids): output = [] @@ -1117,11 +1116,11 @@ class SyncEntitiesFactory: if value is not None: project_values[key] = value - hier_down_queue = Queue() - hier_down_queue.put((project_values, top_id)) + hier_down_queue = collections.deque() + hier_down_queue.append((project_values, top_id)) - while not hier_down_queue.empty(): - hier_values, parent_id = hier_down_queue.get() + while hier_down_queue: + hier_values, parent_id = hier_down_queue.popleft() for child_id in self.entities_dict[parent_id]["children"]: _hier_values = copy.deepcopy(hier_values) for key in attributes_by_key.keys(): @@ -1134,7 +1133,7 @@ class SyncEntitiesFactory: _hier_values[key] = value self.entities_dict[child_id]["hier_attrs"].update(_hier_values) - hier_down_queue.put((_hier_values, child_id)) + hier_down_queue.append((_hier_values, child_id)) def remove_from_archived(self, mongo_id): entity = self.avalon_archived_by_id.pop(mongo_id, None) @@ -1303,15 +1302,15 @@ class SyncEntitiesFactory: create_ftrack_ids.append(self.ft_project_id) # make it go hierarchically - prepare_queue = Queue() + prepare_queue = collections.deque() for child_id in self.entities_dict[self.ft_project_id]["children"]: - prepare_queue.put(child_id) + prepare_queue.append(child_id) - while not prepare_queue.empty(): - ftrack_id = prepare_queue.get() + while prepare_queue: + ftrack_id = prepare_queue.popleft() for child_id in self.entities_dict[ftrack_id]["children"]: - prepare_queue.put(child_id) + prepare_queue.append(child_id) entity_dict = self.entities_dict[ftrack_id] ent_path = self.get_ent_path(ftrack_id) @@ -1426,25 +1425,25 @@ class SyncEntitiesFactory: parent_id = ent_dict["parent_id"] self.entities_dict[parent_id]["children"].remove(ftrack_id) - children_queue = Queue() - children_queue.put(ftrack_id) - while not children_queue.empty(): - _ftrack_id = children_queue.get() + children_queue = collections.deque() + children_queue.append(ftrack_id) + while children_queue: + _ftrack_id = children_queue.popleft() entity_dict = self.entities_dict.pop(_ftrack_id, {"children": []}) for child_id in entity_dict["children"]: - children_queue.put(child_id) + children_queue.append(child_id) def prepare_changes(self): self.log.debug("* Preparing changes for avalon/ftrack") hierarchy_changing_ids = [] ignore_keys = collections.defaultdict(list) - update_queue = Queue() + update_queue = collections.deque() for ftrack_id in self.update_ftrack_ids: - update_queue.put(ftrack_id) + update_queue.append(ftrack_id) - while not update_queue.empty(): - ftrack_id = update_queue.get() + while update_queue: + ftrack_id = update_queue.popleft() if ftrack_id == self.ft_project_id: changes = self.prepare_project_changes() if changes: @@ -1720,7 +1719,7 @@ class SyncEntitiesFactory: new_entity_id = self.create_ftrack_ent_from_avalon_ent( av_entity, parent_id ) - update_queue.put(new_entity_id) + update_queue.append(new_entity_id) if new_entity_id: ftrack_ent_dict["entity"]["parent_id"] = new_entity_id @@ -2024,14 +2023,14 @@ class SyncEntitiesFactory: entity["custom_attributes"][CUST_ATTR_ID_KEY] = str(new_id) def _bubble_changeability(self, unchangeable_ids): - unchangeable_queue = Queue() + unchangeable_queue = collections.deque() for entity_id in unchangeable_ids: - unchangeable_queue.put((entity_id, False)) + unchangeable_queue.append((entity_id, False)) processed_parents_ids = [] subsets_to_remove = [] - while not unchangeable_queue.empty(): - entity_id, child_is_archived = unchangeable_queue.get() + while unchangeable_queue: + entity_id, child_is_archived = unchangeable_queue.popleft() # skip if already processed if entity_id in processed_parents_ids: continue @@ -2067,7 +2066,9 @@ class SyncEntitiesFactory: parent_id = entity["data"]["visualParent"] if parent_id is None: continue - unchangeable_queue.put((str(parent_id), child_is_archived)) + unchangeable_queue.append( + (str(parent_id), child_is_archived) + ) self._delete_subsets_without_asset(subsets_to_remove) @@ -2150,16 +2151,18 @@ class SyncEntitiesFactory: self.dbcon.bulk_write(mongo_changes_bulk) def reload_parents(self, hierarchy_changing_ids): - parents_queue = Queue() - parents_queue.put((self.ft_project_id, [], False)) - while not parents_queue.empty(): - ftrack_id, parent_parents, changed = parents_queue.get() + parents_queue = collections.deque() + parents_queue.append((self.ft_project_id, [], False)) + while parents_queue: + ftrack_id, parent_parents, changed = parents_queue.popleft() _parents = copy.deepcopy(parent_parents) if ftrack_id not in hierarchy_changing_ids and not changed: if ftrack_id != self.ft_project_id: _parents.append(self.entities_dict[ftrack_id]["name"]) for child_id in self.entities_dict[ftrack_id]["children"]: - parents_queue.put((child_id, _parents, changed)) + parents_queue.append( + (child_id, _parents, changed) + ) continue changed = True @@ -2170,7 +2173,9 @@ class SyncEntitiesFactory: _parents.append(self.entities_dict[ftrack_id]["name"]) for child_id in self.entities_dict[ftrack_id]["children"]: - parents_queue.put((child_id, _parents, changed)) + parents_queue.append( + (child_id, _parents, changed) + ) if ftrack_id in self.create_ftrack_ids: mongo_id = self.ftrack_avalon_mapper[ftrack_id] diff --git a/openpype/modules/default_modules/ftrack/lib/ftrack_base_handler.py b/openpype/modules/default_modules/ftrack/lib/ftrack_base_handler.py index a457b886ac..2130abc20c 100644 --- a/openpype/modules/default_modules/ftrack/lib/ftrack_base_handler.py +++ b/openpype/modules/default_modules/ftrack/lib/ftrack_base_handler.py @@ -570,9 +570,15 @@ class BaseHandler(object): if low_entity_type == "assetversion": asset = entity["asset"] + parent = None if asset: parent = asset["parent"] - if parent: + + if parent: + if parent.entity_type.lower() == "project": + return parent + + if "project" in parent: return parent["project"] project_data = entity["link"][0] diff --git a/openpype/modules/default_modules/ftrack/plugins/publish/collect_local_ftrack_creds.py b/openpype/modules/default_modules/ftrack/plugins/publish/collect_local_ftrack_creds.py new file mode 100644 index 0000000000..2093ebf18a --- /dev/null +++ b/openpype/modules/default_modules/ftrack/plugins/publish/collect_local_ftrack_creds.py @@ -0,0 +1,23 @@ +# -*- coding: utf-8 -*- +"""Collect default Deadline server.""" +import pyblish.api +import os + + +class CollectLocalFtrackCreds(pyblish.api.ContextPlugin): + """Collect default Royal Render path.""" + + order = pyblish.api.CollectorOrder + 0.01 + label = "Collect local ftrack credentials" + targets = ["rr_control"] + + def process(self, context): + if os.getenv("FTRACK_API_USER") and os.getenv("FTRACK_API_KEY") and \ + os.getenv("FTRACK_SERVER"): + return + ftrack_module = context.data["openPypeModules"]["ftrack"] + if ftrack_module.enabled: + creds = ftrack_module.get_credentials() + os.environ["FTRACK_API_USER"] = creds[0] + os.environ["FTRACK_API_KEY"] = creds[1] + os.environ["FTRACK_SERVER"] = ftrack_module.ftrack_url diff --git a/openpype/modules/default_modules/ftrack/plugins/publish/collect_username.py b/openpype/modules/default_modules/ftrack/plugins/publish/collect_username.py index 39b7433e11..a5187dd52b 100644 --- a/openpype/modules/default_modules/ftrack/plugins/publish/collect_username.py +++ b/openpype/modules/default_modules/ftrack/plugins/publish/collect_username.py @@ -26,14 +26,17 @@ class CollectUsername(pyblish.api.ContextPlugin): """ order = pyblish.api.CollectorOrder - 0.488 label = "Collect ftrack username" - hosts = ["webpublisher"] + hosts = ["webpublisher", "photoshop"] + targets = ["remotepublish", "filespublish"] _context = None def process(self, context): + self.log.info("CollectUsername") + os.environ["FTRACK_API_USER"] = os.environ["FTRACK_BOT_API_USER"] os.environ["FTRACK_API_KEY"] = os.environ["FTRACK_BOT_API_KEY"] - self.log.info("CollectUsername") + for instance in context: email = instance.data["user_email"] self.log.info("email:: {}".format(email)) diff --git a/openpype/modules/default_modules/python_console_interpreter/window/widgets.py b/openpype/modules/default_modules/python_console_interpreter/window/widgets.py index 0e8dd2fb9b..ecf41eaf3e 100644 --- a/openpype/modules/default_modules/python_console_interpreter/window/widgets.py +++ b/openpype/modules/default_modules/python_console_interpreter/window/widgets.py @@ -176,6 +176,7 @@ class PythonCodeEditor(QtWidgets.QPlainTextEdit): class PythonTabWidget(QtWidgets.QWidget): + add_tab_requested = QtCore.Signal() before_execute = QtCore.Signal(str) def __init__(self, parent): @@ -185,11 +186,15 @@ class PythonTabWidget(QtWidgets.QWidget): self.setFocusProxy(code_input) + add_tab_btn = QtWidgets.QPushButton("Add tab...", self) + add_tab_btn.setToolTip("Add new tab") + execute_btn = QtWidgets.QPushButton("Execute", self) execute_btn.setToolTip("Execute command (Ctrl + Enter)") btns_layout = QtWidgets.QHBoxLayout() btns_layout.setContentsMargins(0, 0, 0, 0) + btns_layout.addWidget(add_tab_btn) btns_layout.addStretch(1) btns_layout.addWidget(execute_btn) @@ -198,12 +203,16 @@ class PythonTabWidget(QtWidgets.QWidget): layout.addWidget(code_input, 1) layout.addLayout(btns_layout, 0) + add_tab_btn.clicked.connect(self._on_add_tab_clicked) execute_btn.clicked.connect(self._on_execute_clicked) code_input.execute_requested.connect(self.execute) self._code_input = code_input self._interpreter = InteractiveInterpreter() + def _on_add_tab_clicked(self): + self.add_tab_requested.emit() + def _on_execute_clicked(self): self.execute() @@ -352,9 +361,6 @@ class PythonInterpreterWidget(QtWidgets.QWidget): tab_widget.setTabsClosable(False) tab_widget.setContextMenuPolicy(QtCore.Qt.CustomContextMenu) - add_tab_btn = QtWidgets.QPushButton("+", tab_widget) - tab_widget.setCornerWidget(add_tab_btn, QtCore.Qt.TopLeftCorner) - widgets_splitter = QtWidgets.QSplitter(self) widgets_splitter.setOrientation(QtCore.Qt.Vertical) widgets_splitter.addWidget(output_widget) @@ -371,14 +377,12 @@ class PythonInterpreterWidget(QtWidgets.QWidget): line_check_timer.setInterval(200) line_check_timer.timeout.connect(self._on_timer_timeout) - add_tab_btn.clicked.connect(self._on_add_clicked) tab_bar.right_clicked.connect(self._on_tab_right_click) tab_bar.double_clicked.connect(self._on_tab_double_click) tab_bar.mid_clicked.connect(self._on_tab_mid_click) tab_widget.tabCloseRequested.connect(self._on_tab_close_req) self._widgets_splitter = widgets_splitter - self._add_tab_btn = add_tab_btn self._output_widget = output_widget self._tab_widget = tab_widget self._line_check_timer = line_check_timer @@ -459,14 +463,41 @@ class PythonInterpreterWidget(QtWidgets.QWidget): return menu = QtWidgets.QMenu(self._tab_widget) - menu.addAction("Rename") + + add_tab_action = QtWidgets.QAction("Add tab...", menu) + add_tab_action.setToolTip("Add new tab") + + rename_tab_action = QtWidgets.QAction("Rename...", menu) + rename_tab_action.setToolTip("Rename tab") + + duplicate_tab_action = QtWidgets.QAction("Duplicate...", menu) + duplicate_tab_action.setToolTip("Duplicate code to new tab") + + close_tab_action = QtWidgets.QAction("Close", menu) + close_tab_action.setToolTip("Close tab and lose content") + close_tab_action.setEnabled(self._tab_widget.tabsClosable()) + + menu.addAction(add_tab_action) + menu.addAction(rename_tab_action) + menu.addAction(duplicate_tab_action) + menu.addAction(close_tab_action) + result = menu.exec_(global_point) if result is None: return - if result.text() == "Rename": + if result is rename_tab_action: self._rename_tab_req(tab_idx) + elif result is add_tab_action: + self._on_add_requested() + + elif result is duplicate_tab_action: + self._duplicate_requested(tab_idx) + + elif result is close_tab_action: + self._on_tab_close_req(tab_idx) + def _rename_tab_req(self, tab_idx): dialog = TabNameDialog(self) dialog.set_tab_name(self._tab_widget.tabText(tab_idx)) @@ -475,6 +506,16 @@ class PythonInterpreterWidget(QtWidgets.QWidget): if tab_name: self._tab_widget.setTabText(tab_idx, tab_name) + def _duplicate_requested(self, tab_idx=None): + if tab_idx is None: + tab_idx = self._tab_widget.currentIndex() + + src_widget = self._tab_widget.widget(tab_idx) + dst_widget = self._add_tab() + if dst_widget is None: + return + dst_widget.set_code(src_widget.get_code()) + def _on_tab_mid_click(self, global_point): point = self._tab_widget.mapFromGlobal(global_point) tab_bar = self._tab_widget.tabBar() @@ -525,12 +566,17 @@ class PythonInterpreterWidget(QtWidgets.QWidget): lines.append(self.ansi_escape.sub("", line)) self._append_lines(lines) - def _on_add_clicked(self): + def _on_add_requested(self): + self._add_tab() + + def _add_tab(self): dialog = TabNameDialog(self) dialog.exec_() tab_name = dialog.result() if tab_name: - self.add_tab(tab_name) + return self.add_tab(tab_name) + + return None def _on_before_execute(self, code_text): at_max = self._output_widget.vertical_scroll_at_max() @@ -562,6 +608,7 @@ class PythonInterpreterWidget(QtWidgets.QWidget): def add_tab(self, tab_name, index=None): widget = PythonTabWidget(self) widget.before_execute.connect(self._on_before_execute) + widget.add_tab_requested.connect(self._on_add_requested) if index is None: if self._tab_widget.count() > 0: index = self._tab_widget.currentIndex() + 1 diff --git a/openpype/modules/default_modules/royal_render/__init__.py b/openpype/modules/default_modules/royal_render/__init__.py new file mode 100644 index 0000000000..cc92e3b50d --- /dev/null +++ b/openpype/modules/default_modules/royal_render/__init__.py @@ -0,0 +1,6 @@ +from .royal_render_module import RoyalRenderModule + + +__all__ = ( + "RoyalRenderModule", +) diff --git a/openpype/modules/default_modules/royal_render/api.py b/openpype/modules/default_modules/royal_render/api.py new file mode 100644 index 0000000000..ed9e71f240 --- /dev/null +++ b/openpype/modules/default_modules/royal_render/api.py @@ -0,0 +1,199 @@ +# -*- coding: utf-8 -*- +"""Wrapper around Royal Render API.""" +import sys +import os + +from openpype.settings import get_project_settings +from openpype.lib.local_settings import OpenPypeSettingsRegistry +from openpype.lib import PypeLogger, run_subprocess +from .rr_job import RRJob, SubmitFile, SubmitterParameter + + +log = PypeLogger.get_logger("RoyalRender") + + +class Api: + + _settings = None + RR_SUBMIT_CONSOLE = 1 + RR_SUBMIT_API = 2 + + def __init__(self, settings, project=None): + self._settings = settings + self._initialize_rr(project) + + def _initialize_rr(self, project=None): + # type: (str) -> None + """Initialize RR Path. + + Args: + project (str, Optional): Project name to set RR api in + context. + + """ + if project: + project_settings = get_project_settings(project) + rr_path = ( + project_settings + ["royalrender"] + ["rr_paths"] + ) + else: + rr_path = ( + self._settings + ["modules"] + ["royalrender"] + ["rr_path"] + ["default"] + ) + os.environ["RR_ROOT"] = rr_path + self._rr_path = rr_path + + def _get_rr_bin_path(self, rr_root=None): + # type: (str) -> str + """Get path to RR bin folder.""" + rr_root = rr_root or self._rr_path + is_64bit_python = sys.maxsize > 2 ** 32 + + rr_bin_path = "" + if sys.platform.lower() == "win32": + rr_bin_path = "/bin/win64" + if not is_64bit_python: + # we are using 64bit python + rr_bin_path = "/bin/win" + rr_bin_path = rr_bin_path.replace( + "/", os.path.sep + ) + + if sys.platform.lower() == "darwin": + rr_bin_path = "/bin/mac64" + if not is_64bit_python: + rr_bin_path = "/bin/mac" + + if sys.platform.lower() == "linux": + rr_bin_path = "/bin/lx64" + + return os.path.join(rr_root, rr_bin_path) + + def _initialize_module_path(self): + # type: () -> None + """Set RR modules for Python.""" + # default for linux + rr_bin = self._get_rr_bin_path() + rr_module_path = os.path.join(rr_bin, "lx64/lib") + + if sys.platform.lower() == "win32": + rr_module_path = rr_bin + rr_module_path = rr_module_path.replace( + "/", os.path.sep + ) + + if sys.platform.lower() == "darwin": + rr_module_path = os.path.join(rr_bin, "lib/python/27") + + sys.path.append(os.path.join(self._rr_path, rr_module_path)) + + def create_submission(self, jobs, submitter_attributes, file_name=None): + # type: (list[RRJob], list[SubmitterParameter], str) -> SubmitFile + """Create jobs submission file. + + Args: + jobs (list): List of :class:`RRJob` + submitter_attributes (list): List of submitter attributes + :class:`SubmitterParameter` for whole submission batch. + file_name (str), optional): File path to write data to. + + Returns: + str: XML data of job submission files. + + """ + raise NotImplementedError + + def submit_file(self, file, mode=RR_SUBMIT_CONSOLE): + # type: (SubmitFile, int) -> None + if mode == self.RR_SUBMIT_CONSOLE: + self._submit_using_console(file) + + # RR v7 supports only Python 2.7 so we bail out in fear + # until there is support for Python 3 😰 + raise NotImplementedError( + "Submission via RoyalRender API is not supported yet") + # self._submit_using_api(file) + + def _submit_using_console(self, file): + # type: (SubmitFile) -> bool + rr_console = os.path.join( + self._get_rr_bin_path(), + "rrSubmitterconsole" + ) + + if sys.platform.lower() == "darwin": + if "/bin/mac64" in rr_console: + rr_console = rr_console.replace("/bin/mac64", "/bin/mac") + + if sys.platform.lower() == "win32": + if "/bin/win64" in rr_console: + rr_console = rr_console.replace("/bin/win64", "/bin/win") + rr_console += ".exe" + + args = [rr_console, file] + run_subprocess(" ".join(args), logger=log) + + def _submit_using_api(self, file): + # type: (SubmitFile) -> None + """Use RR API to submit jobs. + + Args: + file (SubmitFile): Submit jobs definition. + + Throws: + RoyalRenderException: When something fails. + + """ + self._initialize_module_path() + import libpyRR2 as rrLib # noqa + from rrJob import getClass_JobBasics # noqa + import libpyRR2 as _RenderAppBasic # noqa + + tcp = rrLib._rrTCP("") # noqa + rr_server = tcp.getRRServer() + + if len(rr_server) == 0: + log.info("Got RR IP address {}".format(rr_server)) + + # TODO: Port is hardcoded in RR? If not, move it to Settings + if not tcp.setServer(rr_server, 7773): + log.error( + "Can not set RR server: {}".format(tcp.errorMessage())) + raise RoyalRenderException(tcp.errorMessage()) + + # TODO: This need UI and better handling of username/password. + # We can't store password in keychain as it is pulled multiple + # times and users on linux must enter keychain password every time. + # Probably best way until we setup our own user management would be + # to encrypt password and save it to json locally. Not bulletproof + # but at least it is not stored in plaintext. + reg = OpenPypeSettingsRegistry() + try: + rr_user = reg.get_item("rr_username") + rr_password = reg.get_item("rr_password") + except ValueError: + # user has no rr credentials set + pass + else: + # login to RR + tcp.setLogin(rr_user, rr_password) + + job = getClass_JobBasics() + renderer = _RenderAppBasic() + + # iterate over SubmitFile, set _JobBasic (job) and renderer + # and feed it to jobSubmitNew() + # not implemented yet + job.renderer = renderer + tcp.jobSubmitNew(job) + + +class RoyalRenderException(Exception): + """Exception used in various error states coming from RR.""" + pass diff --git a/openpype/modules/default_modules/royal_render/plugins/publish/collect_default_rr_path.py b/openpype/modules/default_modules/royal_render/plugins/publish/collect_default_rr_path.py new file mode 100644 index 0000000000..cdca03bef0 --- /dev/null +++ b/openpype/modules/default_modules/royal_render/plugins/publish/collect_default_rr_path.py @@ -0,0 +1,23 @@ +# -*- coding: utf-8 -*- +"""Collect default Deadline server.""" +import pyblish.api + + +class CollectDefaultRRPath(pyblish.api.ContextPlugin): + """Collect default Royal Render path.""" + + order = pyblish.api.CollectorOrder + 0.01 + label = "Default Royal Render Path" + + def process(self, context): + try: + rr_module = context.data.get( + "openPypeModules")["royalrender"] + except AttributeError: + msg = "Cannot get OpenPype Royal Render module." + self.log.error(msg) + raise AssertionError(msg) + + # get default deadline webservice url from deadline module + self.log.debug(rr_module.rr_paths) + context.data["defaultRRPath"] = rr_module.rr_paths["default"] # noqa: E501 diff --git a/openpype/modules/default_modules/royal_render/plugins/publish/collect_rr_path_from_instance.py b/openpype/modules/default_modules/royal_render/plugins/publish/collect_rr_path_from_instance.py new file mode 100644 index 0000000000..fb27a76d11 --- /dev/null +++ b/openpype/modules/default_modules/royal_render/plugins/publish/collect_rr_path_from_instance.py @@ -0,0 +1,49 @@ +# -*- coding: utf-8 -*- +import pyblish.api + + +class CollectRRPathFromInstance(pyblish.api.InstancePlugin): + """Collect RR Path from instance.""" + + order = pyblish.api.CollectorOrder + label = "Royal Render Path from the Instance" + families = ["rendering"] + + def process(self, instance): + instance.data["rrPath"] = self._collect_rr_path(instance) + self.log.info( + "Using {} for submission.".format(instance.data["rrPath"])) + + @staticmethod + def _collect_rr_path(render_instance): + # type: (pyblish.api.Instance) -> str + """Get Royal Render path from render instance.""" + rr_settings = ( + render_instance.context.data + ["system_settings"] + ["modules"] + ["royalrender"] + ) + try: + default_servers = rr_settings["rr_paths"] + project_servers = ( + render_instance.context.data + ["project_settings"] + ["royalrender"] + ["rr_paths"] + ) + rr_servers = { + k: default_servers[k] + for k in project_servers + if k in default_servers + } + + except AttributeError: + # Handle situation were we had only one url for deadline. + return render_instance.context.data["defaultRRPath"] + + return rr_servers[ + list(rr_servers.keys())[ + int(render_instance.data.get("rrPaths")) + ] + ] diff --git a/openpype/modules/default_modules/royal_render/plugins/publish/collect_sequences_from_job.py b/openpype/modules/default_modules/royal_render/plugins/publish/collect_sequences_from_job.py new file mode 100644 index 0000000000..d2754d1f92 --- /dev/null +++ b/openpype/modules/default_modules/royal_render/plugins/publish/collect_sequences_from_job.py @@ -0,0 +1,209 @@ +# -*- coding: utf-8 -*- +"""Collect sequences from Royal Render Job.""" +import os +import re +import copy +import json +from pprint import pformat + +import pyblish.api +from avalon import api + + +def collect(root, + regex=None, + exclude_regex=None, + frame_start=None, + frame_end=None): + """Collect sequence collections in root""" + + from avalon.vendor import clique + + files = [] + for filename in os.listdir(root): + + # Must have extension + ext = os.path.splitext(filename)[1] + if not ext: + continue + + # Only files + if not os.path.isfile(os.path.join(root, filename)): + continue + + # Include and exclude regex + if regex and not re.search(regex, filename): + continue + if exclude_regex and re.search(exclude_regex, filename): + continue + + files.append(filename) + + # Match collections + # Support filenames like: projectX_shot01_0010.tiff with this regex + pattern = r"(?P(?P0*)\d+)\.\D+\d?$" + collections, remainder = clique.assemble(files, + patterns=[pattern], + minimum_items=1) + + # Ignore any remainders + if remainder: + print("Skipping remainder {}".format(remainder)) + + # Exclude any frames outside start and end frame. + for collection in collections: + for index in list(collection.indexes): + if frame_start is not None and index < frame_start: + collection.indexes.discard(index) + continue + if frame_end is not None and index > frame_end: + collection.indexes.discard(index) + continue + + # Keep only collections that have at least a single frame + collections = [c for c in collections if c.indexes] + + return collections + + +class CollectSequencesFromJob(pyblish.api.ContextPlugin): + """Gather file sequences from job directory. + + When "OPENPYPE_PUBLISH_DATA" environment variable is set these paths + (folders or .json files) are parsed for image sequences. Otherwise the + current working directory is searched for file sequences. + + """ + order = pyblish.api.CollectorOrder + targets = ["rr_control"] + label = "Collect Rendered Frames" + + def process(self, context): + if os.environ.get("OPENPYPE_PUBLISH_DATA"): + self.log.debug(os.environ.get("OPENPYPE_PUBLISH_DATA")) + paths = os.environ["OPENPYPE_PUBLISH_DATA"].split(os.pathsep) + self.log.info("Collecting paths: {}".format(paths)) + else: + cwd = context.get("workspaceDir", os.getcwd()) + paths = [cwd] + + for path in paths: + + self.log.info("Loading: {}".format(path)) + + if path.endswith(".json"): + # Search using .json configuration + with open(path, "r") as f: + try: + data = json.load(f) + except Exception as exc: + self.log.error("Error loading json: " + "{} - Exception: {}".format(path, exc)) + raise + + cwd = os.path.dirname(path) + root_override = data.get("root") + if root_override: + if os.path.isabs(root_override): + root = root_override + else: + root = os.path.join(cwd, root_override) + else: + root = cwd + + metadata = data.get("metadata") + if metadata: + session = metadata.get("session") + if session: + self.log.info("setting session using metadata") + api.Session.update(session) + os.environ.update(session) + + else: + # Search in directory + data = {} + root = path + + self.log.info("Collecting: {}".format(root)) + regex = data.get("regex") + if regex: + self.log.info("Using regex: {}".format(regex)) + + collections = collect(root=root, + regex=regex, + exclude_regex=data.get("exclude_regex"), + frame_start=data.get("frameStart"), + frame_end=data.get("frameEnd")) + + self.log.info("Found collections: {}".format(collections)) + + if data.get("subset") and len(collections) > 1: + self.log.error("Forced subset can only work with a single " + "found sequence") + raise RuntimeError("Invalid sequence") + + fps = data.get("fps", 25) + + # Get family from the data + families = data.get("families", ["render"]) + if "render" not in families: + families.append("render") + if "ftrack" not in families: + families.append("ftrack") + if "review" not in families: + families.append("review") + + for collection in collections: + instance = context.create_instance(str(collection)) + self.log.info("Collection: %s" % list(collection)) + + # Ensure each instance gets a unique reference to the data + data = copy.deepcopy(data) + + # If no subset provided, get it from collection's head + subset = data.get("subset", collection.head.rstrip("_. ")) + + # If no start or end frame provided, get it from collection + indices = list(collection.indexes) + start = data.get("frameStart", indices[0]) + end = data.get("frameEnd", indices[-1]) + + # root = os.path.normpath(root) + # self.log.info("Source: {}}".format(data.get("source", ""))) + + ext = list(collection)[0].split('.')[-1] + + instance.data.update({ + "name": str(collection), + "family": families[0], # backwards compatibility / pyblish + "families": list(families), + "subset": subset, + "asset": data.get("asset", api.Session["AVALON_ASSET"]), + "stagingDir": root, + "frameStart": start, + "frameEnd": end, + "fps": fps, + "source": data.get('source', '') + }) + instance.append(collection) + instance.context.data['fps'] = fps + + if "representations" not in instance.data: + instance.data["representations"] = [] + + representation = { + 'name': ext, + 'ext': '{}'.format(ext), + 'files': list(collection), + "stagingDir": root, + "anatomy_template": "render", + "fps": fps, + "tags": ['review'] + } + instance.data["representations"].append(representation) + + if data.get('user'): + context.data["user"] = data['user'] + + self.log.debug("Collected instance:\n" + "{}".format(pformat(instance.data))) diff --git a/openpype/modules/default_modules/royal_render/royal_render_module.py b/openpype/modules/default_modules/royal_render/royal_render_module.py new file mode 100644 index 0000000000..4f72860ad6 --- /dev/null +++ b/openpype/modules/default_modules/royal_render/royal_render_module.py @@ -0,0 +1,46 @@ +# -*- coding: utf-8 -*- +"""Module providing support for Royal Render.""" +import os +import openpype.modules +from openpype.modules import OpenPypeModule +from openpype_interfaces import IPluginPaths + + +class RoyalRenderModule(OpenPypeModule, IPluginPaths): + """Class providing basic Royal Render implementation logic.""" + name = "royalrender" + + @property + def api(self): + if not self._api: + # import royal render modules + from . import api as rr_api + self._api = rr_api.Api(self.settings) + + return self._api + + def __init__(self, manager, settings): + # type: (openpype.modules.base.ModulesManager, dict) -> None + self.rr_paths = {} + self._api = None + self.settings = settings + super(RoyalRenderModule, self).__init__(manager, settings) + + def initialize(self, module_settings): + # type: (dict) -> None + rr_settings = module_settings[self.name] + self.enabled = rr_settings["enabled"] + self.rr_paths = rr_settings.get("rr_paths") + + @staticmethod + def get_plugin_paths(): + # type: () -> dict + """Royal Render plugin paths. + + Returns: + dict: Dictionary of plugin paths for RR. + """ + current_dir = os.path.dirname(os.path.abspath(__file__)) + return { + "publish": [os.path.join(current_dir, "plugins", "publish")] + } diff --git a/openpype/modules/default_modules/royal_render/rr_job.py b/openpype/modules/default_modules/royal_render/rr_job.py new file mode 100644 index 0000000000..c660eceac7 --- /dev/null +++ b/openpype/modules/default_modules/royal_render/rr_job.py @@ -0,0 +1,256 @@ +# -*- coding: utf-8 -*- +"""Python wrapper for RoyalRender XML job file.""" +from xml.dom import minidom as md +import attr +from collections import namedtuple, OrderedDict + + +CustomAttribute = namedtuple("CustomAttribute", ["name", "value"]) + + +@attr.s +class RRJob: + """Mapping of Royal Render job file to a data class.""" + + # Required + # -------- + + # Name of your render application. Same as in the render config file. + # (Maya, Softimage) + Software = attr.ib() # type: str + + # The OS the scene was created on, all texture paths are set on + # that OS. Possible values are windows, linux, osx + SceneOS = attr.ib() # type: str + + # Renderer you use. Same as in the render config file + # (VRay, Mental Ray, Arnold) + Renderer = attr.ib() # type: str + + # Version you want to render with. (5.11, 2010, 12) + Version = attr.ib() # type: str + + # Name of the scene file with full path. + SceneName = attr.ib() # type: str + + # Is the job enabled for submission? + # enabled by default + IsActive = attr.ib() # type: str + + # Sequence settings of this job + SeqStart = attr.ib() # type: int + SeqEnd = attr.ib() # type: int + SeqStep = attr.ib() # type: int + SeqFileOffset = attr.ib() # type: int + + # If you specify ImageDir, then ImageFilename has no path. If you do + # NOT specify ImageDir, then ImageFilename has to include the path. + # Same for ImageExtension. + # Important: Do not forget any _ or . in front or after the frame + # numbering. Usually ImageExtension always starts with a . (.tga, .exr) + ImageDir = attr.ib() # type: str + ImageFilename = attr.ib() # type: str + ImageExtension = attr.ib() # type: str + + # Some applications always add a . or _ in front of the frame number. + # Set this variable to that character. The user can then change + # the filename at the rrSubmitter and the submitter keeps + # track of this character. + ImagePreNumberLetter = attr.ib() # type: str + + # If you render a single file, e.g. Quicktime or Avi, then you have to + # set this value. Videos have to be rendered at once on one client. + ImageSingleOutputFile = attr.ib(default="false") # type: str + + # Semi-Required (required for some render applications) + # ----------------------------------------------------- + + # The database of your scene file. In Maya and XSI called "project", + # in Lightwave "content dir" + SceneDatabaseDir = attr.ib(default=None) # type: str + + # Required if you want to split frames on multiple clients + ImageWidth = attr.ib(default=None) # type: int + ImageHeight = attr.ib(default=None) # type: int + Camera = attr.ib(default=None) # type: str + Layer = attr.ib(default=None) # type: str + Channel = attr.ib(default=None) # type: str + + # Optional + # -------- + + # Used for the RR render license function. + # E.g. If you render with mentalRay, then add mentalRay. If you render + # with Nuke and you use Furnace plugins in your comp, add Furnace. + # TODO: determine how this work for multiple plugins + RequiredPlugins = attr.ib(default=None) # type: str + + # Frame Padding of the frame number in the rendered filename. + # Some render config files are setting the padding at render time. + ImageFramePadding = attr.ib(default=None) # type: str + + # Some render applications support overriding the image format at + # the render commandline. + OverrideImageFormat = attr.ib(default=None) # type: str + + # rrControl can display the name of additonal channels that are + # rendered. Each channel requires these two values. ChannelFilename + # contains the full path. + ChannelFilename = attr.ib(default=None) # type: str + ChannelExtension = attr.ib(default=None) # type: str + + # A value between 0 and 255. Each job gets the Pre ID attached as small + # letter to the main ID. A new main ID is generated for every machine + # for every 5/1000s. + PreID = attr.ib(default=None) # type: int + + # When the job is received by the server, the server checks for other + # jobs send from this machine. If a job with the PreID was found, then + # this jobs waits for the other job. Note: This flag can be used multiple + # times to wait for multiple jobs. + WaitForPreID = attr.ib(default=None) # type: int + + # List of submitter options per job + # list item must be of `SubmitterParameter` type + SubmitterParameters = attr.ib(factory=list) # type: list + + # List of Custom job attributes + # Royal Render support custom attributes in format or + # + # list item must be of `CustomAttribute` named tuple + CustomAttributes = attr.ib(factory=list) # type: list + + # Additional information for subsequent publish script and + # for better display in rrControl + UserName = attr.ib(default=None) # type: str + CustomSeQName = attr.ib(default=None) # type: str + CustomSHotName = attr.ib(default=None) # type: str + CustomVersionName = attr.ib(default=None) # type: str + CustomUserInfo = attr.ib(default=None) # type: str + SubmitMachine = attr.ib(default=None) # type: str + Color_ID = attr.ib(default=2) # type: int + + RequiredLicenses = attr.ib(default=None) # type: str + + # Additional frame info + Priority = attr.ib(default=50) # type: int + TotalFrames = attr.ib(default=None) # type: int + Tiled = attr.ib(default=None) # type: str + + +class SubmitterParameter: + """Wrapper for Submitter Parameters.""" + def __init__(self, parameter, *args): + # type: (str, list) -> None + self._parameter = parameter + self._values = args + + def serialize(self): + # type: () -> str + """Serialize submitter parameter as a string value. + + This can be later on used as text node in job xml file. + + Returns: + str: concatenated string of parameter values. + + """ + return '"{param}={val}"'.format( + param=self._parameter, val="~".join(self._values)) + + +@attr.s +class SubmitFile: + """Class wrapping Royal Render submission XML file.""" + + # Syntax version of the submission file. + syntax_version = attr.ib(default="6.0") # type: str + + # Delete submission file after processing + DeleteXML = attr.ib(default=1) # type: int + + # List of submitter options per job + # list item must be of `SubmitterParameter` type + SubmitterParameters = attr.ib(factory=list) # type: list + + # List of job is submission batch. + # list item must be of type `RRJob` + Jobs = attr.ib(factory=list) # type: list + + @staticmethod + def _process_submitter_parameters(parameters, dom, append_to): + # type: (list[SubmitterParameter], md.Document, md.Element) -> None + """Take list of :class:`SubmitterParameter` and process it as XML. + + This will take :class:`SubmitterParameter`, create XML element + for them and convert value to Royal Render compatible string + (options and values separated by ~) + + Args: + parameters (list of SubmitterParameter): List of parameters. + dom (xml.dom.minidom.Document): XML Document + append_to (xml.dom.minidom.Element): Element to append to. + + """ + for param in parameters: + if not isinstance(param, SubmitterParameter): + raise AttributeError( + "{} is not of type `SubmitterParameter`".format(param)) + xml_parameter = dom.createElement("SubmitterParameter") + xml_parameter.appendChild(dom.createTextNode(param.serialize())) + append_to.appendChild(xml_parameter) + + def serialize(self): + # type: () -> str + """Return all data serialized as XML. + + Returns: + str: XML data as string. + + """ + def filter_data(a, v): + """Skip private attributes.""" + if a.name.startswith("_"): + return False + if v is None: + return False + return True + + root = md.Document() + # root element: + job_file = root.createElement('RR_Job_File') + job_file.setAttribute("syntax_version", self.syntax_version) + + # handle Submitter Parameters for batch + # foo=bar~baz~goo + self._process_submitter_parameters( + self.SubmitterParameters, root, job_file) + + for job in self.Jobs: # type: RRJob + if not isinstance(job, RRJob): + raise AttributeError( + "{} is not of type `SubmitterParameter`".format(job)) + xml_job = root.createElement("Job") + # handle Submitter Parameters for job + self._process_submitter_parameters( + job.SubmitterParameters, root, xml_job + ) + job_custom_attributes = job.CustomAttributes + + serialized_job = attr.asdict( + job, dict_factory=OrderedDict, filter=filter_data) + serialized_job.pop("CustomAttributes") + serialized_job.pop("SubmitterParameters") + + for custom_attr in job_custom_attributes: # type: CustomAttribute + serialized_job["Custom{}".format( + custom_attr.name)] = custom_attr.value + + for item, value in serialized_job.items(): + xml_attr = root.create(item) + xml_attr.appendChild( + root.createTextNode(value) + ) + xml_job.appendChild(xml_attr) + + return root.toprettyxml(indent="\t") diff --git a/openpype/modules/default_modules/royal_render/rr_root/README.md b/openpype/modules/default_modules/royal_render/rr_root/README.md new file mode 100644 index 0000000000..0a9777833e --- /dev/null +++ b/openpype/modules/default_modules/royal_render/rr_root/README.md @@ -0,0 +1,5 @@ +## OpenPype RoyalRender integration plugins + +### Installation + +Copy content of this folder to your `RR_ROOT` (place where RoyalRender studio wide installation is). \ No newline at end of file diff --git a/openpype/modules/default_modules/royal_render/rr_root/plugins/control_job/perjob/m50__openpype_publish_render.py b/openpype/modules/default_modules/royal_render/rr_root/plugins/control_job/perjob/m50__openpype_publish_render.py new file mode 100644 index 0000000000..17e4fb38d1 --- /dev/null +++ b/openpype/modules/default_modules/royal_render/rr_root/plugins/control_job/perjob/m50__openpype_publish_render.py @@ -0,0 +1,170 @@ +# -*- coding: utf-8 -*- +"""This is RR control plugin that runs on the job by user interaction. + +It asks user for context to publish, getting it from OpenPype. In order to +run it needs `OPENPYPE_ROOT` to be set to know where to execute OpenPype. + +""" +import rr # noqa +import rrGlobal # noqa +import subprocess +import os +import glob +import platform +import tempfile +import json + + +class OpenPypeContextSelector: + """Class to handle publishing context determination in RR.""" + + def __init__(self): + self.job = rr.getJob() + self.context = None + + self.openpype_executable = "openpype_gui" + if platform.system().lower() == "windows": + self.openpype_executable = "{}.exe".format( + self.openpype_executable) + + op_path = os.environ.get("OPENPYPE_ROOT") + print("initializing ... {}".format(op_path)) + if not op_path: + print("Warning: OpenPype root is not found.") + + if platform.system().lower() == "windows": + print(" * trying to find OpenPype on local computer.") + op_path = os.path.join( + os.environ.get("PROGRAMFILES"), + "OpenPype", "openpype_console.exe" + ) + if os.path.exists(op_path): + print(" - found OpenPype installation {}".format(op_path)) + else: + # try to find in user local context + op_path = os.path.join( + os.environ.get("LOCALAPPDATA"), + "Programs", + "OpenPype", "openpype_console.exe" + ) + if os.path.exists(op_path): + print( + " - found OpenPype installation {}".format( + op_path)) + else: + raise Exception("Error: OpenPype was not found.") + + self.openpype_root = op_path + + # TODO: this should try to find metadata file. Either using + # jobs custom attributes or using environment variable + # or just using plain existence of file. + # self.context = self._process_metadata_file() + + def _process_metadata_file(self): + """Find and process metadata file. + + Try to find metadata json file in job folder to get context from. + + Returns: + dict: Context from metadata json file. + + """ + image_dir = self.job.imageDir + metadata_files = glob.glob( + "{}{}*_metadata.json".format(image_dir, os.path.sep)) + if not metadata_files: + return {} + + raise NotImplementedError( + "Processing existing metadata not implemented yet.") + + def process_job(self): + """Process selected job. + + This should process selected job. If context can be determined + automatically, no UI will be show and publishing will directly + proceed. + """ + if not self.context: + self.show() + + self.context["user"] = self.job.userName + self.run_publish() + + def show(self): + """Show UI for context selection. + + Because of RR UI limitations, this must be done using OpenPype + itself. + + """ + tf = tempfile.TemporaryFile(delete=False) + context_file = tf.name + op_args = [os.path.join(self.openpype_root, self.openpype_executable), + "contextselection", tf.name] + + tf.close() + print(">>> running {}".format(" ".join(op_args))) + + subprocess.call(op_args) + + with open(context_file, "r") as cf: + self.context = json.load(cf) + + os.unlink(context_file) + print(">>> context: {}".format(self.context)) + + if not self.context or \ + not self.context.get("project") or \ + not self.context.get("asset") or \ + not self.context.get("task"): + self._show_rr_warning("Context selection failed.") + return + + # self.context["app_name"] = self.job.renderer.name + self.context["app_name"] = "maya/2020" + + @staticmethod + def _show_rr_warning(text): + warning_dialog = rrGlobal.getGenericUI() + warning_dialog.addItem(rrGlobal.genUIType.label, "infoLabel", "") + warning_dialog.setText("infoLabel", text) + warning_dialog.addItem( + rrGlobal.genUIType.layoutH, "btnLayout", "") + warning_dialog.addItem( + rrGlobal.genUIType.closeButton, "Ok", "btnLayout") + warning_dialog.execute() + del warning_dialog + + def run_publish(self): + """Run publish process.""" + env = {'AVALON_PROJECT': str(self.context.get("project")), + "AVALON_ASSET": str(self.context.get("asset")), + "AVALON_TASK": str(self.context.get("task")), + "AVALON_APP_NAME": str(self.context.get("app_name"))} + + print(">>> setting environment:") + for k, v in env.items(): + print(" {}: {}".format(k, v)) + + args = [os.path.join(self.openpype_root, self.openpype_executable), + 'publish', '-t', "rr_control", "--gui", + os.path.join(self.job.imageDir, + os.path.dirname(self.job.imageFileName)) + ] + + print(">>> running {}".format(" ".join(args))) + orig = os.environ.copy() + orig.update(env) + try: + subprocess.call(args, env=orig) + except subprocess.CalledProcessError as e: + self._show_rr_warning(" Publish failed [ {} ]".format( + e.returncode + )) + + +print("running selector") +selector = OpenPypeContextSelector() +selector.process_job() diff --git a/openpype/modules/default_modules/settings_module/interfaces.py b/openpype/modules/default_modules/settings_module/interfaces.py deleted file mode 100644 index 42db395649..0000000000 --- a/openpype/modules/default_modules/settings_module/interfaces.py +++ /dev/null @@ -1,30 +0,0 @@ -from abc import abstractmethod -from openpype.modules import OpenPypeInterface - - -class ISettingsChangeListener(OpenPypeInterface): - """Module has plugin paths to return. - - Expected result is dictionary with keys "publish", "create", "load" or - "actions" and values as list or string. - { - "publish": ["path/to/publish_plugins"] - } - """ - @abstractmethod - def on_system_settings_save( - self, old_value, new_value, changes, new_value_metadata - ): - pass - - @abstractmethod - def on_project_settings_save( - self, old_value, new_value, changes, project_name, new_value_metadata - ): - pass - - @abstractmethod - def on_project_anatomy_save( - self, old_value, new_value, changes, project_name, new_value_metadata - ): - pass diff --git a/openpype/modules/default_modules/sync_server/providers/abstract_provider.py b/openpype/modules/default_modules/sync_server/providers/abstract_provider.py index 8ae0ceed79..688a17f14f 100644 --- a/openpype/modules/default_modules/sync_server/providers/abstract_provider.py +++ b/openpype/modules/default_modules/sync_server/providers/abstract_provider.py @@ -201,5 +201,9 @@ class AbstractProvider: msg = "Error in resolving local root from anatomy" log.error(msg) raise ValueError(msg) + except IndexError: + msg = "Path {} contains unfillable placeholder" + log.error(msg) + raise ValueError(msg) return path diff --git a/openpype/modules/default_modules/sync_server/providers/dropbox.py b/openpype/modules/default_modules/sync_server/providers/dropbox.py index 0d735a0b59..90d7d44bb8 100644 --- a/openpype/modules/default_modules/sync_server/providers/dropbox.py +++ b/openpype/modules/default_modules/sync_server/providers/dropbox.py @@ -24,25 +24,19 @@ class DropboxHandler(AbstractProvider): ) return - provider_presets = self.presets.get(self.CODE) - if not provider_presets: - msg = "Sync Server: No provider presets for {}".format(self.CODE) - log.info(msg) - return - - token = self.presets[self.CODE].get("token", "") + token = self.presets.get("token", "") if not token: msg = "Sync Server: No access token for dropbox provider" log.info(msg) return - team_folder_name = self.presets[self.CODE].get("team_folder_name", "") + team_folder_name = self.presets.get("team_folder_name", "") if not team_folder_name: msg = "Sync Server: No team folder name for dropbox provider" log.info(msg) return - acting_as_member = self.presets[self.CODE].get("acting_as_member", "") + acting_as_member = self.presets.get("acting_as_member", "") if not acting_as_member: msg = ( "Sync Server: No acting member for dropbox provider" @@ -51,13 +45,15 @@ class DropboxHandler(AbstractProvider): return self.dbx = None - try: - self.dbx = self._get_service( - token, acting_as_member, team_folder_name - ) - except Exception as e: - log.info("Could not establish dropbox object: {}".format(e)) - return + + if self.presets["enabled"]: + try: + self.dbx = self._get_service( + token, acting_as_member, team_folder_name + ) + except Exception as e: + log.info("Could not establish dropbox object: {}".format(e)) + return super(AbstractProvider, self).__init__() @@ -101,9 +97,14 @@ class DropboxHandler(AbstractProvider): }, # roots could be overriden only on Project level, User cannot { - 'key': "roots", - 'label': "Roots", - 'type': 'dict' + "key": "root", + "label": "Roots", + "type": "dict-roots", + "object_type": { + "type": "path", + "multiplatform": False, + "multipath": False + } } ] @@ -164,7 +165,7 @@ class DropboxHandler(AbstractProvider): Returns: (boolean) """ - return self.dbx is not None + return self.presets["enabled"] and self.dbx is not None @classmethod def get_configurable_items(cls): diff --git a/openpype/modules/default_modules/sync_server/providers/gdrive.py b/openpype/modules/default_modules/sync_server/providers/gdrive.py index 0aabd9fbcd..d43e2b3d61 100644 --- a/openpype/modules/default_modules/sync_server/providers/gdrive.py +++ b/openpype/modules/default_modules/sync_server/providers/gdrive.py @@ -73,13 +73,7 @@ class GDriveHandler(AbstractProvider): format(site_name)) return - provider_presets = self.presets.get(self.CODE) - if not provider_presets: - msg = "Sync Server: No provider presets for {}".format(self.CODE) - log.info(msg) - return - - cred_path = self.presets[self.CODE].get("credentials_url", {}).\ + cred_path = self.presets.get("credentials_url", {}).\ get(platform.system().lower()) or '' if not os.path.exists(cred_path): msg = "Sync Server: No credentials for gdrive provider " + \ @@ -87,10 +81,12 @@ class GDriveHandler(AbstractProvider): log.info(msg) return - self.service = self._get_gd_service(cred_path) + self.service = None + if self.presets["enabled"]: + self.service = self._get_gd_service(cred_path) - self._tree = tree - self.active = True + self._tree = tree + self.active = True def is_active(self): """ @@ -98,7 +94,7 @@ class GDriveHandler(AbstractProvider): Returns: (boolean) """ - return self.service is not None + return self.presets["enabled"] and self.service is not None @classmethod def get_system_settings_schema(cls): @@ -125,15 +121,22 @@ class GDriveHandler(AbstractProvider): editable = [ # credentials could be overriden on Project or User level { - 'key': "credentials_url", - 'label': "Credentials url", - 'type': 'text' + "type": "path", + "key": "credentials_url", + "label": "Credentials url", + "multiplatform": True, + "placeholder": "Credentials url" }, # roots could be overriden only on Project leve, User cannot { - 'key': "roots", - 'label': "Roots", - 'type': 'dict' + "key": "root", + "label": "Roots", + "type": "dict-roots", + "object_type": { + "type": "path", + "multiplatform": False, + "multipath": False + } } ] return editable diff --git a/openpype/modules/default_modules/sync_server/providers/local_drive.py b/openpype/modules/default_modules/sync_server/providers/local_drive.py index 8e5f170bc9..68f604b39c 100644 --- a/openpype/modules/default_modules/sync_server/providers/local_drive.py +++ b/openpype/modules/default_modules/sync_server/providers/local_drive.py @@ -50,9 +50,14 @@ class LocalDriveHandler(AbstractProvider): # for non 'studio' sites, 'studio' is configured in Anatomy editable = [ { - 'key': "roots", - 'label': "Roots", - 'type': 'dict' + "key": "root", + "label": "Roots", + "type": "dict-roots", + "object_type": { + "type": "path", + "multiplatform": True, + "multipath": False + } } ] return editable @@ -68,7 +73,7 @@ class LocalDriveHandler(AbstractProvider): """ editable = [ { - 'key': "roots", + 'key': "root", 'label': "Roots", 'type': 'dict' } @@ -84,6 +89,7 @@ class LocalDriveHandler(AbstractProvider): if not os.path.isfile(source_path): raise FileNotFoundError("Source file {} doesn't exist." .format(source_path)) + if overwrite: thread = threading.Thread(target=self._copy, args=(source_path, target_path)) @@ -176,7 +182,10 @@ class LocalDriveHandler(AbstractProvider): def _copy(self, source_path, target_path): print("copying {}->{}".format(source_path, target_path)) - shutil.copy(source_path, target_path) + try: + shutil.copy(source_path, target_path) + except shutil.SameFileError: + print("same files, skipping") def _mark_progress(self, collection, file, representation, server, site, source_path, target_path, direction): diff --git a/openpype/modules/default_modules/sync_server/providers/sftp.py b/openpype/modules/default_modules/sync_server/providers/sftp.py index 07450265e2..4f505ae016 100644 --- a/openpype/modules/default_modules/sync_server/providers/sftp.py +++ b/openpype/modules/default_modules/sync_server/providers/sftp.py @@ -1,8 +1,6 @@ import os import os.path import time -import sys -import six import threading import platform @@ -14,6 +12,7 @@ log = Logger().get_logger("SyncServer") pysftp = None try: import pysftp + import paramiko except (ImportError, SyntaxError): pass @@ -37,7 +36,6 @@ class SFTPHandler(AbstractProvider): def __init__(self, project_name, site_name, tree=None, presets=None): self.presets = None - self.active = False self.project_name = project_name self.site_name = site_name self.root = None @@ -49,22 +47,15 @@ class SFTPHandler(AbstractProvider): format(site_name)) return - provider_presets = self.presets.get(self.CODE) - if not provider_presets: - msg = "Sync Server: No provider presets for {}".format(self.CODE) - log.warning(msg) - return - # store to instance for reconnect - self.sftp_host = provider_presets["sftp_host"] - self.sftp_port = provider_presets["sftp_port"] - self.sftp_user = provider_presets["sftp_user"] - self.sftp_pass = provider_presets["sftp_pass"] - self.sftp_key = provider_presets["sftp_key"] - self.sftp_key_pass = provider_presets["sftp_key_pass"] + self.sftp_host = presets["sftp_host"] + self.sftp_port = presets["sftp_port"] + self.sftp_user = presets["sftp_user"] + self.sftp_pass = presets["sftp_pass"] + self.sftp_key = presets["sftp_key"] + self.sftp_key_pass = presets["sftp_key_pass"] self._tree = None - self.active = True @property def conn(self): @@ -80,7 +71,7 @@ class SFTPHandler(AbstractProvider): Returns: (boolean) """ - return self.conn is not None + return self.presets["enabled"] and self.conn is not None @classmethod def get_system_settings_schema(cls): @@ -108,7 +99,7 @@ class SFTPHandler(AbstractProvider): editable = [ # credentials could be overriden on Project or User level { - 'key': "sftp_server", + 'key': "sftp_host", 'label': "SFTP host name", 'type': 'text' }, @@ -130,7 +121,8 @@ class SFTPHandler(AbstractProvider): { 'key': "sftp_key", 'label': "SFTP user ssh key", - 'type': 'path' + 'type': 'path', + "multiplatform": True }, { 'key': "sftp_key_pass", @@ -139,9 +131,14 @@ class SFTPHandler(AbstractProvider): }, # roots could be overriden only on Project leve, User cannot { - 'key': "roots", - 'label': "Roots", - 'type': 'dict' + "key": "root", + "label": "Roots", + "type": "dict-roots", + "object_type": { + "type": "path", + "multiplatform": False, + "multipath": False + } } ] return editable @@ -171,7 +168,8 @@ class SFTPHandler(AbstractProvider): { 'key': "sftp_key", 'label': "SFTP user ssh key", - 'type': 'path' + 'type': 'path', + "multiplatform": True }, { 'key': "sftp_key_pass", @@ -194,7 +192,7 @@ class SFTPHandler(AbstractProvider): Format is importing for usage of python's format ** approach """ # roots cannot be locally overridden - return self.presets['root'] + return self.presets['roots'] def get_tree(self): """ @@ -421,7 +419,10 @@ class SFTPHandler(AbstractProvider): if self.sftp_key_pass: conn_params['private_key_pass'] = self.sftp_key_pass - return pysftp.Connection(**conn_params) + try: + return pysftp.Connection(**conn_params) + except paramiko.ssh_exception.SSHException: + log.warning("Couldn't connect", exc_info=True) def _mark_progress(self, collection, file, representation, server, site, source_path, target_path, direction): diff --git a/openpype/modules/default_modules/sync_server/resources/refresh.png b/openpype/modules/default_modules/sync_server/resources/refresh.png new file mode 100644 index 0000000000..5ddd181fe6 Binary files /dev/null and b/openpype/modules/default_modules/sync_server/resources/refresh.png differ diff --git a/openpype/modules/default_modules/sync_server/sync_server.py b/openpype/modules/default_modules/sync_server/sync_server.py index 2227ec9366..22eed01ef3 100644 --- a/openpype/modules/default_modules/sync_server/sync_server.py +++ b/openpype/modules/default_modules/sync_server/sync_server.py @@ -80,6 +80,10 @@ async def upload(module, collection, file, representation, provider_name, remote_site_name, True ) + + module.handle_alternate_site(collection, representation, remote_site_name, + file["_id"], file_id) + return file_id @@ -131,6 +135,10 @@ async def download(module, collection, file, representation, provider_name, local_site, True ) + + module.handle_alternate_site(collection, representation, local_site, + file["_id"], file_id) + return file_id @@ -246,6 +254,7 @@ class SyncServerThread(threading.Thread): asyncio.ensure_future(self.check_shutdown(), loop=self.loop) asyncio.ensure_future(self.sync_loop(), loop=self.loop) + log.info("Sync Server Started") self.loop.run_forever() except Exception: log.warning( @@ -421,6 +430,12 @@ class SyncServerThread(threading.Thread): periodically. """ while self.is_running: + if self.module.long_running_tasks: + task = self.module.long_running_tasks.pop() + log.info("starting long running") + await self.loop.run_in_executor(None, task["func"]) + log.info("finished long running") + self.module.projects_processed.remove(task["project_name"]) await asyncio.sleep(0.5) tasks = [task for task in asyncio.all_tasks() if task is not asyncio.current_task()] diff --git a/openpype/modules/default_modules/sync_server/sync_server_module.py b/openpype/modules/default_modules/sync_server/sync_server_module.py index f2e9237542..cd29d93384 100644 --- a/openpype/modules/default_modules/sync_server/sync_server_module.py +++ b/openpype/modules/default_modules/sync_server/sync_server_module.py @@ -4,6 +4,7 @@ from datetime import datetime import threading import platform import copy +from collections import deque from avalon.api import AvalonMongoDB @@ -108,6 +109,7 @@ class SyncServerModule(OpenPypeModule, ITrayModule): # some parts of code need to run sequentially, not in async self.lock = None + self._sync_system_settings = None # settings for all enabled projects for sync self._sync_project_settings = None self.sync_server_thread = None # asyncio requires new thread @@ -120,6 +122,11 @@ class SyncServerModule(OpenPypeModule, ITrayModule): self._connection = None + # list of long blocking tasks + self.long_running_tasks = deque() + # projects that long tasks are running on + self.projects_processed = set() + """ Start of Public API """ def add_site(self, collection, representation_id, site_name=None, force=False): @@ -146,9 +153,9 @@ class SyncServerModule(OpenPypeModule, ITrayModule): if not site_name: site_name = self.DEFAULT_SITE - self.reset_provider_for_file(collection, - representation_id, - site_name=site_name, force=force) + self.reset_site_on_representation(collection, + representation_id, + site_name=site_name, force=force) # public facing API def remove_site(self, collection, representation_id, site_name, @@ -170,10 +177,10 @@ class SyncServerModule(OpenPypeModule, ITrayModule): if not self.get_sync_project_setting(collection): raise ValueError("Project not configured") - self.reset_provider_for_file(collection, - representation_id, - site_name=site_name, - remove=True) + self.reset_site_on_representation(collection, + representation_id, + site_name=site_name, + remove=True) if remove_local_files: self._remove_local_file(collection, representation_id, site_name) @@ -197,6 +204,105 @@ class SyncServerModule(OpenPypeModule, ITrayModule): for repre in representations: self.remove_site(collection, repre.get("_id"), site_name, True) + def create_validate_project_task(self, collection, site_name): + """Adds metadata about project files validation on a queue. + + This process will loop through all representation and check if + their files actually exist on an active site. + + This might be useful for edge cases when artists is switching + between sites, remote site is actually physically mounted and + active site has same file urls etc. + + Task will run on a asyncio loop, shouldn't be blocking. + """ + task = { + "type": "validate", + "project_name": collection, + "func": lambda: self.validate_project(collection, site_name) + } + self.projects_processed.add(collection) + self.long_running_tasks.append(task) + + def validate_project(self, collection, site_name, remove_missing=False): + """ + Validate 'collection' of 'site_name' and its local files + + If file present and not marked with a 'site_name' in DB, DB is + updated with site name and file modified date. + + Args: + module (SyncServerModule) + collection (string): project name + site_name (string): active site name + remove_missing (bool): if True remove sites in DB if missing + physically + """ + self.log.debug("Validation of {} for {} started".format(collection, + site_name)) + query = { + "type": "representation" + } + + representations = list( + self.connection.database[collection].find(query)) + if not representations: + self.log.debug("No repre found") + return + + sites_added = 0 + sites_removed = 0 + for repre in representations: + repre_id = repre["_id"] + for repre_file in repre.get("files", []): + try: + has_site = site_name in [site["name"] + for site in repre_file["sites"]] + except TypeError: + self.log.debug("Structure error in {}".format(repre_id)) + continue + + if has_site and not remove_missing: + continue + + file_path = repre_file.get("path", "") + local_file_path = self.get_local_file_path(collection, + site_name, + file_path) + + if local_file_path and os.path.exists(local_file_path): + self.log.debug("Adding site {} for {}".format(site_name, + repre_id)) + if not has_site: + query = { + "_id": repre_id + } + created_dt = datetime.fromtimestamp( + os.path.getmtime(local_file_path)) + elem = {"name": site_name, + "created_dt": created_dt} + self._add_site(collection, query, [repre], elem, + site_name=site_name, + file_id=repre_file["_id"]) + sites_added += 1 + else: + if has_site and remove_missing: + self.log.debug("Removing site {} for {}". + format(site_name, repre_id)) + self.reset_provider_for_file(collection, + repre_id, + file_id=repre_file["_id"], + remove=True) + sites_removed += 1 + + if sites_added % 100 == 0: + self.log.debug("Sites added {}".format(sites_added)) + + self.log.debug("Validation of {} for {} ended".format(collection, + site_name)) + self.log.info("Sites added {}, sites removed {}".format(sites_added, + sites_removed)) + def pause_representation(self, collection, representation_id, site_name): """ Sets 'representation_id' as paused, eg. no syncing should be @@ -209,8 +315,8 @@ class SyncServerModule(OpenPypeModule, ITrayModule): """ log.info("Pausing SyncServer for {}".format(representation_id)) self._paused_representations.add(representation_id) - self.reset_provider_for_file(collection, representation_id, - site_name=site_name, pause=True) + self.reset_site_on_representation(collection, representation_id, + site_name=site_name, pause=True) def unpause_representation(self, collection, representation_id, site_name): """ @@ -229,8 +335,8 @@ class SyncServerModule(OpenPypeModule, ITrayModule): except KeyError: pass # self.paused_representations is not persistent - self.reset_provider_for_file(collection, representation_id, - site_name=site_name, pause=False) + self.reset_site_on_representation(collection, representation_id, + site_name=site_name, pause=False) def is_representation_paused(self, representation_id, check_parents=False, project_name=None): @@ -664,6 +770,58 @@ class SyncServerModule(OpenPypeModule, ITrayModule): enabled_projects.append(project_name) return enabled_projects + + def handle_alternate_site(self, collection, representation, processed_site, + file_id, synced_file_id): + """ + For special use cases where one site vendors another. + + Current use case is sftp site vendoring (exposing) same data as + regular site (studio). Each site is accessible for different + audience. 'studio' for artists in a studio, 'sftp' for externals. + + Change of file status on one site actually means same change on + 'alternate' site. (eg. artists publish to 'studio', 'sftp' is using + same location >> file is accesible on 'sftp' site right away. + + Args: + collection (str): name of project + representation (dict) + processed_site (str): real site_name of published/uploaded file + file_id (ObjectId): DB id of file handled + synced_file_id (str): id of the created file returned + by provider + """ + sites = self.sync_system_settings.get("sites", {}) + sites[self.DEFAULT_SITE] = {"provider": "local_drive", + "alternative_sites": []} + + alternate_sites = [] + for site_name, site_info in sites.items(): + conf_alternative_sites = site_info.get("alternative_sites", []) + if processed_site in conf_alternative_sites: + alternate_sites.append(site_name) + continue + if processed_site == site_name and conf_alternative_sites: + alternate_sites.extend(conf_alternative_sites) + continue + + alternate_sites = set(alternate_sites) + + for alt_site in alternate_sites: + query = { + "_id": representation["_id"] + } + elem = {"name": alt_site, + "created_dt": datetime.now(), + "id": synced_file_id} + + self.log.debug("Adding alternate {} to {}".format( + alt_site, representation["_id"])) + self._add_site(collection, query, + [representation], elem, + alt_site, file_id=file_id, force=True) + """ End of Public API """ def get_local_file_path(self, collection, site_name, file_path): @@ -694,12 +852,19 @@ class SyncServerModule(OpenPypeModule, ITrayModule): def tray_init(self): """ - Actual initialization of Sync Server. + Actual initialization of Sync Server for Tray. Called when tray is initialized, it checks if module should be enabled. If not, no initialization necessary. """ - # import only in tray, because of Python2 hosts + self.server_init() + + from .tray.app import SyncServerWindow + self.widget = SyncServerWindow(self) + + def server_init(self): + """Actual initialization of Sync Server.""" + # import only in tray or Python3, because of Python2 hosts from .sync_server import SyncServerThread if not self.enabled: @@ -712,21 +877,7 @@ class SyncServerModule(OpenPypeModule, ITrayModule): self.lock = threading.Lock() - try: - self.sync_server_thread = SyncServerThread(self) - - from .tray.app import SyncServerWindow - self.widget = SyncServerWindow(self) - except ValueError: - log.info("No system setting for sync. Not syncing.", exc_info=True) - self.enabled = False - except KeyError: - log.info(( - "There are not set presets for SyncServer OR " - "Credentials provided are invalid, " - "no syncing possible"). - format(str(self.sync_project_settings)), exc_info=True) - self.enabled = False + self.sync_server_thread = SyncServerThread(self) def tray_start(self): """ @@ -739,6 +890,9 @@ class SyncServerModule(OpenPypeModule, ITrayModule): Returns: None """ + self.server_start() + + def server_start(self): if self.sync_project_settings and self.enabled: self.sync_server_thread.start() else: @@ -751,6 +905,9 @@ class SyncServerModule(OpenPypeModule, ITrayModule): Called from Module Manager """ + self.server_exit() + + def server_exit(self): if not self.sync_server_thread: return @@ -760,6 +917,7 @@ class SyncServerModule(OpenPypeModule, ITrayModule): log.info("Stopping sync server server") self.sync_server_thread.is_running = False self.sync_server_thread.stop() + log.info("Sync server stopped") except Exception: log.warning( "Error has happened during Killing sync server", @@ -802,6 +960,14 @@ class SyncServerModule(OpenPypeModule, ITrayModule): return self._connection + @property + def sync_system_settings(self): + if self._sync_system_settings is None: + self._sync_system_settings = get_system_settings()["modules"].\ + get("sync_server") + + return self._sync_system_settings + @property def sync_project_settings(self): if self._sync_project_settings is None: @@ -887,9 +1053,7 @@ class SyncServerModule(OpenPypeModule, ITrayModule): (dict): {'studio': {'provider':'local_drive'...}, 'MY_LOCAL': {'provider':....}} """ - sys_sett = get_system_settings() - sync_sett = sys_sett["modules"].get("sync_server") - + sync_sett = self.sync_system_settings project_enabled = True if project_name: project_enabled = project_name in self.get_enabled_projects() @@ -947,10 +1111,9 @@ class SyncServerModule(OpenPypeModule, ITrayModule): if provider: return provider - sys_sett = get_system_settings() - sync_sett = sys_sett["modules"].get("sync_server") - for site, detail in sync_sett.get("sites", {}).items(): - sites[site] = detail.get("provider") + sync_sett = self.sync_system_settings + for conf_site, detail in sync_sett.get("sites", {}).items(): + sites[conf_site] = detail.get("provider") return sites.get(site, 'N/A') @@ -1229,9 +1392,9 @@ class SyncServerModule(OpenPypeModule, ITrayModule): return -1, None - def reset_provider_for_file(self, collection, representation_id, - side=None, file_id=None, site_name=None, - remove=False, pause=None, force=False): + def reset_site_on_representation(self, collection, representation_id, + side=None, file_id=None, site_name=None, + remove=False, pause=None, force=False): """ Reset information about synchronization for particular 'file_id' and provider. @@ -1317,9 +1480,12 @@ class SyncServerModule(OpenPypeModule, ITrayModule): update = { "$set": {"files.$[f].sites.$[s]": elem} } + if not isinstance(file_id, ObjectId): + file_id = ObjectId(file_id) + arr_filter = [ {'s.name': site_name}, - {'f._id': ObjectId(file_id)} + {'f._id': file_id} ] self._update_site(collection, query, update, arr_filter) @@ -1347,7 +1513,7 @@ class SyncServerModule(OpenPypeModule, ITrayModule): found = False for repre_file in representation.pop().get("files"): for site in repre_file.get("sites"): - if site["name"] == site_name: + if site.get("name") == site_name: found = True break if not found: @@ -1398,13 +1564,20 @@ class SyncServerModule(OpenPypeModule, ITrayModule): self._update_site(collection, query, update, arr_filter) def _add_site(self, collection, query, representation, elem, site_name, - force=False): + force=False, file_id=None): """ Adds 'site_name' to 'representation' on 'collection' + Args: + representation (list of 1 dict) + file_id (ObjectId) + Use 'force' to remove existing or raises ValueError """ for repre_file in representation.pop().get("files"): + if file_id and file_id != repre_file["_id"]: + continue + for site in repre_file.get("sites"): if site["name"] == site_name: if force: @@ -1417,11 +1590,19 @@ class SyncServerModule(OpenPypeModule, ITrayModule): log.info(msg) raise ValueError(msg) - update = { - "$push": {"files.$[].sites": elem} - } + if not file_id: + update = { + "$push": {"files.$[].sites": elem} + } - arr_filter = [] + arr_filter = [] + else: + update = { + "$push": {"files.$[f].sites": elem} + } + arr_filter = [ + {'f._id': file_id} + ] self._update_site(collection, query, update, arr_filter) @@ -1496,7 +1677,24 @@ class SyncServerModule(OpenPypeModule, ITrayModule): return int(ld) def show_widget(self): - """Show dialog to enter credentials""" + """Show dialog for Sync Queue""" + no_errors = False + try: + from .tray.app import SyncServerWindow + self.widget = SyncServerWindow(self) + no_errors = True + except ValueError: + log.info("No system setting for sync. Not syncing.", exc_info=True) + except KeyError: + log.info(( + "There are not set presets for SyncServer OR " + "Credentials provided are invalid, " + "no syncing possible"). + format(str(self.sync_project_settings)), exc_info=True) + except: + log.error("Uncaught exception durin start of SyncServer", + exc_info=True) + self.enabled = no_errors self.widget.show() def _get_success_dict(self, new_file_id): diff --git a/openpype/modules/default_modules/sync_server/tray/delegates.py b/openpype/modules/default_modules/sync_server/tray/delegates.py index 461b9fffb3..5ab809a816 100644 --- a/openpype/modules/default_modules/sync_server/tray/delegates.py +++ b/openpype/modules/default_modules/sync_server/tray/delegates.py @@ -4,6 +4,18 @@ from Qt import QtCore, QtWidgets, QtGui from openpype.lib import PypeLogger from . import lib +from openpype.tools.utils.constants import ( + LOCAL_PROVIDER_ROLE, + REMOTE_PROVIDER_ROLE, + LOCAL_PROGRESS_ROLE, + REMOTE_PROGRESS_ROLE, + LOCAL_DATE_ROLE, + REMOTE_DATE_ROLE, + LOCAL_FAILED_ROLE, + REMOTE_FAILED_ROLE, + EDIT_ICON_ROLE +) + log = PypeLogger().get_logger("SyncServer") @@ -14,7 +26,7 @@ class PriorityDelegate(QtWidgets.QStyledItemDelegate): if option.widget.selectionModel().isSelected(index) or \ option.state & QtWidgets.QStyle.State_MouseOver: - edit_icon = index.data(lib.EditIconRole) + edit_icon = index.data(EDIT_ICON_ROLE) if not edit_icon: return @@ -38,7 +50,7 @@ class PriorityDelegate(QtWidgets.QStyledItemDelegate): editor = PriorityLineEdit( parent, option.widget.selectionModel().selectedRows()) - editor.setFocus(True) + editor.setFocus() return editor def setModelData(self, editor, model, index): @@ -71,19 +83,30 @@ class ImageDelegate(QtWidgets.QStyledItemDelegate): Prints icon of site and progress of synchronization """ - def __init__(self, parent=None): + def __init__(self, parent=None, side=None): super(ImageDelegate, self).__init__(parent) self.icons = {} + self.side = side def paint(self, painter, option, index): super(ImageDelegate, self).paint(painter, option, index) option = QtWidgets.QStyleOptionViewItem(option) option.showDecorationSelected = True - provider = index.data(lib.ProviderRole) - value = index.data(lib.ProgressRole) - date_value = index.data(lib.DateRole) - is_failed = index.data(lib.FailedRole) + if not self.side: + log.warning("No side provided, delegate won't work") + return + + if self.side == 'local': + provider = index.data(LOCAL_PROVIDER_ROLE) + value = index.data(LOCAL_PROGRESS_ROLE) + date_value = index.data(LOCAL_DATE_ROLE) + is_failed = index.data(LOCAL_FAILED_ROLE) + else: + provider = index.data(REMOTE_PROVIDER_ROLE) + value = index.data(REMOTE_PROGRESS_ROLE) + date_value = index.data(REMOTE_DATE_ROLE) + is_failed = index.data(REMOTE_FAILED_ROLE) if not self.icons.get(provider): resource_path = os.path.dirname(__file__) diff --git a/openpype/modules/default_modules/sync_server/tray/lib.py b/openpype/modules/default_modules/sync_server/tray/lib.py index 25c600abd2..87344be634 100644 --- a/openpype/modules/default_modules/sync_server/tray/lib.py +++ b/openpype/modules/default_modules/sync_server/tray/lib.py @@ -1,4 +1,3 @@ -from Qt import QtCore import attr import abc import six @@ -19,14 +18,6 @@ STATUS = { DUMMY_PROJECT = "No project configured" -ProviderRole = QtCore.Qt.UserRole + 2 -ProgressRole = QtCore.Qt.UserRole + 4 -DateRole = QtCore.Qt.UserRole + 6 -FailedRole = QtCore.Qt.UserRole + 8 -HeaderNameRole = QtCore.Qt.UserRole + 10 -FullItemRole = QtCore.Qt.UserRole + 12 -EditIconRole = QtCore.Qt.UserRole + 14 - @six.add_metaclass(abc.ABCMeta) class AbstractColumnFilter: @@ -161,7 +152,7 @@ def translate_provider_for_icon(sync_server, project, site): return sync_server.get_provider_for_site(site=site) -def get_item_by_id(model, object_id): +def get_value_from_id_by_role(model, object_id, role): + """Return value from item with 'object_id' with 'role'.""" index = model.get_index(object_id) - item = model.data(index, FullItemRole) - return item + return model.data(index, role) diff --git a/openpype/modules/default_modules/sync_server/tray/models.py b/openpype/modules/default_modules/sync_server/tray/models.py index 5642c5b34a..80f41992cb 100644 --- a/openpype/modules/default_modules/sync_server/tray/models.py +++ b/openpype/modules/default_modules/sync_server/tray/models.py @@ -13,6 +13,23 @@ from openpype.api import get_local_site_id from . import lib +from openpype.tools.utils.constants import ( + LOCAL_PROVIDER_ROLE, + REMOTE_PROVIDER_ROLE, + LOCAL_PROGRESS_ROLE, + REMOTE_PROGRESS_ROLE, + HEADER_NAME_ROLE, + EDIT_ICON_ROLE, + LOCAL_DATE_ROLE, + REMOTE_DATE_ROLE, + LOCAL_FAILED_ROLE, + REMOTE_FAILED_ROLE, + STATUS_ROLE, + PATH_ROLE, + ERROR_ROLE, + TRIES_ROLE +) + log = PypeLogger().get_logger("SyncServer") @@ -68,10 +85,68 @@ class _SyncRepresentationModel(QtCore.QAbstractTableModel): if orientation == Qt.Horizontal: return self.COLUMN_LABELS[section][1] - if role == lib.HeaderNameRole: + if role == HEADER_NAME_ROLE: if orientation == Qt.Horizontal: return self.COLUMN_LABELS[section][0] # return name + def data(self, index, role): + item = self._data[index.row()] + + header_value = self._header[index.column()] + if role == LOCAL_PROVIDER_ROLE: + return item.local_provider + + if role == REMOTE_PROVIDER_ROLE: + return item.remote_provider + + if role == LOCAL_PROGRESS_ROLE: + return item.local_progress + + if role == REMOTE_PROGRESS_ROLE: + return item.remote_progress + + if role == LOCAL_DATE_ROLE: + if item.created_dt: + return pretty_timestamp(item.created_dt) + + if role == REMOTE_DATE_ROLE: + if item.sync_dt: + return pretty_timestamp(item.sync_dt) + + if role == LOCAL_FAILED_ROLE: + return item.status == lib.STATUS[2] and \ + item.local_progress < 1 + + if role == REMOTE_FAILED_ROLE: + return item.status == lib.STATUS[2] and \ + item.remote_progress < 1 + + if role in (Qt.DisplayRole, Qt.EditRole): + # because of ImageDelegate + if header_value in ['remote_site', 'local_site']: + return "" + + return attr.asdict(item)[self._header[index.column()]] + + if role == EDIT_ICON_ROLE: + if self.can_edit and header_value in self.EDITABLE_COLUMNS: + return self.edit_icon + + if role == PATH_ROLE: + return item.path + + if role == ERROR_ROLE: + return item.error + + if role == TRIES_ROLE: + return item.tries + + if role == STATUS_ROLE: + return item.status + + if role == Qt.UserRole: + return item._id + @property def can_edit(self): """Returns true if some site is user local site, eg. could edit""" @@ -124,7 +199,8 @@ class _SyncRepresentationModel(QtCore.QAbstractTableModel): if not representations: self.query = self.get_query(load_records) - representations = self.dbcon.aggregate(self.query) + representations = self.dbcon.aggregate(pipeline=self.query, + allowDiskUse=True) self.add_page_records(self.active_site, self.remote_site, representations) @@ -159,7 +235,8 @@ class _SyncRepresentationModel(QtCore.QAbstractTableModel): items_to_fetch = min(self._total_records - self._rec_loaded, self.PAGE_SIZE) self.query = self.get_query(self._rec_loaded) - representations = self.dbcon.aggregate(self.query) + representations = self.dbcon.aggregate(pipeline=self.query, + allowDiskUse=True) self.beginInsertRows(index, self._rec_loaded, self._rec_loaded + items_to_fetch - 1) @@ -192,16 +269,16 @@ class _SyncRepresentationModel(QtCore.QAbstractTableModel): else: order = -1 - backup_sort = dict(self.sort) + backup_sort = dict(self.sort_criteria) - self.sort = {self.SORT_BY_COLUMN[index]: order} # reset + self.sort_criteria = {self.SORT_BY_COLUMN[index]: order} # reset # add last one for key, val in backup_sort.items(): if key != '_id' and key != self.SORT_BY_COLUMN[index]: - self.sort[key] = val + self.sort_criteria[key] = val break # add default one - self.sort['_id'] = 1 + self.sort_criteria['_id'] = 1 self.query = self.get_query() # import json @@ -209,7 +286,8 @@ class _SyncRepresentationModel(QtCore.QAbstractTableModel): # replace('False', 'false').\ # replace('True', 'true').replace('None', 'null')) - representations = self.dbcon.aggregate(self.query) + representations = self.dbcon.aggregate(pipeline=self.query, + allowDiskUse=True) self.refresh(representations) def set_word_filter(self, word_filter): @@ -440,67 +518,19 @@ class SyncRepresentationSummaryModel(_SyncRepresentationModel): self.active_site = self.sync_server.get_active_site(self.project) self.remote_site = self.sync_server.get_remote_site(self.project) - self.sort = self.DEFAULT_SORT + self.sort_criteria = self.DEFAULT_SORT self.query = self.get_query() self.default_query = list(self.get_query()) - representations = self.dbcon.aggregate(self.query) + representations = self.dbcon.aggregate(pipeline=self.query, + allowDiskUse=True) self.refresh(representations) self.timer = QtCore.QTimer() self.timer.timeout.connect(self.tick) self.timer.start(self.REFRESH_SEC) - def data(self, index, role): - item = self._data[index.row()] - - if role == lib.FullItemRole: - return item - - header_value = self._header[index.column()] - if role == lib.ProviderRole: - if header_value == 'local_site': - return item.local_provider - if header_value == 'remote_site': - return item.remote_provider - - if role == lib.ProgressRole: - if header_value == 'local_site': - return item.local_progress - if header_value == 'remote_site': - return item.remote_progress - - if role == lib.DateRole: - if header_value == 'local_site': - if item.created_dt: - return pretty_timestamp(item.created_dt) - if header_value == 'remote_site': - if item.sync_dt: - return pretty_timestamp(item.sync_dt) - - if role == lib.FailedRole: - if header_value == 'local_site': - return item.status == lib.STATUS[2] and \ - item.local_progress < 1 - if header_value == 'remote_site': - return item.status == lib.STATUS[2] and \ - item.remote_progress < 1 - - if role in (Qt.DisplayRole, Qt.EditRole): - # because of ImageDelegate - if header_value in ['remote_site', 'local_site']: - return "" - - return attr.asdict(item)[self._header[index.column()]] - - if role == lib.EditIconRole: - if self.can_edit and header_value in self.EDITABLE_COLUMNS: - return self.edit_icon - - if role == Qt.UserRole: - return item._id - def add_page_records(self, local_site, remote_site, representations): """ Process all records from 'representation' and add them to storage. @@ -732,7 +762,7 @@ class SyncRepresentationSummaryModel(_SyncRepresentationModel): ) aggr.extend( - [{"$sort": self.sort}, + [{"$sort": self.sort_criteria}, { '$facet': { 'paginatedResults': [{'$skip': self._rec_loaded}, @@ -970,65 +1000,17 @@ class SyncRepresentationDetailModel(_SyncRepresentationModel): self.active_site = self.sync_server.get_active_site(self.project) self.remote_site = self.sync_server.get_remote_site(self.project) - self.sort = self.DEFAULT_SORT + self.sort_criteria = self.DEFAULT_SORT self.query = self.get_query() - representations = self.dbcon.aggregate(self.query) + representations = self.dbcon.aggregate(pipeline=self.query, + allowDiskUse=True) self.refresh(representations) self.timer = QtCore.QTimer() self.timer.timeout.connect(self.tick) self.timer.start(SyncRepresentationSummaryModel.REFRESH_SEC) - def data(self, index, role): - item = self._data[index.row()] - - if role == lib.FullItemRole: - return item - - header_value = self._header[index.column()] - if role == lib.ProviderRole: - if header_value == 'local_site': - return item.local_provider - if header_value == 'remote_site': - return item.remote_provider - - if role == lib.ProgressRole: - if header_value == 'local_site': - return item.local_progress - if header_value == 'remote_site': - return item.remote_progress - - if role == lib.DateRole: - if header_value == 'local_site': - if item.created_dt: - return pretty_timestamp(item.created_dt) - if header_value == 'remote_site': - if item.sync_dt: - return pretty_timestamp(item.sync_dt) - - if role == lib.FailedRole: - if header_value == 'local_site': - return item.status == lib.STATUS[2] and \ - item.local_progress < 1 - if header_value == 'remote_site': - return item.status == lib.STATUS[2] and \ - item.remote_progress < 1 - - if role in (Qt.DisplayRole, Qt.EditRole): - # because of ImageDelegate - if header_value in ['remote_site', 'local_site']: - return "" - - return attr.asdict(item)[self._header[index.column()]] - - if role == lib.EditIconRole: - if self.can_edit and header_value in self.EDITABLE_COLUMNS: - return self.edit_icon - - if role == Qt.UserRole: - return item._id - def add_page_records(self, local_site, remote_site, representations): """ Process all records from 'representation' and add them to storage. @@ -1235,7 +1217,7 @@ class SyncRepresentationDetailModel(_SyncRepresentationModel): print(self.column_filtering) aggr.extend([ - {"$sort": self.sort}, + {"$sort": self.sort_criteria}, { '$facet': { 'paginatedResults': [{'$skip': self._rec_loaded}, diff --git a/openpype/modules/default_modules/sync_server/tray/widgets.py b/openpype/modules/default_modules/sync_server/tray/widgets.py index 45537c1c2e..18487b3d11 100644 --- a/openpype/modules/default_modules/sync_server/tray/widgets.py +++ b/openpype/modules/default_modules/sync_server/tray/widgets.py @@ -22,6 +22,20 @@ from .models import ( from . import lib from . import delegates +from openpype.tools.utils.constants import ( + LOCAL_PROGRESS_ROLE, + REMOTE_PROGRESS_ROLE, + HEADER_NAME_ROLE, + STATUS_ROLE, + PATH_ROLE, + LOCAL_SITE_NAME_ROLE, + REMOTE_SITE_NAME_ROLE, + LOCAL_DATE_ROLE, + REMOTE_DATE_ROLE, + ERROR_ROLE, + TRIES_ROLE +) + log = PypeLogger().get_logger("SyncServer") @@ -32,6 +46,8 @@ class SyncProjectListWidget(QtWidgets.QWidget): project_changed = QtCore.Signal() message_generated = QtCore.Signal(str) + refresh_msec = 10000 + def __init__(self, sync_server, parent): super(SyncProjectListWidget, self).__init__(parent) self.setObjectName("ProjectListWidget") @@ -56,8 +72,8 @@ class SyncProjectListWidget(QtWidgets.QWidget): layout.addWidget(project_list, 1) project_list.customContextMenuRequested.connect(self._on_context_menu) - project_list.selectionModel().currentChanged.connect( - self._on_index_change + project_list.selectionModel().selectionChanged.connect( + self._on_selection_changed ) self.project_model = project_model @@ -69,17 +85,43 @@ class SyncProjectListWidget(QtWidgets.QWidget): self.remote_site = None self.icons = {} - def _on_index_change(self, new_idx, _old_idx): - project_name = new_idx.data(QtCore.Qt.DisplayRole) + self._selection_changed = False + self._model_reset = False + timer = QtCore.QTimer() + timer.setInterval(self.refresh_msec) + timer.timeout.connect(self.refresh) + timer.start() + + self.timer = timer + + def _on_selection_changed(self, new_selection, _old_selection): + # block involuntary selection changes + if self._selection_changed or self._model_reset: + return + + indexes = new_selection.indexes() + if not indexes: + return + + project_name = indexes[0].data(QtCore.Qt.DisplayRole) + + if self.current_project == project_name: + return + self._selection_changed = True self.current_project = project_name self.project_changed.emit() + self.refresh() + self._selection_changed = False def refresh(self): + selected_index = None model = self.project_model + self._model_reset = True model.clear() + self._model_reset = False - project_name = None + selected_item = None for project_name in self.sync_server.sync_project_settings.\ keys(): if self.sync_server.is_paused() or \ @@ -88,20 +130,38 @@ class SyncProjectListWidget(QtWidgets.QWidget): else: icon = self._get_icon("synced") - model.appendRow(QtGui.QStandardItem(icon, project_name)) + if project_name in self.sync_server.projects_processed: + icon = self._get_icon("refresh") + + item = QtGui.QStandardItem(icon, project_name) + model.appendRow(item) + + if self.current_project == project_name: + selected_item = item + + if selected_item: + selected_index = model.indexFromItem(selected_item) if len(self.sync_server.sync_project_settings.keys()) == 0: model.appendRow(QtGui.QStandardItem(lib.DUMMY_PROJECT)) - self.current_project = self.project_list.currentIndex().data( - QtCore.Qt.DisplayRole - ) if not self.current_project: self.current_project = model.item(0).data(QtCore.Qt.DisplayRole) - if project_name: - self.local_site = self.sync_server.get_active_site(project_name) - self.remote_site = self.sync_server.get_remote_site(project_name) + self.project_model = model + + if selected_index and \ + selected_index.isValid() and \ + not self._selection_changed: + mode = QtCore.QItemSelectionModel.Select | \ + QtCore.QItemSelectionModel.Rows + self.project_list.selectionModel().select(selected_index, mode) + + if self.current_project: + self.local_site = self.sync_server.get_active_site( + self.current_project) + self.remote_site = self.sync_server.get_remote_site( + self.current_project) def _can_edit(self): """Returns true if some site is user local site, eg. could edit""" @@ -143,6 +203,11 @@ class SyncProjectListWidget(QtWidgets.QWidget): actions_mapping[action] = self._clear_project menu.addAction(action) + if self.project_name not in self.sync_server.projects_processed: + action = QtWidgets.QAction("Validate files on active site") + actions_mapping[action] = self._validate_site + menu.addAction(action) + result = menu.exec_(QtGui.QCursor.pos()) if result: to_run = actions_mapping[result] @@ -167,6 +232,13 @@ class SyncProjectListWidget(QtWidgets.QWidget): self.project_name = None self.refresh() + def _validate_site(self): + if self.project_name: + self.sync_server.create_validate_project_task(self.project_name, + self.local_site) + self.project_name = None + self.refresh() + class _SyncRepresentationWidget(QtWidgets.QWidget): """ @@ -231,14 +303,19 @@ class _SyncRepresentationWidget(QtWidgets.QWidget): if is_multi: index = self.model.get_index(list(self._selected_ids)[0]) - item = self.model.data(index, lib.FullItemRole) + local_progress = self.model.data(index, LOCAL_PROGRESS_ROLE) + remote_progress = self.model.data(index, REMOTE_PROGRESS_ROLE) + status = self.model.data(index, STATUS_ROLE) else: - item = self.model.data(point_index, lib.FullItemRole) + local_progress = self.model.data(point_index, LOCAL_PROGRESS_ROLE) + remote_progress = self.model.data(point_index, + REMOTE_PROGRESS_ROLE) + status = self.model.data(point_index, STATUS_ROLE) + can_edit = self.model.can_edit - action_kwarg_map, actions_mapping, menu = self._prepare_menu(item, - is_multi, - can_edit) + action_kwarg_map, actions_mapping, menu = self._prepare_menu( + local_progress, remote_progress, is_multi, can_edit, status) result = menu.exec_(QtGui.QCursor.pos()) if result: @@ -249,7 +326,8 @@ class _SyncRepresentationWidget(QtWidgets.QWidget): self.model.refresh() - def _prepare_menu(self, item, is_multi, can_edit): + def _prepare_menu(self, local_progress, remote_progress, + is_multi, can_edit, status=None): menu = QtWidgets.QMenu(self) actions_mapping = {} @@ -258,11 +336,6 @@ class _SyncRepresentationWidget(QtWidgets.QWidget): active_site = self.model.active_site remote_site = self.model.remote_site - local_progress = item.local_progress - remote_progress = item.remote_progress - - project = self.model.project - for site, progress in {active_site: local_progress, remote_site: remote_progress}.items(): provider = self.sync_server.get_provider_for_site(site=site) @@ -302,12 +375,6 @@ class _SyncRepresentationWidget(QtWidgets.QWidget): actions_mapping[action] = self._change_priority menu.addAction(action) - # # temp for testing only !!! - # action = QtWidgets.QAction("Download") - # action_kwarg_map[action] = self._get_action_kwargs(active_site) - # actions_mapping[action] = self._add_site - # menu.addAction(action) - if not actions_mapping: action = QtWidgets.QAction("< No action >") actions_mapping[action] = None @@ -318,11 +385,15 @@ class _SyncRepresentationWidget(QtWidgets.QWidget): def _pause(self, selected_ids=None): log.debug("Pause {}".format(selected_ids)) for representation_id in selected_ids: - item = lib.get_item_by_id(self.model, representation_id) - if item.status not in [lib.STATUS[0], lib.STATUS[1]]: + status = lib.get_value_from_id_by_role(self.model, + representation_id, + STATUS_ROLE) + if status not in [lib.STATUS[0], lib.STATUS[1]]: continue for site_name in [self.model.active_site, self.model.remote_site]: - check_progress = self._get_progress(item, site_name) + check_progress = self._get_progress(self.model, + representation_id, + site_name) if check_progress < 1: self.sync_server.pause_representation(self.model.project, representation_id, @@ -333,11 +404,15 @@ class _SyncRepresentationWidget(QtWidgets.QWidget): def _unpause(self, selected_ids=None): log.debug("UnPause {}".format(selected_ids)) for representation_id in selected_ids: - item = lib.get_item_by_id(self.model, representation_id) - if item.status not in lib.STATUS[3]: + status = lib.get_value_from_id_by_role(self.model, + representation_id, + STATUS_ROLE) + if status not in lib.STATUS[3]: continue for site_name in [self.model.active_site, self.model.remote_site]: - check_progress = self._get_progress(item, site_name) + check_progress = self._get_progress(self.model, + representation_id, + site_name) if check_progress < 1: self.sync_server.unpause_representation( self.model.project, @@ -350,8 +425,11 @@ class _SyncRepresentationWidget(QtWidgets.QWidget): def _add_site(self, selected_ids=None, site_name=None): log.debug("Add site {}:{}".format(selected_ids, site_name)) for representation_id in selected_ids: - item = lib.get_item_by_id(self.model, representation_id) - if item.local_site == site_name or item.remote_site == site_name: + item_local_site = lib.get_value_from_id_by_role( + self.model, representation_id, LOCAL_SITE_NAME_ROLE) + item_remote_site = lib.get_value_from_id_by_role( + self.model, representation_id, REMOTE_SITE_NAME_ROLE) + if site_name in [item_local_site, item_remote_site]: # site already exists skip continue @@ -402,8 +480,8 @@ class _SyncRepresentationWidget(QtWidgets.QWidget): """ log.debug("Reset site {}:{}".format(selected_ids, site_name)) for representation_id in selected_ids: - item = lib.get_item_by_id(self.model, representation_id) - check_progress = self._get_progress(item, site_name, True) + check_progress = self._get_progress(self.model, representation_id, + site_name, True) # do not reset if opposite side is not fully there if check_progress != 1: @@ -411,7 +489,7 @@ class _SyncRepresentationWidget(QtWidgets.QWidget): format(check_progress)) continue - self.sync_server.reset_provider_for_file( + self.sync_server.reset_site_on_representation( self.model.project, representation_id, site_name=site_name, @@ -424,11 +502,8 @@ class _SyncRepresentationWidget(QtWidgets.QWidget): def _open_in_explorer(self, selected_ids=None, site_name=None): log.debug("Open in Explorer {}:{}".format(selected_ids, site_name)) for selected_id in selected_ids: - item = lib.get_item_by_id(self.model, selected_id) - if not item: - return - - fpath = item.path + fpath = lib.get_value_from_id_by_role(self.model, selected_id, + PATH_ROLE) project = self.model.project fpath = self.sync_server.get_local_file_path(project, site_name, @@ -456,10 +531,17 @@ class _SyncRepresentationWidget(QtWidgets.QWidget): self.model.is_editing = True self.table_view.openPersistentEditor(real_index) - def _get_progress(self, item, site_name, opposite=False): + def _get_progress(self, model, representation_id, + site_name, opposite=False): """Returns progress value according to site (side)""" - progress = {'local': item.local_progress, - 'remote': item.remote_progress} + local_progress = lib.get_value_from_id_by_role(model, + representation_id, + LOCAL_PROGRESS_ROLE) + remote_progress = lib.get_value_from_id_by_role(model, + representation_id, + REMOTE_PROGRESS_ROLE) + progress = {'local': local_progress, + 'remote': remote_progress} side = 'remote' if site_name == self.model.active_site: side = 'local' @@ -533,11 +615,11 @@ class SyncRepresentationSummaryWidget(_SyncRepresentationWidget): table_view.viewport().setAttribute(QtCore.Qt.WA_Hover, True) column = table_view.model().get_header_index("local_site") - delegate = delegates.ImageDelegate(self) + delegate = delegates.ImageDelegate(self, side="local") table_view.setItemDelegateForColumn(column, delegate) column = table_view.model().get_header_index("remote_site") - delegate = delegates.ImageDelegate(self) + delegate = delegates.ImageDelegate(self, side="remote") table_view.setItemDelegateForColumn(column, delegate) column = table_view.model().get_header_index("priority") @@ -573,19 +655,21 @@ class SyncRepresentationSummaryWidget(_SyncRepresentationWidget): self.selection_model = self.table_view.selectionModel() self.selection_model.selectionChanged.connect(self._selection_changed) - def _prepare_menu(self, item, is_multi, can_edit): + def _prepare_menu(self, local_progress, remote_progress, + is_multi, can_edit, status=None): action_kwarg_map, actions_mapping, menu = \ - super()._prepare_menu(item, is_multi, can_edit) + super()._prepare_menu(local_progress, remote_progress, + is_multi, can_edit) if can_edit and ( - item.status in [lib.STATUS[0], lib.STATUS[1]] or is_multi): + status in [lib.STATUS[0], lib.STATUS[1]] or is_multi): action = QtWidgets.QAction("Pause in queue") actions_mapping[action] = self._pause # pause handles which site_name it will pause itself action_kwarg_map[action] = {"selected_ids": self._selected_ids} menu.addAction(action) - if can_edit and (item.status == lib.STATUS[3] or is_multi): + if can_edit and (status == lib.STATUS[3] or is_multi): action = QtWidgets.QAction("Unpause in queue") actions_mapping[action] = self._unpause action_kwarg_map[action] = {"selected_ids": self._selected_ids} @@ -695,11 +779,11 @@ class SyncRepresentationDetailWidget(_SyncRepresentationWidget): table_view.verticalHeader().hide() column = model.get_header_index("local_site") - delegate = delegates.ImageDelegate(self) + delegate = delegates.ImageDelegate(self, side="local") table_view.setItemDelegateForColumn(column, delegate) column = model.get_header_index("remote_site") - delegate = delegates.ImageDelegate(self) + delegate = delegates.ImageDelegate(self, side="remote") table_view.setItemDelegateForColumn(column, delegate) if model.can_edit: @@ -757,12 +841,14 @@ class SyncRepresentationDetailWidget(_SyncRepresentationWidget): detail_window.exec() - def _prepare_menu(self, item, is_multi, can_edit): + def _prepare_menu(self, local_progress, remote_progress, + is_multi, can_edit, status=None): """Adds view (and model) dependent actions to default ones""" action_kwarg_map, actions_mapping, menu = \ - super()._prepare_menu(item, is_multi, can_edit) + super()._prepare_menu(local_progress, remote_progress, + is_multi, can_edit, status) - if item.status == lib.STATUS[2] or is_multi: + if status == lib.STATUS[2] or is_multi: action = QtWidgets.QAction("Open error detail") actions_mapping[action] = self._show_detail action_kwarg_map[action] = {"selected_ids": self._selected_ids} @@ -777,8 +863,8 @@ class SyncRepresentationDetailWidget(_SyncRepresentationWidget): redo of upload/download """ for file_id in selected_ids: - item = lib.get_item_by_id(self.model, file_id) - check_progress = self._get_progress(item, site_name, True) + check_progress = self._get_progress(self.model, file_id, + site_name, True) # do not reset if opposite side is not fully there if check_progress != 1: @@ -786,7 +872,7 @@ class SyncRepresentationDetailWidget(_SyncRepresentationWidget): format(check_progress)) continue - self.sync_server.reset_provider_for_file( + self.sync_server.reset_site_on_representation( self.model.project, self.representation_id, site_name=site_name, @@ -837,20 +923,28 @@ class SyncRepresentationErrorWidget(QtWidgets.QWidget): no_errors = True for file_id in selected_ids: - item = lib.get_item_by_id(model, file_id) - if not item.created_dt or not item.sync_dt or not item.error: + created_dt = lib.get_value_from_id_by_role(model, file_id, + LOCAL_DATE_ROLE) + sync_dt = lib.get_value_from_id_by_role(model, file_id, + REMOTE_DATE_ROLE) + errors = lib.get_value_from_id_by_role(model, file_id, + ERROR_ROLE) + if not created_dt or not sync_dt or not errors: continue + tries = lib.get_value_from_id_by_role(model, file_id, + TRIES_ROLE) + no_errors = False - dt = max(item.created_dt, item.sync_dt) + dt = max(created_dt, sync_dt) txts = [] txts.append("{}: {}
".format("Last update date", pretty_timestamp(dt))) txts.append("{}: {}
".format("Retries", - str(item.tries))) + str(tries))) txts.append("{}: {}
".format("Error message", - item.error)) + errors)) text_area = QtWidgets.QTextEdit("\n\n".join(txts)) text_area.setReadOnly(True) @@ -1104,7 +1198,7 @@ class HorizontalHeader(QtWidgets.QHeaderView): column_name = self.model.headerData(column_idx, QtCore.Qt.Horizontal, - lib.HeaderNameRole) + HEADER_NAME_ROLE) button = self.filter_buttons.get(column_name) if not button: continue diff --git a/openpype/modules/default_modules/timers_manager/timers_manager.py b/openpype/modules/default_modules/timers_manager/timers_manager.py index 7687d056f8..1aeccbb958 100644 --- a/openpype/modules/default_modules/timers_manager/timers_manager.py +++ b/openpype/modules/default_modules/timers_manager/timers_manager.py @@ -1,10 +1,7 @@ import os import platform from openpype.modules import OpenPypeModule -from openpype_interfaces import ( - ITimersManager, - ITrayService -) +from openpype_interfaces import ITrayService from avalon.api import AvalonMongoDB diff --git a/openpype/modules/example_addons/example_addon/addon.py b/openpype/modules/example_addons/example_addon/addon.py index 5573e33cc1..50554b1e43 100644 --- a/openpype/modules/example_addons/example_addon/addon.py +++ b/openpype/modules/example_addons/example_addon/addon.py @@ -8,14 +8,15 @@ in global space here until are required or used. """ import os +import click from openpype.modules import ( JsonFilesSettingsDef, - OpenPypeAddOn + OpenPypeAddOn, + ModulesManager ) # Import interface defined by this addon to be able find other addons using it from openpype_interfaces import ( - IExampleInterface, IPluginPaths, ITrayAction ) @@ -75,19 +76,6 @@ class ExampleAddon(OpenPypeAddOn, IPluginPaths, ITrayAction): self._create_dialog() - def connect_with_modules(self, enabled_modules): - """Method where you should find connected modules. - - It is triggered by OpenPype modules manager at the best possible time. - Some addons and modules may required to connect with other modules - before their main logic is executed so changes would require to restart - whole process. - """ - self._connected_modules = [] - for module in enabled_modules: - if isinstance(module, IExampleInterface): - self._connected_modules.append(module) - def _create_dialog(self): # Don't recreate dialog if already exists if self._dialog is not None: @@ -106,8 +94,6 @@ class ExampleAddon(OpenPypeAddOn, IPluginPaths, ITrayAction): """ # Make sure dialog is created self._create_dialog() - # Change value of dialog by current state - self._dialog.set_connected_modules(self.get_connected_modules()) # Show dialog self._dialog.open() @@ -130,3 +116,32 @@ class ExampleAddon(OpenPypeAddOn, IPluginPaths, ITrayAction): return { "publish": [os.path.join(current_dir, "plugins", "publish")] } + + def cli(self, click_group): + click_group.add_command(cli_main) + + +@click.group(ExampleAddon.name, help="Example addon dynamic cli commands.") +def cli_main(): + pass + + +@cli_main.command() +def nothing(): + """Does nothing but print a message.""" + print("You've triggered \"nothing\" command.") + + +@cli_main.command() +def show_dialog(): + """Show ExampleAddon dialog. + + We don't have access to addon directly through cli so we have to create + it again. + """ + from openpype.tools.utils.lib import qt_app_context + + manager = ModulesManager() + example_addon = manager.modules_by_name[ExampleAddon.name] + with qt_app_context(): + example_addon.show_dialog() diff --git a/openpype/modules/example_addons/example_addon/interfaces.py b/openpype/modules/example_addons/example_addon/interfaces.py deleted file mode 100644 index 371536efc7..0000000000 --- a/openpype/modules/example_addons/example_addon/interfaces.py +++ /dev/null @@ -1,28 +0,0 @@ -""" Using interfaces is one way of connecting multiple OpenPype Addons/Modules. - -Interfaces must be in `interfaces.py` file (or folder). Interfaces should not -import module logic or other module in global namespace. That is because -all of them must be imported before all OpenPype AddOns and Modules. - -Ideally they should just define abstract and helper methods. If interface -require any logic or connection it should be defined in module. - -Keep in mind that attributes and methods will be added to other addon -attributes and methods so they should be unique and ideally contain -addon name in it's name. -""" - -from abc import abstractmethod -from openpype.modules import OpenPypeInterface - - -class IExampleInterface(OpenPypeInterface): - """Example interface of addon.""" - _example_module = None - - def get_example_module(self): - return self._example_module - - @abstractmethod - def example_method_of_example_interface(self): - pass diff --git a/openpype/modules/example_addons/example_addon/widgets.py b/openpype/modules/example_addons/example_addon/widgets.py index 0acf238409..c0a0a7e510 100644 --- a/openpype/modules/example_addons/example_addon/widgets.py +++ b/openpype/modules/example_addons/example_addon/widgets.py @@ -9,7 +9,8 @@ class MyExampleDialog(QtWidgets.QDialog): self.setWindowTitle("Connected modules") - label_widget = QtWidgets.QLabel(self) + msg = "This is example dialog of example addon." + label_widget = QtWidgets.QLabel(msg, self) ok_btn = QtWidgets.QPushButton("OK", self) btns_layout = QtWidgets.QHBoxLayout() @@ -28,12 +29,3 @@ class MyExampleDialog(QtWidgets.QDialog): def _on_ok_clicked(self): self.done(1) - - def set_connected_modules(self, connected_modules): - if connected_modules: - message = "\n".join(connected_modules) - else: - message = ( - "Other enabled modules/addons are not using my interface." - ) - self._label_widget.setText(message) diff --git a/openpype/modules/default_modules/interfaces.py b/openpype/modules/interfaces.py similarity index 91% rename from openpype/modules/default_modules/interfaces.py rename to openpype/modules/interfaces.py index a60c5fa606..e6e84a0d42 100644 --- a/openpype/modules/default_modules/interfaces.py +++ b/openpype/modules/interfaces.py @@ -263,3 +263,31 @@ class ITrayService(ITrayModule): """Change icon of an QAction to orange circle.""" if self.menu_action: self.menu_action.setIcon(self.get_icon_idle()) + + +class ISettingsChangeListener(OpenPypeInterface): + """Module has plugin paths to return. + + Expected result is dictionary with keys "publish", "create", "load" or + "actions" and values as list or string. + { + "publish": ["path/to/publish_plugins"] + } + """ + @abstractmethod + def on_system_settings_save( + self, old_value, new_value, changes, new_value_metadata + ): + pass + + @abstractmethod + def on_project_settings_save( + self, old_value, new_value, changes, project_name, new_value_metadata + ): + pass + + @abstractmethod + def on_project_anatomy_save( + self, old_value, new_value, changes, project_name, new_value_metadata + ): + pass diff --git a/openpype/pipeline/__init__.py b/openpype/pipeline/__init__.py new file mode 100644 index 0000000000..e968df4011 --- /dev/null +++ b/openpype/pipeline/__init__.py @@ -0,0 +1,28 @@ +from .lib import attribute_definitions + +from .create import ( + BaseCreator, + Creator, + AutoCreator, + CreatedInstance +) + +from .publish import ( + PublishValidationError, + KnownPublishError, + OpenPypePyblishPluginMixin +) + + +__all__ = ( + "attribute_definitions", + + "BaseCreator", + "Creator", + "AutoCreator", + "CreatedInstance", + + "PublishValidationError", + "KnownPublishError", + "OpenPypePyblishPluginMixin" +) diff --git a/openpype/pipeline/create/README.md b/openpype/pipeline/create/README.md new file mode 100644 index 0000000000..9eef7c72a7 --- /dev/null +++ b/openpype/pipeline/create/README.md @@ -0,0 +1,78 @@ +# Create +Creation is process defying what and how will be published. May work in a different way based on host implementation. + +## CreateContext +Entry point of creation. All data and metadata are handled through create context. Context hold all global data and instances. Is responsible for loading of plugins (create, publish), triggering creator methods, validation of host implementation and emitting changes to creators and host. + +Discovers Creator plugins to be able create new instances and convert existing instances. Creators may have defined attributes that are specific for their instances. Attributes definition can enhance behavior of instance during publishing. + +Publish plugins are loaded because they can also define attributes definitions. These are less family specific To be able define attributes Publish plugin must inherit from `OpenPypePyblishPluginMixin` and must override `get_attribute_defs` class method which must return list of attribute definitions. Values of publish plugin definitions are stored per plugin name under `publish_attributes`. Also can override `convert_attribute_values` class method which gives ability to modify values on instance before are used in CreatedInstance. Method `convert_attribute_values` can be also used without `get_attribute_defs` to modify values when changing compatibility (remove metadata from instance because are irrelevant). + +Possible attribute definitions can be found in `openpype/pipeline/lib/attribute_definitions.py`. + +Except creating and removing instances are all changes not automatically propagated to host context (scene/workfile/...) to propagate changes call `save_changes` which trigger update of all instances in context using Creators implementation. + + +## CreatedInstance +Product of creation is "instance" which holds basic data defying it. Core data are `creator_identifier`, `family` and `subset`. Other data can be keys used to fill subset name or metadata modifying publishing process of the instance (more described later). All instances have `id` which holds constant `pyblish.avalon.instance` and `uuid` which is identifier of the instance. +Family tells how should be instance processed and subset what name will published item have. +- There are cases when subset is not fully filled during creation and may change during publishing. That is in most of cases caused because instance is related to other instance or instance data do not represent final product. + +`CreatedInstance` is entity holding the data which are stored and used. + +```python +{ + # Immutable data after creation + ## Identifier that this data represents instance for publishing (automatically assigned) + "id": "pyblish.avalon.instance", + ## Identifier of this specific instance (automatically assigned) + "uuid": , + ## Instance family (used from Creator) + "family": , + + # Mutable data + ## Subset name based on subset name template - may change overtime (on context change) + "subset": , + ## Instance is active and will be published + "active": True, + ## Version of instance + "version": 1, + # Identifier of creator (is unique) + "creator_identifier": "", + ## Creator specific attributes (defined by Creator) + "creator_attributes": {...}, + ## Publish plugin specific plugins (defined by Publish plugin) + "publish_attributes": { + # Attribute values are stored by publish plugin name + # - Duplicated plugin names can cause clashes! + : {...}, + ... + }, + ## Additional data related to instance (`asset`, `task`, etc.) + ... +} +``` + +## Creator +To be able create, update, remove or collect existing instances there must be defined a creator. Creator must have unique identifier and can represents a family. There can be multiple Creators for single family. Identifier of creator should contain family (advise). + +Creator has abstract methods to handle instances. For new instance creation is used `create` which should create metadata in host context and add new instance object to `CreateContext`. To collect existing instances is used `collect_instances` which should find all existing instances related to creator and add them to `CreateContext`. To update data of instance is used `update_instances` which is called from `CreateContext` on `save_changes`. To remove instance use `remove_instances` which should remove metadata from host context and remove instance from `CreateContext`. + +Creator has access to `CreateContext` which created object of the creator. All new instances or removed instances must be told to context. To do so use methods `_add_instance_to_context` and `_remove_instance_from_context` where `CreatedInstance` is passed. They should be called from `create` if new instance was created and from `remove_instances` if instance was removed. + +Creators don't have strictly defined how are instances handled but it is good practice to define a way which is host specific. It is not strict because there are cases when host implementation just can't handle all requirements of all creators. + +### AutoCreator +Auto-creators are automatically executed when `CreateContext` is reset. They can be used to create instances that should be always available and may not require artist's manual creation (e.g. `workfile`). Should not create duplicated instance and validate existence before creates a new. Method `remove_instances` is implemented to do nothing. + +## Host +Host implementation must have available global context metadata handler functions. One to get current context data and second to update them. Currently are to context data stored only context publish plugin attribute values. + +### Get global context data (`get_context_data`) +There are data that are not specific for any instance but are specific for whole context (e.g. Context plugins values). + +### Update global context data (`update_context_data`) +Update global context data. + +### Optional title of context +It is recommended to implement `get_context_title` function. String returned from this function will be shown in UI as context in which artist is. diff --git a/openpype/pipeline/create/__init__.py b/openpype/pipeline/create/__init__.py new file mode 100644 index 0000000000..610ef6d8e2 --- /dev/null +++ b/openpype/pipeline/create/__init__.py @@ -0,0 +1,24 @@ +from .creator_plugins import ( + CreatorError, + + BaseCreator, + Creator, + AutoCreator +) + +from .context import ( + CreatedInstance, + CreateContext +) + + +__all__ = ( + "CreatorError", + + "BaseCreator", + "Creator", + "AutoCreator", + + "CreatedInstance", + "CreateContext" +) diff --git a/openpype/pipeline/create/context.py b/openpype/pipeline/create/context.py new file mode 100644 index 0000000000..7b0f50b1dc --- /dev/null +++ b/openpype/pipeline/create/context.py @@ -0,0 +1,1142 @@ +import os +import copy +import logging +import collections +import inspect +from uuid import uuid4 +from contextlib import contextmanager + +from ..lib import UnknownDef +from .creator_plugins import ( + BaseCreator, + Creator, + AutoCreator +) + +from openpype.api import ( + get_system_settings, + get_project_settings +) + + +class ImmutableKeyError(TypeError): + """Accessed key is immutable so does not allow changes or removements.""" + def __init__(self, key, msg=None): + self.immutable_key = key + if not msg: + msg = "Key \"{}\" is immutable and does not allow changes.".format( + key + ) + super(ImmutableKeyError, self).__init__(msg) + + +class HostMissRequiredMethod(Exception): + """Host does not have implemented required functions for creation.""" + def __init__(self, host, missing_methods): + self.missing_methods = missing_methods + self.host = host + joined_methods = ", ".join( + ['"{}"'.format(name) for name in missing_methods] + ) + dirpath = os.path.dirname( + os.path.normpath(inspect.getsourcefile(host)) + ) + dirpath_parts = dirpath.split(os.path.sep) + host_name = dirpath_parts.pop(-1) + if host_name == "api": + host_name = dirpath_parts.pop(-1) + + msg = "Host \"{}\" does not have implemented method/s {}".format( + host_name, joined_methods + ) + super(HostMissRequiredMethod, self).__init__(msg) + + +class InstanceMember: + """Representation of instance member. + + TODO: + Implement and use! + """ + def __init__(self, instance, name): + self.instance = instance + + instance.add_members(self) + + self.name = name + self._actions = [] + + def add_action(self, label, callback): + self._actions.append({ + "label": label, + "callback": callback + }) + + +class AttributeValues: + """Container which keep values of Attribute definitions. + + Goal is to have one object which hold values of attribute definitions for + single instance. + + Has dictionary like methods. Not all of them are allowed all the time. + + Args: + attr_defs(AbtractAttrDef): Defintions of value type and properties. + values(dict): Values after possible conversion. + origin_data(dict): Values loaded from host before conversion. + """ + def __init__(self, attr_defs, values, origin_data=None): + if origin_data is None: + origin_data = copy.deepcopy(values) + self._origin_data = origin_data + + attr_defs_by_key = { + attr_def.key: attr_def + for attr_def in attr_defs + } + for key, value in values.items(): + if key not in attr_defs_by_key: + new_def = UnknownDef(key, label=key, default=value) + attr_defs.append(new_def) + attr_defs_by_key[key] = new_def + + self._attr_defs = attr_defs + self._attr_defs_by_key = attr_defs_by_key + + self._data = {} + for attr_def in attr_defs: + value = values.get(attr_def.key) + if value is not None: + self._data[attr_def.key] = value + + def __setitem__(self, key, value): + if key not in self._attr_defs_by_key: + raise KeyError("Key \"{}\" was not found.".format(key)) + + old_value = self._data.get(key) + if old_value == value: + return + self._data[key] = value + + def __getitem__(self, key): + if key not in self._attr_defs_by_key: + return self._data[key] + return self._data.get(key, self._attr_defs_by_key[key].default) + + def __contains__(self, key): + return key in self._attr_defs_by_key + + def get(self, key, default=None): + if key in self._attr_defs_by_key: + return self[key] + return default + + def keys(self): + return self._attr_defs_by_key.keys() + + def values(self): + for key in self._attr_defs_by_key.keys(): + yield self._data.get(key) + + def items(self): + for key in self._attr_defs_by_key.keys(): + yield key, self._data.get(key) + + def update(self, value): + for _key, _value in dict(value): + self[_key] = _value + + def pop(self, key, default=None): + return self._data.pop(key, default) + + def reset_values(self): + self._data = [] + + @property + def attr_defs(self): + """Pointer to attribute definitions.""" + return self._attr_defs + + def data_to_store(self): + """Create new dictionary with data to store.""" + output = {} + for key in self._data: + output[key] = self[key] + return output + + @staticmethod + def calculate_changes(new_data, old_data): + """Calculate changes of 2 dictionary objects.""" + changes = {} + for key, new_value in new_data.items(): + old_value = old_data.get(key) + if old_value != new_value: + changes[key] = (old_value, new_value) + return changes + + def changes(self): + return self.calculate_changes(self._data, self._origin_data) + + +class CreatorAttributeValues(AttributeValues): + """Creator specific attribute values of an instance. + + Args: + instance (CreatedInstance): Instance for which are values hold. + """ + def __init__(self, instance, *args, **kwargs): + self.instance = instance + super(CreatorAttributeValues, self).__init__(*args, **kwargs) + + +class PublishAttributeValues(AttributeValues): + """Publish plugin specific attribute values. + + Values are for single plugin which can be on `CreatedInstance` + or context values stored on `CreateContext`. + + Args: + publish_attributes(PublishAttributes): Wrapper for multiple publish + attributes is used as parent object. + """ + def __init__(self, publish_attributes, *args, **kwargs): + self.publish_attributes = publish_attributes + super(PublishAttributeValues, self).__init__(*args, **kwargs) + + @property + def parent(self): + self.publish_attributes.parent + + +class PublishAttributes: + """Wrapper for publish plugin attribute definitions. + + Cares about handling attribute definitions of multiple publish plugins. + + Args: + parent(CreatedInstance, CreateContext): Parent for which will be + data stored and from which are data loaded. + origin_data(dict): Loaded data by plugin class name. + attr_plugins(list): List of publish plugins that may have defined + attribute definitions. + """ + def __init__(self, parent, origin_data, attr_plugins=None): + self.parent = parent + self._origin_data = copy.deepcopy(origin_data) + + attr_plugins = attr_plugins or [] + self.attr_plugins = attr_plugins + + self._data = copy.deepcopy(origin_data) + self._plugin_names_order = [] + self._missing_plugins = [] + + self.set_publish_plugins(attr_plugins) + + def __getitem__(self, key): + return self._data[key] + + def __contains__(self, key): + return key in self._data + + def keys(self): + return self._data.keys() + + def values(self): + return self._data.values() + + def items(self): + return self._data.items() + + def pop(self, key, default=None): + """Remove or reset value for plugin. + + Plugin values are reset to defaults if plugin is available but + data of plugin which was not found are removed. + + Args: + key(str): Plugin name. + default: Default value if plugin was not found. + """ + if key not in self._data: + return default + + if key in self._missing_plugins: + self._missing_plugins.remove(key) + removed_item = self._data.pop(key) + return removed_item.data_to_store() + + value_item = self._data[key] + # Prepare value to return + output = value_item.data_to_store() + # Reset values + value_item.reset_values() + return output + + def plugin_names_order(self): + """Plugin names order by their 'order' attribute.""" + for name in self._plugin_names_order: + yield name + + def data_to_store(self): + """Convert attribute values to "data to store".""" + output = {} + for key, attr_value in self._data.items(): + output[key] = attr_value.data_to_store() + return output + + def changes(self): + """Return changes per each key.""" + changes = {} + for key, attr_val in self._data.items(): + attr_changes = attr_val.changes() + if attr_changes: + if key not in changes: + changes[key] = {} + changes[key].update(attr_val) + + for key, value in self._origin_data.items(): + if key not in self._data: + changes[key] = (value, None) + return changes + + def set_publish_plugins(self, attr_plugins): + """Set publish plugins attribute definitions.""" + self._plugin_names_order = [] + self._missing_plugins = [] + self.attr_plugins = attr_plugins or [] + if not attr_plugins: + return + + origin_data = self._origin_data + data = self._data + self._data = {} + added_keys = set() + for plugin in attr_plugins: + output = plugin.convert_attribute_values(data) + if output is not None: + data = output + attr_defs = plugin.get_attribute_defs() + if not attr_defs: + continue + + key = plugin.__name__ + added_keys.add(key) + self._plugin_names_order.append(key) + + value = data.get(key) or {} + orig_value = copy.deepcopy(origin_data.get(key) or {}) + self._data[key] = PublishAttributeValues( + self, attr_defs, value, orig_value + ) + + for key, value in data.items(): + if key not in added_keys: + self._missing_plugins.append(key) + self._data[key] = PublishAttributeValues( + self, [], value, value + ) + + +class CreatedInstance: + """Instance entity with data that will be stored to workfile. + + I think `data` must be required argument containing all minimum information + about instance like "asset" and "task" and all data used for filling subset + name as creators may have custom data for subset name filling. + + Args: + family(str): Name of family that will be created. + subset_name(str): Name of subset that will be created. + data(dict): Data used for filling subset name or override data from + already existing instance. + creator(BaseCreator): Creator responsible for instance. + host(ModuleType): Host implementation loaded with + `avalon.api.registered_host`. + new(bool): Is instance new. + """ + # Keys that can't be changed or removed from data after loading using + # creator. + # - 'creator_attributes' and 'publish_attributes' can change values of + # their individual children but not on their own + __immutable_keys = ( + "id", + "uuid", + "family", + "creator_identifier", + "creator_attributes", + "publish_attributes" + ) + + def __init__( + self, family, subset_name, data, creator, new=True + ): + self.creator = creator + + # Instance members may have actions on them + self._members = [] + + # Create a copy of passed data to avoid changing them on the fly + data = copy.deepcopy(data or {}) + # Store original value of passed data + self._orig_data = copy.deepcopy(data) + + # Pop family and subset to prevent unexpected changes + data.pop("family", None) + data.pop("subset", None) + + # Pop dictionary values that will be converted to objects to be able + # catch changes + orig_creator_attributes = data.pop("creator_attributes", None) or {} + orig_publish_attributes = data.pop("publish_attributes", None) or {} + + # QUESTION Does it make sense to have data stored as ordered dict? + self._data = collections.OrderedDict() + # QUESTION Do we need this "id" information on instance? + self._data["id"] = "pyblish.avalon.instance" + self._data["family"] = family + self._data["subset"] = subset_name + self._data["active"] = data.get("active", True) + self._data["creator_identifier"] = creator.identifier + + # QUESTION handle version of instance here or in creator? + version = None + if not new: + version = data.get("version") + + if version is None: + version = 1 + self._data["version"] = version + + # Pop from source data all keys that are defined in `_data` before + # this moment and through their values away + # - they should be the same and if are not then should not change + # already set values + for key in self._data.keys(): + if key in data: + data.pop(key) + + # Stored creator specific attribute values + # {key: value} + creator_values = copy.deepcopy(orig_creator_attributes) + creator_attr_defs = creator.get_attribute_defs() + + self._data["creator_attributes"] = CreatorAttributeValues( + self, creator_attr_defs, creator_values, orig_creator_attributes + ) + + # Stored publish specific attribute values + # {: {key: value}} + # - must be set using 'set_publish_plugins' + self._data["publish_attributes"] = PublishAttributes( + self, orig_publish_attributes, None + ) + if data: + self._data.update(data) + + if not self._data.get("uuid"): + self._data["uuid"] = str(uuid4()) + + self._asset_is_valid = self.has_set_asset + self._task_is_valid = self.has_set_task + + def __str__(self): + return ( + "" + " {data}" + ).format( + subset=str(self._data), + creator_identifier=self.creator_identifier, + family=self.family, + data=str(self._data) + ) + + # --- Dictionary like methods --- + def __getitem__(self, key): + return self._data[key] + + def __contains__(self, key): + return key in self._data + + def __setitem__(self, key, value): + # Validate immutable keys + if key not in self.__immutable_keys: + self._data[key] = value + + elif value != self._data.get(key): + # Raise exception if key is immutable and value has changed + raise ImmutableKeyError(key) + + def get(self, key, default=None): + return self._data.get(key, default) + + def pop(self, key, *args, **kwargs): + # Raise exception if is trying to pop key which is immutable + if key in self.__immutable_keys: + raise ImmutableKeyError(key) + + self._data.pop(key, *args, **kwargs) + + def keys(self): + return self._data.keys() + + def values(self): + return self._data.values() + + def items(self): + return self._data.items() + # ------ + + @property + def family(self): + return self._data["family"] + + @property + def subset_name(self): + return self._data["subset"] + + @property + def creator_identifier(self): + return self.creator.identifier + + @property + def creator_label(self): + return self.creator.label or self.creator_identifier + + @property + def create_context(self): + return self.creator.create_context + + @property + def host(self): + return self.create_context.host + + @property + def has_set_asset(self): + """Asset name is set in data.""" + return "asset" in self._data + + @property + def has_set_task(self): + """Task name is set in data.""" + return "task" in self._data + + @property + def has_valid_context(self): + """Context data are valid for publishing.""" + return self.has_valid_asset and self.has_valid_task + + @property + def has_valid_asset(self): + """Asset set in context exists in project.""" + if not self.has_set_asset: + return False + return self._asset_is_valid + + @property + def has_valid_task(self): + """Task set in context exists in project.""" + if not self.has_set_task: + return False + return self._task_is_valid + + def set_asset_invalid(self, invalid): + # TODO replace with `set_asset_name` + self._asset_is_valid = not invalid + + def set_task_invalid(self, invalid): + # TODO replace with `set_task_name` + self._task_is_valid = not invalid + + @property + def id(self): + """Instance identifier.""" + return self._data["uuid"] + + @property + def data(self): + """Legacy access to data. + + Access to data is needed to modify values. + """ + return self + + def changes(self): + """Calculate and return changes.""" + changes = {} + new_keys = set() + for key, new_value in self._data.items(): + new_keys.add(key) + if key in ("creator_attributes", "publish_attributes"): + continue + + old_value = self._orig_data.get(key) + if old_value != new_value: + changes[key] = (old_value, new_value) + + creator_attr_changes = self.creator_attributes.changes() + if creator_attr_changes: + changes["creator_attributes"] = creator_attr_changes + + publish_attr_changes = self.publish_attributes.changes() + if publish_attr_changes: + changes["publish_attributes"] = publish_attr_changes + + for key, old_value in self._orig_data.items(): + if key not in new_keys: + changes[key] = (old_value, None) + return changes + + @property + def creator_attributes(self): + return self._data["creator_attributes"] + + @property + def creator_attribute_defs(self): + return self.creator_attributes.attr_defs + + @property + def publish_attributes(self): + return self._data["publish_attributes"] + + def data_to_store(self): + output = collections.OrderedDict() + for key, value in self._data.items(): + if key in ("creator_attributes", "publish_attributes"): + continue + output[key] = value + + output["creator_attributes"] = self.creator_attributes.data_to_store() + output["publish_attributes"] = self.publish_attributes.data_to_store() + + return output + + @classmethod + def from_existing(cls, instance_data, creator): + """Convert instance data from workfile to CreatedInstance.""" + instance_data = copy.deepcopy(instance_data) + + family = instance_data.get("family", None) + if family is None: + family = creator.family + subset_name = instance_data.get("subset", None) + + return cls( + family, subset_name, instance_data, creator, new=False + ) + + def set_publish_plugins(self, attr_plugins): + self.publish_attributes.set_publish_plugins(attr_plugins) + + def add_members(self, members): + """Currently unused method.""" + for member in members: + if member not in self._members: + self._members.append(member) + + +class CreateContext: + """Context of instance creation. + + Context itself also can store data related to whole creation (workfile). + - those are mainly for Context publish plugins + + Args: + host(ModuleType): Host implementation which handles implementation and + global metadata. + dbcon(AvalonMongoDB): Connection to mongo with context (at least + project). + headless(bool): Context is created out of UI (Current not used). + reset(bool): Reset context on initialization. + discover_publish_plugins(bool): Discover publish plugins during reset + phase. + """ + # Methods required in host implementaion to be able create instances + # or change context data. + required_methods = ( + "get_context_data", + "update_context_data" + ) + + def __init__( + self, host, dbcon=None, headless=False, reset=True, + discover_publish_plugins=True + ): + # Create conncetion if is not passed + if dbcon is None: + import avalon.api + + session = avalon.api.session_data_from_environment(True) + dbcon = avalon.api.AvalonMongoDB(session) + dbcon.install() + + self.dbcon = dbcon + self.host = host + + # Prepare attribute for logger (Created on demand in `log` property) + self._log = None + + # Publish context plugins attributes and it's values + self._publish_attributes = PublishAttributes(self, {}) + self._original_context_data = {} + + # Validate host implementation + # - defines if context is capable of handling context data + host_is_valid = True + missing_methods = self.get_host_misssing_methods(host) + if missing_methods: + host_is_valid = False + joined_methods = ", ".join( + ['"{}"'.format(name) for name in missing_methods] + ) + self.log.warning(( + "Host miss required methods to be able use creation." + " Missing methods: {}" + ).format(joined_methods)) + + self._host_is_valid = host_is_valid + # Currently unused variable + self.headless = headless + + # Instances by their ID + self._instances_by_id = {} + + # Discovered creators + self.creators = {} + # Prepare categories of creators + self.autocreators = {} + # Manual creators + self.manual_creators = {} + + self.publish_discover_result = None + self.publish_plugins = [] + self.plugins_with_defs = [] + self._attr_plugins_by_family = {} + + # Helpers for validating context of collected instances + # - they can be validation for multiple instances at one time + # using context manager which will trigger validation + # after leaving of last context manager scope + self._bulk_counter = 0 + self._bulk_instances_to_process = [] + + # Trigger reset if was enabled + if reset: + self.reset(discover_publish_plugins) + + @property + def instances(self): + return self._instances_by_id.values() + + @property + def publish_attributes(self): + """Access to global publish attributes.""" + return self._publish_attributes + + @classmethod + def get_host_misssing_methods(cls, host): + """Collect missing methods from host. + + Args: + host(ModuleType): Host implementaion. + """ + missing = set() + for attr_name in cls.required_methods: + if not hasattr(host, attr_name): + missing.add(attr_name) + return missing + + @property + def host_is_valid(self): + """Is host valid for creation.""" + return self._host_is_valid + + @property + def log(self): + """Dynamic access to logger.""" + if self._log is None: + self._log = logging.getLogger(self.__class__.__name__) + return self._log + + def reset(self, discover_publish_plugins=True): + """Reset context with all plugins and instances. + + All changes will be lost if were not saved explicitely. + """ + self.reset_avalon_context() + self.reset_plugins(discover_publish_plugins) + self.reset_context_data() + + with self.bulk_instances_collection(): + self.reset_instances() + self.execute_autocreators() + + def reset_avalon_context(self): + """Give ability to reset avalon context. + + Reset is based on optional host implementation of `get_current_context` + function or using `avalon.api.Session`. + + Some hosts have ability to change context file without using workfiles + tool but that change is not propagated to + """ + import avalon.api + + project_name = asset_name = task_name = None + if hasattr(self.host, "get_current_context"): + host_context = self.host.get_current_context() + if host_context: + project_name = host_context.get("project_name") + asset_name = host_context.get("asset_name") + task_name = host_context.get("task_name") + + if not project_name: + project_name = avalon.api.Session.get("AVALON_PROJECT") + if not asset_name: + asset_name = avalon.api.Session.get("AVALON_ASSET") + if not task_name: + task_name = avalon.api.Session.get("AVALON_TASK") + + if project_name: + self.dbcon.Session["AVALON_PROJECT"] = project_name + if asset_name: + self.dbcon.Session["AVALON_ASSET"] = asset_name + if task_name: + self.dbcon.Session["AVALON_TASK"] = task_name + + def reset_plugins(self, discover_publish_plugins=True): + """Reload plugins. + + Reloads creators from preregistered paths and can load publish plugins + if it's enabled on context. + """ + import avalon.api + import pyblish.logic + + from openpype.pipeline import OpenPypePyblishPluginMixin + from openpype.pipeline.publish import ( + publish_plugins_discover, + DiscoverResult + ) + + # Reset publish plugins + self._attr_plugins_by_family = {} + + discover_result = DiscoverResult() + plugins_with_defs = [] + plugins_by_targets = [] + if discover_publish_plugins: + discover_result = publish_plugins_discover() + publish_plugins = discover_result.plugins + + targets = pyblish.logic.registered_targets() or ["default"] + plugins_by_targets = pyblish.logic.plugins_by_targets( + publish_plugins, targets + ) + # Collect plugins that can have attribute definitions + for plugin in publish_plugins: + if OpenPypePyblishPluginMixin in inspect.getmro(plugin): + plugins_with_defs.append(plugin) + + self.publish_discover_result = discover_result + self.publish_plugins = plugins_by_targets + self.plugins_with_defs = plugins_with_defs + + # Prepare settings + project_name = self.dbcon.Session["AVALON_PROJECT"] + system_settings = get_system_settings() + project_settings = get_project_settings(project_name) + + # Discover and prepare creators + creators = {} + autocreators = {} + manual_creators = {} + for creator_class in avalon.api.discover(BaseCreator): + if inspect.isabstract(creator_class): + self.log.info( + "Skipping abstract Creator {}".format(str(creator_class)) + ) + continue + + creator_identifier = creator_class.identifier + if creator_identifier in creators: + self.log.warning(( + "Duplicated Creator identifier. " + "Using first and skipping following" + )) + continue + creator = creator_class( + self, + system_settings, + project_settings, + self.headless + ) + creators[creator_identifier] = creator + if isinstance(creator, AutoCreator): + autocreators[creator_identifier] = creator + elif isinstance(creator, Creator): + manual_creators[creator_identifier] = creator + + self.autocreators = autocreators + self.manual_creators = manual_creators + + self.creators = creators + + def reset_context_data(self): + """Reload context data using host implementation. + + These data are not related to any instance but may be needed for whole + publishing. + """ + if not self.host_is_valid: + self._original_context_data = {} + self._publish_attributes = PublishAttributes(self, {}) + return + + original_data = self.host.get_context_data() or {} + self._original_context_data = copy.deepcopy(original_data) + + publish_attributes = original_data.get("publish_attributes") or {} + + attr_plugins = self._get_publish_plugins_with_attr_for_context() + self._publish_attributes = PublishAttributes( + self, publish_attributes, attr_plugins + ) + + def context_data_to_store(self): + """Data that should be stored by host function. + + The same data should be returned on loading. + """ + return { + "publish_attributes": self._publish_attributes.data_to_store() + } + + def context_data_changes(self): + """Changes of attributes.""" + changes = {} + publish_attribute_changes = self._publish_attributes.changes() + if publish_attribute_changes: + changes["publish_attributes"] = publish_attribute_changes + return changes + + def creator_adds_instance(self, instance): + """Creator adds new instance to context. + + Instances should be added only from creators. + + Args: + instance(CreatedInstance): Instance with prepared data from + creator. + + TODO: Rename method to more suit. + """ + # Add instance to instances list + if instance.id in self._instances_by_id: + self.log.warning(( + "Instance with id {} is already added to context." + ).format(instance.id)) + return + + self._instances_by_id[instance.id] = instance + # Prepare publish plugin attributes and set it on instance + attr_plugins = self._get_publish_plugins_with_attr_for_family( + instance.creator.family + ) + instance.set_publish_plugins(attr_plugins) + + # Add instance to be validated inside 'bulk_instances_collection' + # context manager if is inside bulk + with self.bulk_instances_collection(): + self._bulk_instances_to_process.append(instance) + + def creator_removed_instance(self, instance): + self._instances_by_id.pop(instance.id, None) + + @contextmanager + def bulk_instances_collection(self): + """Validate context of instances in bulk. + + This can be used for single instance or for adding multiple instances + which is helpfull on reset. + + Should not be executed from multiple threads. + """ + self._bulk_counter += 1 + try: + yield + finally: + self._bulk_counter -= 1 + + # Trigger validation if there is no more context manager for bulk + # instance validation + if self._bulk_counter == 0: + ( + self._bulk_instances_to_process, + instances_to_validate + ) = ( + [], + self._bulk_instances_to_process + ) + self.validate_instances_context(instances_to_validate) + + def reset_instances(self): + """Reload instances""" + self._instances_by_id = {} + + # Collect instances + for creator in self.creators.values(): + creator.collect_instances() + + def execute_autocreators(self): + """Execute discovered AutoCreator plugins. + + Reset instances if any autocreator executed properly. + """ + for identifier, creator in self.autocreators.items(): + try: + creator.create() + + except Exception: + # TODO raise report exception if any crashed + msg = ( + "Failed to run AutoCreator with identifier \"{}\" ({})." + ).format(identifier, inspect.getfile(creator.__class__)) + self.log.warning(msg, exc_info=True) + + def validate_instances_context(self, instances=None): + """Validate 'asset' and 'task' instance context.""" + # Use all instances from context if 'instances' are not passed + if instances is None: + instances = tuple(self._instances_by_id.values()) + + # Skip if instances are empty + if not instances: + return + + task_names_by_asset_name = collections.defaultdict(set) + for instance in instances: + task_name = instance.get("task") + asset_name = instance.get("asset") + if asset_name and task_name: + task_names_by_asset_name[asset_name].add(task_name) + + asset_names = [ + asset_name + for asset_name in task_names_by_asset_name.keys() + if asset_name is not None + ] + asset_docs = list(self.dbcon.find( + { + "type": "asset", + "name": {"$in": asset_names} + }, + { + "name": True, + "data.tasks": True + } + )) + + task_names_by_asset_name = {} + for asset_doc in asset_docs: + asset_name = asset_doc["name"] + tasks = asset_doc.get("data", {}).get("tasks") or {} + task_names_by_asset_name[asset_name] = set(tasks.keys()) + + for instance in instances: + if not instance.has_valid_asset or not instance.has_valid_task: + continue + + asset_name = instance["asset"] + if asset_name not in task_names_by_asset_name: + instance.set_asset_invalid(True) + continue + + task_name = instance["task"] + if not task_name: + continue + + if task_name not in task_names_by_asset_name[asset_name]: + instance.set_task_invalid(True) + + def save_changes(self): + """Save changes. Update all changed values.""" + if not self.host_is_valid: + missing_methods = self.get_host_misssing_methods(self.host) + raise HostMissRequiredMethod(self.host, missing_methods) + + self._save_context_changes() + self._save_instance_changes() + + def _save_context_changes(self): + """Save global context values.""" + changes = self.context_data_changes() + if changes: + data = self.context_data_to_store() + self.host.update_context_data(data, changes) + + def _save_instance_changes(self): + """Save instance specific values.""" + instances_by_identifier = collections.defaultdict(list) + for instance in self._instances_by_id.values(): + identifier = instance.creator_identifier + instances_by_identifier[identifier].append(instance) + + for identifier, cretor_instances in instances_by_identifier.items(): + update_list = [] + for instance in cretor_instances: + instance_changes = instance.changes() + if instance_changes: + update_list.append((instance, instance_changes)) + + creator = self.creators[identifier] + if update_list: + creator.update_instances(update_list) + + def remove_instances(self, instances): + """Remove instances from context. + + Args: + instances(list): Instances that should be removed + from context. + """ + instances_by_identifier = collections.defaultdict(list) + for instance in instances: + identifier = instance.creator_identifier + instances_by_identifier[identifier].append(instance) + + for identifier, creator_instances in instances_by_identifier.items(): + creator = self.creators.get(identifier) + creator.remove_instances(creator_instances) + + def _get_publish_plugins_with_attr_for_family(self, family): + """Publish plugin attributes for passed family. + + Attribute definitions for specific family are cached. + + Args: + family(str): Instance family for which should be attribute + definitions returned. + """ + if family not in self._attr_plugins_by_family: + import pyblish.logic + + filtered_plugins = pyblish.logic.plugins_by_families( + self.plugins_with_defs, [family] + ) + plugins = [] + for plugin in filtered_plugins: + if plugin.__instanceEnabled__: + plugins.append(plugin) + self._attr_plugins_by_family[family] = plugins + + return self._attr_plugins_by_family[family] + + def _get_publish_plugins_with_attr_for_context(self): + """Publish plugins attributes for Context plugins.""" + plugins = [] + for plugin in self.plugins_with_defs: + if not plugin.__instanceEnabled__: + plugins.append(plugin) + return plugins diff --git a/openpype/pipeline/create/creator_plugins.py b/openpype/pipeline/create/creator_plugins.py new file mode 100644 index 0000000000..aa2e3333ce --- /dev/null +++ b/openpype/pipeline/create/creator_plugins.py @@ -0,0 +1,269 @@ +import copy +import logging + +from abc import ( + ABCMeta, + abstractmethod, + abstractproperty +) +import six + +from openpype.lib import get_subset_name_with_asset_doc + + +class CreatorError(Exception): + """Should be raised when creator failed because of known issue. + + Message of error should be user readable. + """ + + def __init__(self, message): + super(CreatorError, self).__init__(message) + + +@six.add_metaclass(ABCMeta) +class BaseCreator: + """Plugin that create and modify instance data before publishing process. + + We should maybe find better name as creation is only one part of it's logic + and to avoid expectations that it is the same as `avalon.api.Creator`. + + Single object should be used for multiple instances instead of single + instance per one creator object. Do not store temp data or mid-process data + to `self` if it's not Plugin specific. + """ + + # Label shown in UI + label = None + + # Variable to store logger + _log = None + + # Creator is enabled (Probably does not have reason of existence?) + enabled = True + + # Creator (and family) icon + # - may not be used if `get_icon` is reimplemented + icon = None + + def __init__( + self, create_context, system_settings, project_settings, headless=False + ): + # Reference to CreateContext + self.create_context = create_context + + # Creator is running in headless mode (without UI elemets) + # - we may use UI inside processing this attribute should be checked + self.headless = headless + + @abstractproperty + def identifier(self): + """Identifier of creator (must be unique).""" + pass + + @abstractproperty + def family(self): + """Family that plugin represents.""" + pass + + @property + def log(self): + if self._log is None: + self._log = logging.getLogger(self.__class__.__name__) + return self._log + + def _add_instance_to_context(self, instance): + """Helper method to ad d""" + self.create_context.creator_adds_instance(instance) + + def _remove_instance_from_context(self, instance): + self.create_context.creator_removed_instance(instance) + + @abstractmethod + def create(self, options=None): + """Create new instance. + + Replacement of `process` method from avalon implementation. + - must expect all data that were passed to init in previous + implementation + """ + pass + + @abstractmethod + def collect_instances(self, attr_plugins=None): + pass + + @abstractmethod + def update_instances(self, update_list): + pass + + @abstractmethod + def remove_instances(self, instances): + """Method called on instance removement. + + Can also remove instance metadata from context but should return + 'True' if did so. + + Args: + instance(list): Instance objects which should be + removed. + """ + pass + + def get_icon(self): + """Icon of creator (family). + + Can return path to image file or awesome icon name. + """ + return self.icon + + def get_dynamic_data( + self, variant, task_name, asset_doc, project_name, host_name + ): + """Dynamic data for subset name filling. + + These may be get dynamically created based on current context of + workfile. + """ + return {} + + def get_subset_name( + self, variant, task_name, asset_doc, project_name, host_name=None + ): + """Return subset name for passed context. + + CHANGES: + Argument `asset_id` was replaced with `asset_doc`. It is easier to + query asset before. In some cases would this method be called multiple + times and it would be too slow to query asset document on each + callback. + + NOTE: + Asset document is not used yet but is required if would like to use + task type in subset templates. + + Args: + variant(str): Subset name variant. In most of cases user input. + task_name(str): For which task subset is created. + asset_doc(dict): Asset document for which subset is created. + project_name(str): Project name. + host_name(str): Which host creates subset. + """ + dynamic_data = self.get_dynamic_data( + variant, task_name, asset_doc, project_name, host_name + ) + + return get_subset_name_with_asset_doc( + self.family, + variant, + task_name, + asset_doc, + project_name, + host_name, + dynamic_data=dynamic_data + ) + + def get_attribute_defs(self): + """Plugin attribute definitions. + + Attribute definitions of plugin that hold data about created instance + and values are stored to metadata for future usage and for publishing + purposes. + + NOTE: + Convert method should be implemented which should care about updating + keys/values when plugin attributes change. + + Returns: + list: Attribute definitions that can be tweaked for + created instance. + """ + return [] + + +class Creator(BaseCreator): + """Creator that has more information for artist to show in UI. + + Creation requires prepared subset name and instance data. + """ + + # GUI Purposes + # - default_variants may not be used if `get_default_variants` is overriden + default_variants = [] + + # Short description of family + # - may not be used if `get_description` is overriden + description = None + + # Detailed description of family for artists + # - may not be used if `get_detail_description` is overriden + detailed_description = None + + @abstractmethod + def create(self, subset_name, instance_data, options=None): + """Create new instance and store it. + + Ideally should be stored to workfile using host implementation. + + Args: + subset_name(str): Subset name of created instance. + instance_data(dict): + """ + + # instance = CreatedInstance( + # self.family, subset_name, instance_data + # ) + pass + + def get_description(self): + """Short description of family and plugin. + + Returns: + str: Short description of family. + """ + return self.description + + def get_detail_description(self): + """Description of family and plugin. + + Can be detailed with markdown or html tags. + + Returns: + str: Detailed description of family for artist. + """ + return self.detailed_description + + def get_default_variants(self): + """Default variant values for UI tooltips. + + Replacement of `defatults` attribute. Using method gives ability to + have some "logic" other than attribute values. + + By default returns `default_variants` value. + + Returns: + list: Whisper variants for user input. + """ + return copy.deepcopy(self.default_variants) + + def get_default_variant(self): + """Default variant value that will be used to prefill variant input. + + This is for user input and value may not be content of result from + `get_default_variants`. + + Can return `None`. In that case first element from + `get_default_variants` should be used. + """ + + return None + + +class AutoCreator(BaseCreator): + """Creator which is automatically triggered without user interaction. + + Can be used e.g. for `workfile`. + """ + def remove_instances(self, instances): + """Skip removement.""" + pass diff --git a/openpype/pipeline/lib/__init__.py b/openpype/pipeline/lib/__init__.py new file mode 100644 index 0000000000..1bb65be79b --- /dev/null +++ b/openpype/pipeline/lib/__init__.py @@ -0,0 +1,18 @@ +from .attribute_definitions import ( + AbtractAttrDef, + UnknownDef, + NumberDef, + TextDef, + EnumDef, + BoolDef +) + + +__all__ = ( + "AbtractAttrDef", + "UnknownDef", + "NumberDef", + "TextDef", + "EnumDef", + "BoolDef" +) diff --git a/openpype/pipeline/lib/attribute_definitions.py b/openpype/pipeline/lib/attribute_definitions.py new file mode 100644 index 0000000000..2b34e15bc4 --- /dev/null +++ b/openpype/pipeline/lib/attribute_definitions.py @@ -0,0 +1,263 @@ +import re +import collections +import uuid +from abc import ABCMeta, abstractmethod +import six + + +class AbstractAttrDefMeta(ABCMeta): + """Meta class to validate existence of 'key' attribute. + + Each object of `AbtractAttrDef` mus have defined 'key' attribute. + """ + def __call__(self, *args, **kwargs): + obj = super(AbstractAttrDefMeta, self).__call__(*args, **kwargs) + init_class = getattr(obj, "__init__class__", None) + if init_class is not AbtractAttrDef: + raise TypeError("{} super was not called in __init__.".format( + type(obj) + )) + return obj + + +@six.add_metaclass(AbstractAttrDefMeta) +class AbtractAttrDef: + """Abstraction of attribute definiton. + + Each attribute definition must have implemented validation and + conversion method. + + Attribute definition should have ability to return "default" value. That + can be based on passed data into `__init__` so is not abstracted to + attribute. + + QUESTION: + How to force to set `key` attribute? + + Args: + key(str): Under which key will be attribute value stored. + label(str): Attribute label. + tooltip(str): Attribute tooltip. + """ + + def __init__(self, key, default, label=None, tooltip=None): + self.key = key + self.label = label + self.tooltip = tooltip + self.default = default + self._id = uuid.uuid4() + + self.__init__class__ = AbtractAttrDef + + @property + def id(self): + return self._id + + def __eq__(self, other): + if not isinstance(other, self.__class__): + return False + return self.key == other.key + + @abstractmethod + def convert_value(self, value): + """Convert value to a valid one. + + Convert passed value to a valid type. Use default if value can't be + converted. + """ + pass + + +class UnknownDef(AbtractAttrDef): + """Definition is not known because definition is not available.""" + def __init__(self, key, default=None, **kwargs): + kwargs["default"] = default + super(UnknownDef, self).__init__(key, **kwargs) + + def convert_value(self, value): + return value + + +class NumberDef(AbtractAttrDef): + """Number definition. + + Number can have defined minimum/maximum value and decimal points. Value + is integer if decimals are 0. + + Args: + minimum(int, float): Minimum possible value. + maximum(int, float): Maximum possible value. + decimals(int): Maximum decimal points of value. + default(int, float): Default value for conversion. + """ + + def __init__( + self, key, minimum=None, maximum=None, decimals=None, default=None, + **kwargs + ): + minimum = 0 if minimum is None else minimum + maximum = 999999 if maximum is None else maximum + # Swap min/max when are passed in opposited order + if minimum > maximum: + maximum, minimum = minimum, maximum + + if default is None: + default = 0 + + elif not isinstance(default, (int, float)): + raise TypeError(( + "'default' argument must be 'int' or 'float', not '{}'" + ).format(type(default))) + + # Fix default value by mim/max values + if default < minimum: + default = minimum + + elif default > maximum: + default = maximum + + super(NumberDef, self).__init__(key, default=default, **kwargs) + + self.minimum = minimum + self.maximum = maximum + self.decimals = 0 if decimals is None else decimals + + def __eq__(self, other): + if not super(NumberDef, self).__eq__(other): + return False + + return ( + self.decimals == other.decimals + and self.maximum == other.maximum + and self.maximum == other.maximum + ) + + def convert_value(self, value): + if isinstance(value, six.string_types): + try: + value = float(value) + except Exception: + pass + + if not isinstance(value, (int, float)): + return self.default + + if self.decimals == 0: + return int(value) + return round(float(value), self.decimals) + + +class TextDef(AbtractAttrDef): + """Text definition. + + Text can have multiline option so endline characters are allowed regex + validation can be applied placeholder for UI purposes and default value. + + Regex validation is not part of attribute implemntentation. + + Args: + multiline(bool): Text has single or multiline support. + regex(str, re.Pattern): Regex validation. + placeholder(str): UI placeholder for attribute. + default(str, None): Default value. Empty string used when not defined. + """ + def __init__( + self, key, multiline=None, regex=None, placeholder=None, default=None, + **kwargs + ): + if default is None: + default = "" + + super(TextDef, self).__init__(key, default=default, **kwargs) + + if multiline is None: + multiline = False + + elif not isinstance(default, six.string_types): + raise TypeError(( + "'default' argument must be a {}, not '{}'" + ).format(six.string_types, type(default))) + + if isinstance(regex, six.string_types): + regex = re.compile(regex) + + self.multiline = multiline + self.placeholder = placeholder + self.regex = regex + + def __eq__(self, other): + if not super(TextDef, self).__eq__(other): + return False + + return ( + self.multiline == other.multiline + and self.regex == other.regex + ) + + def convert_value(self, value): + if isinstance(value, six.string_types): + return value + return self.default + + +class EnumDef(AbtractAttrDef): + """Enumeration of single item from items. + + Args: + items: Items definition that can be coverted to + `collections.OrderedDict`. Dictionary represent {value: label} + relation. + default: Default value. Must be one key(value) from passed items. + """ + + def __init__(self, key, items, default=None, **kwargs): + if not items: + raise ValueError(( + "Empty 'items' value. {} must have" + " defined values on initialization." + ).format(self.__class__.__name__)) + + items = collections.OrderedDict(items) + if default not in items: + for _key in items.keys(): + default = _key + break + + super(EnumDef, self).__init__(key, default=default, **kwargs) + + self.items = items + + def __eq__(self, other): + if not super(EnumDef, self).__eq__(other): + return False + + if set(self.items.keys()) != set(other.items.keys()): + return False + + for key, label in self.items.items(): + if other.items[key] != label: + return False + return True + + def convert_value(self, value): + if value in self.items: + return value + return self.default + + +class BoolDef(AbtractAttrDef): + """Boolean representation. + + Args: + default(bool): Default value. Set to `False` if not defined. + """ + + def __init__(self, key, default=None, **kwargs): + if default is None: + default = False + super(BoolDef, self).__init__(key, default=default, **kwargs) + + def convert_value(self, value): + if isinstance(value, bool): + return value + return self.default diff --git a/openpype/pipeline/publish/README.md b/openpype/pipeline/publish/README.md new file mode 100644 index 0000000000..870d29314d --- /dev/null +++ b/openpype/pipeline/publish/README.md @@ -0,0 +1,38 @@ +# Publish +OpenPype is using `pyblish` for publishing process which is a little bit extented and modified mainly for UI purposes. OpenPype's (new) publish UI does not allow to enable/disable instances or plugins that can be done during creation part. Also does support actions only for validators after validation exception. + +## Exceptions +OpenPype define few specific exceptions that should be used in publish plugins. + +### Validation exception +Validation plugins should raise `PublishValidationError` to show to an artist what's wrong and give him actions to fix it. The exception says that error happened in plugin can be fixed by artist himself (with or without action on plugin). Any other errors will stop publishing immediately. Exception `PublishValidationError` raised after validation order has same effect as any other exception. + +Exception `PublishValidationError` 3 arguments: +- **message** Which is not used in UI but for headless publishing. +- **title** Short description of error (2-5 words). Title is used for grouping of exceptions per plugin. +- **description** Detailed description of happened issue where markdown and html can be used. + + +### Known errors +When there is a known error that can't be fixed by user (e.g. can't connect to deadline service, etc.) `KnownPublishError` should be raise. The only difference is that it's message is shown in UI to artist otherwise a neutral message without context is shown. + +## Plugin extension +Publish plugins can be extended by additional logic when inherits from `OpenPypePyblishPluginMixin` which can be used as mixin (additional inheritance of class). + +```python +import pyblish.api +from openpype.pipeline import OpenPypePyblishPluginMixin + + +# Example context plugin +class MyExtendedPlugin( + pyblish.api.ContextPlugin, OpenPypePyblishPluginMixin +): + pass + +``` + +### Extensions +Currently only extension is ability to define attributes for instances during creation. Method `get_attribute_defs` returns attribute definitions for families defined in plugin's `families` attribute if it's instance plugin or for whole context if it's context plugin. To convert existing values (or to remove legacy values) can be implemented `convert_attribute_values`. Values of publish attributes from created instance are never removed automatically so implementing of this method is best way to remove legacy data or convert them to new data structure. + +Possible attribute definitions can be found in `openpype/pipeline/lib/attribute_definitions.py`. diff --git a/openpype/pipeline/publish/__init__.py b/openpype/pipeline/publish/__init__.py new file mode 100644 index 0000000000..ca958816fe --- /dev/null +++ b/openpype/pipeline/publish/__init__.py @@ -0,0 +1,20 @@ +from .publish_plugins import ( + PublishValidationError, + KnownPublishError, + OpenPypePyblishPluginMixin +) + +from .lib import ( + DiscoverResult, + publish_plugins_discover +) + + +__all__ = ( + "PublishValidationError", + "KnownPublishError", + "OpenPypePyblishPluginMixin", + + "DiscoverResult", + "publish_plugins_discover" +) diff --git a/openpype/pipeline/publish/lib.py b/openpype/pipeline/publish/lib.py new file mode 100644 index 0000000000..0fa712a301 --- /dev/null +++ b/openpype/pipeline/publish/lib.py @@ -0,0 +1,126 @@ +import os +import sys +import types + +import six +import pyblish.plugin + + +class DiscoverResult: + """Hold result of publish plugins discovery. + + Stores discovered plugins duplicated plugins and file paths which + crashed on execution of file. + """ + def __init__(self): + self.plugins = [] + self.crashed_file_paths = {} + self.duplicated_plugins = [] + + def __iter__(self): + for plugin in self.plugins: + yield plugin + + def __getitem__(self, item): + return self.plugins[item] + + def __setitem__(self, item, value): + self.plugins[item] = value + + +def publish_plugins_discover(paths=None): + """Find and return available pyblish plug-ins + + Overriden function from `pyblish` module to be able collect crashed files + and reason of their crash. + + Arguments: + paths (list, optional): Paths to discover plug-ins from. + If no paths are provided, all paths are searched. + + """ + + # The only difference with `pyblish.api.discover` + result = DiscoverResult() + + plugins = dict() + plugin_names = [] + + allow_duplicates = pyblish.plugin.ALLOW_DUPLICATES + log = pyblish.plugin.log + + # Include plug-ins from registered paths + if not paths: + paths = pyblish.plugin.plugin_paths() + + for path in paths: + path = os.path.normpath(path) + if not os.path.isdir(path): + continue + + for fname in os.listdir(path): + if fname.startswith("_"): + continue + + abspath = os.path.join(path, fname) + + if not os.path.isfile(abspath): + continue + + mod_name, mod_ext = os.path.splitext(fname) + + if not mod_ext == ".py": + continue + + module = types.ModuleType(mod_name) + module.__file__ = abspath + + try: + with open(abspath, "rb") as f: + six.exec_(f.read(), module.__dict__) + + # Store reference to original module, to avoid + # garbage collection from collecting it's global + # imports, such as `import os`. + sys.modules[abspath] = module + + except Exception as err: + result.crashed_file_paths[abspath] = sys.exc_info() + + log.debug("Skipped: \"%s\" (%s)", mod_name, err) + continue + + for plugin in pyblish.plugin.plugins_from_module(module): + if not allow_duplicates and plugin.__name__ in plugin_names: + result.duplicated_plugins.append(plugin) + log.debug("Duplicate plug-in found: %s", plugin) + continue + + plugin_names.append(plugin.__name__) + + plugin.__module__ = module.__file__ + key = "{0}.{1}".format(plugin.__module__, plugin.__name__) + plugins[key] = plugin + + # Include plug-ins from registration. + # Directly registered plug-ins take precedence. + for plugin in pyblish.plugin.registered_plugins(): + if not allow_duplicates and plugin.__name__ in plugin_names: + result.duplicated_plugins.append(plugin) + log.debug("Duplicate plug-in found: %s", plugin) + continue + + plugin_names.append(plugin.__name__) + + plugins[plugin.__name__] = plugin + + plugins = list(plugins.values()) + pyblish.plugin.sort(plugins) # In-place + + # In-place user-defined filter + for filter_ in pyblish.plugin._registered_plugin_filters: + filter_(plugins) + + result.plugins = plugins + + return result diff --git a/openpype/pipeline/publish/publish_plugins.py b/openpype/pipeline/publish/publish_plugins.py new file mode 100644 index 0000000000..b60b9f43a7 --- /dev/null +++ b/openpype/pipeline/publish/publish_plugins.py @@ -0,0 +1,86 @@ +class PublishValidationError(Exception): + """Validation error happened during publishing. + + This exception should be used when validation publishing failed. + + Has additional UI specific attributes that may be handy for artist. + + Args: + message(str): Message of error. Short explanation an issue. + title(str): Title showed in UI. All instances are grouped under + single title. + description(str): Detailed description of an error. It is possible + to use Markdown syntax. + """ + def __init__(self, message, title=None, description=None): + self.message = message + self.title = title or "< Missing title >" + self.description = description or message + super(PublishValidationError, self).__init__(message) + + +class KnownPublishError(Exception): + """Publishing crashed because of known error. + + Message will be shown in UI for artist. + """ + pass + + +class OpenPypePyblishPluginMixin: + # TODO + # executable_in_thread = False + # + # state_message = None + # state_percent = None + # _state_change_callbacks = [] + # + # def set_state(self, percent=None, message=None): + # """Inner callback of plugin that would help to show in UI state. + # + # Plugin have registered callbacks on state change which could trigger + # update message and percent in UI and repaint the change. + # + # This part must be optional and should not be used to display errors + # or for logging. + # + # Message should be short without details. + # + # Args: + # percent(int): Percent of processing in range <1-100>. + # message(str): Message which will be shown to user (if in UI). + # """ + # if percent is not None: + # self.state_percent = percent + # + # if message: + # self.state_message = message + # + # for callback in self._state_change_callbacks: + # callback(self) + + @classmethod + def get_attribute_defs(cls): + """Publish attribute definitions. + + Attributes available for all families in plugin's `families` attribute. + Returns: + list: Attribute definitions for plugin. + """ + return [] + + @classmethod + def convert_attribute_values(cls, attribute_values): + if cls.__name__ not in attribute_values: + return attribute_values + + plugin_values = attribute_values[cls.__name__] + + attr_defs = cls.get_attribute_defs() + for attr_def in attr_defs: + key = attr_def.key + if key in plugin_values: + plugin_values[key] = attr_def.convert_value( + plugin_values[key] + ) + return attribute_values diff --git a/openpype/plugins/publish/collect_anatomy_instance_data.py b/openpype/plugins/publish/collect_anatomy_instance_data.py index 4fd657167c..e0eb1618b5 100644 --- a/openpype/plugins/publish/collect_anatomy_instance_data.py +++ b/openpype/plugins/publish/collect_anatomy_instance_data.py @@ -38,6 +38,8 @@ class CollectAnatomyInstanceData(pyblish.api.ContextPlugin): order = pyblish.api.CollectorOrder + 0.49 label = "Collect Anatomy Instance data" + follow_workfile_version = False + def process(self, context): self.log.info("Collecting anatomy data for all instances.") @@ -213,7 +215,10 @@ class CollectAnatomyInstanceData(pyblish.api.ContextPlugin): context_asset_doc = context.data["assetEntity"] for instance in context: - version_number = instance.data.get("version") + if self.follow_workfile_version: + version_number = context.data('version') + else: + 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 diff --git a/openpype/plugins/publish/collect_from_create_context.py b/openpype/plugins/publish/collect_from_create_context.py new file mode 100644 index 0000000000..16e3f669c3 --- /dev/null +++ b/openpype/plugins/publish/collect_from_create_context.py @@ -0,0 +1,57 @@ +"""Create instances based on CreateContext. + +""" +import os +import pyblish.api +import avalon.api + + +class CollectFromCreateContext(pyblish.api.ContextPlugin): + """Collect instances and data from CreateContext from new publishing.""" + + label = "Collect From Create Context" + order = pyblish.api.CollectorOrder - 0.5 + + def process(self, context): + create_context = context.data.pop("create_context", None) + # Skip if create context is not available + if not create_context: + return + + for created_instance in create_context.instances: + instance_data = created_instance.data_to_store() + if instance_data["active"]: + self.create_instance(context, instance_data) + + # Update global data to context + context.data.update(create_context.context_data_to_store()) + + # Update context data + for key in ("AVALON_PROJECT", "AVALON_ASSET", "AVALON_TASK"): + value = create_context.dbcon.Session.get(key) + if value is not None: + avalon.api.Session[key] = value + os.environ[key] = value + + def create_instance(self, context, in_data): + subset = in_data["subset"] + # If instance data already contain families then use it + instance_families = in_data.get("families") or [] + + instance = context.create_instance(subset) + instance.data.update({ + "subset": subset, + "asset": in_data["asset"], + "task": in_data["task"], + "label": subset, + "name": subset, + "family": in_data["family"], + "families": instance_families + }) + for key, value in in_data.items(): + if key not in instance.data: + instance.data[key] = value + self.log.info("collected instance: {}".format(instance.data)) + self.log.info("parsing data: {}".format(in_data)) + + instance.data["representations"] = list() diff --git a/openpype/plugins/publish/extract_review.py b/openpype/plugins/publish/extract_review.py index 7284483f5f..264b362558 100644 --- a/openpype/plugins/publish/extract_review.py +++ b/openpype/plugins/publish/extract_review.py @@ -649,6 +649,8 @@ class ExtractReview(pyblish.api.InstancePlugin): AssertionError: if more then one collection is obtained. """ + start_frame = int(start_frame) + end_frame = int(end_frame) collections = clique.assemble(files)[0] assert len(collections) == 1, "Multiple collections found." col = collections[0] diff --git a/openpype/plugins/publish/integrate_new.py b/openpype/plugins/publish/integrate_new.py index 451ea1d80d..7ff7466a2a 100644 --- a/openpype/plugins/publish/integrate_new.py +++ b/openpype/plugins/publish/integrate_new.py @@ -99,7 +99,8 @@ class IntegrateAssetNew(pyblish.api.InstancePlugin): "camerarig", "redshiftproxy", "effect", - "xgen" + "xgen", + "hda" ] exclude_families = ["clip"] db_representation_context_keys = [ @@ -1028,29 +1029,8 @@ class IntegrateAssetNew(pyblish.api.InstancePlugin): """ local_site = 'studio' # default remote_site = None - sync_server_presets = None - - if (instance.context.data["system_settings"] - ["modules"] - ["sync_server"] - ["enabled"]): - sync_server_presets = (instance.context.data["project_settings"] - ["global"] - ["sync_server"]) - - local_site_id = openpype.api.get_local_site_id() - if sync_server_presets["enabled"]: - local_site = sync_server_presets["config"].\ - get("active_site", "studio").strip() - if local_site == 'local': - local_site = local_site_id - - remote_site = sync_server_presets["config"].get("remote_site") - if remote_site == local_site: - remote_site = None - - if remote_site == 'local': - remote_site = local_site_id + always_accesible = [] + sync_project_presets = None rec = { "_id": io.ObjectId(), @@ -1065,12 +1045,93 @@ class IntegrateAssetNew(pyblish.api.InstancePlugin): if sites: rec["sites"] = sites else: + system_sync_server_presets = ( + instance.context.data["system_settings"] + ["modules"] + ["sync_server"]) + log.debug("system_sett:: {}".format(system_sync_server_presets)) + + if system_sync_server_presets["enabled"]: + sync_project_presets = ( + instance.context.data["project_settings"] + ["global"] + ["sync_server"]) + + if sync_project_presets and sync_project_presets["enabled"]: + local_site, remote_site = self._get_sites(sync_project_presets) + + always_accesible = sync_project_presets["config"]. \ + get("always_accessible_on", []) + + already_attached_sites = {} meta = {"name": local_site, "created_dt": datetime.now()} rec["sites"] = [meta] + already_attached_sites[meta["name"]] = meta["created_dt"] - if remote_site: + if sync_project_presets and sync_project_presets["enabled"]: + # add remote meta = {"name": remote_site.strip()} rec["sites"].append(meta) + already_attached_sites[meta["name"]] = None + + # add skeleton for site where it should be always synced to + for always_on_site in always_accesible: + if always_on_site not in already_attached_sites.keys(): + meta = {"name": always_on_site.strip()} + rec["sites"].append(meta) + already_attached_sites[meta["name"]] = None + + # add alternative sites + rec = self._add_alternative_sites(system_sync_server_presets, + already_attached_sites, + rec) + + log.debug("final sites:: {}".format(rec["sites"])) + + return rec + + def _get_sites(self, sync_project_presets): + """Returns tuple (local_site, remote_site)""" + local_site_id = openpype.api.get_local_site_id() + local_site = sync_project_presets["config"]. \ + get("active_site", "studio").strip() + + if local_site == 'local': + local_site = local_site_id + + remote_site = sync_project_presets["config"].get("remote_site") + if remote_site == local_site: + remote_site = None + + if remote_site == 'local': + remote_site = local_site_id + + return local_site, remote_site + + def _add_alternative_sites(self, + system_sync_server_presets, + already_attached_sites, + rec): + """Loop through all configured sites and add alternatives. + + See SyncServerModule.handle_alternate_site + """ + conf_sites = system_sync_server_presets.get("sites", {}) + + for site_name, site_info in conf_sites.items(): + alt_sites = set(site_info.get("alternative_sites", [])) + already_attached_keys = list(already_attached_sites.keys()) + for added_site in already_attached_keys: + if added_site in alt_sites: + if site_name in already_attached_keys: + continue + meta = {"name": site_name} + real_created = already_attached_sites[added_site] + # alt site inherits state of 'created_dt' + if real_created: + meta["created_dt"] = real_created + rec["sites"].append(meta) + already_attached_sites[meta["name"]] = real_created return rec diff --git a/openpype/plugins/publish/validate_containers.py b/openpype/plugins/publish/validate_containers.py index 784221c3b6..ce91bd3396 100644 --- a/openpype/plugins/publish/validate_containers.py +++ b/openpype/plugins/publish/validate_containers.py @@ -9,9 +9,9 @@ class ShowInventory(pyblish.api.Action): on = "failed" def process(self, context, plugin): - from avalon.tools import sceneinventory + from openpype.tools.utils import host_tools - sceneinventory.show() + host_tools.show_scene_inventory() class ValidateContainers(pyblish.api.ContextPlugin): diff --git a/openpype/plugins/publish/validate_unique_names.py b/openpype/plugins/publish/validate_unique_names.py new file mode 100644 index 0000000000..459c90e6c1 --- /dev/null +++ b/openpype/plugins/publish/validate_unique_names.py @@ -0,0 +1,39 @@ +from maya import cmds + +import pyblish.api +import openpype.api +import openpype.hosts.maya.api.action + + +class ValidateUniqueNames(pyblish.api.Validator): + """transform names should be unique + + ie: using cmds.ls(someNodeName) should always return shortname + + """ + + order = openpype.api.ValidateContentsOrder + hosts = ["maya"] + families = ["model"] + label = "Unique transform name" + actions = [openpype.hosts.maya.api.action.SelectInvalidAction] + + @staticmethod + def get_invalid(instance): + """Returns the invalid transforms in the instance. + + Returns: + list: Non unique name transforms + + """ + + return [tr for tr in cmds.ls(instance, type="transform") + if '|' in tr] + + def process(self, instance): + """Process all the nodes in the instance "objectSet""" + + invalid = self.get_invalid(instance) + if invalid: + raise ValueError("Nodes found with none unique names. " + "values: {0}".format(invalid)) diff --git a/openpype/plugins/publish/validate_version.py b/openpype/plugins/publish/validate_version.py index 927e024476..e48ce6e3c3 100644 --- a/openpype/plugins/publish/validate_version.py +++ b/openpype/plugins/publish/validate_version.py @@ -21,8 +21,9 @@ class ValidateVersion(pyblish.api.InstancePlugin): if latest_version is not None: msg = ( - "Version `{0}` that you are trying to publish, already exists" - " in the database. Version in database: `{1}`. Please version " - "up your workfile to a higher version number than: `{1}`." - ).format(version, latest_version) + "Version `{0}` from instance `{1}` that you are trying to" + " publish, already exists in the database. Version in" + " database: `{2}`. Please version up your workfile to a higher" + " version number than: `{2}`." + ).format(version, instance.data["name"], latest_version) assert (int(version) > int(latest_version)), msg diff --git a/openpype/pype_commands.py b/openpype/pype_commands.py index 5288749e8b..f0ba9a997e 100644 --- a/openpype/pype_commands.py +++ b/openpype/pype_commands.py @@ -3,10 +3,19 @@ import os import sys import json -from datetime import datetime +import time from openpype.lib import PypeLogger from openpype.api import get_app_environments_for_context +from openpype.lib.plugin_tools import parse_json, get_batch_asset_task_info +from openpype.lib.remote_publish import ( + get_webpublish_conn, + start_webpublish_log, + publish_and_log, + fail_batch, + find_variant_key, + get_task_data +) class PypeCommands: @@ -33,6 +42,25 @@ class PypeCommands: user_role = "manager" settings.main(user_role) + @staticmethod + def add_modules(click_func): + """Modules/Addons can add their cli commands dynamically.""" + from openpype.modules import ModulesManager + + manager = ModulesManager() + log = PypeLogger.get_logger("AddModulesCLI") + for module in manager.modules: + try: + module.cli(click_func) + + except Exception: + log.warning( + "Failed to add cli command for module \"{}\"".format( + module.name + ) + ) + return click_func + @staticmethod def launch_eventservercli(*args): from openpype_modules.ftrack.ftrack_server.event_server_cli import ( @@ -52,7 +80,7 @@ class PypeCommands: standalonepublish.main() @staticmethod - def publish(paths, targets=None): + def publish(paths, targets=None, gui=False): """Start headless publishing. Publish use json from passed paths argument. @@ -61,20 +89,35 @@ class PypeCommands: paths (list): Paths to jsons. targets (string): What module should be targeted (to choose validator for example) + gui (bool): Show publish UI. Raises: RuntimeError: When there is no path to process. """ - if not any(paths): - raise RuntimeError("No publish paths specified") - + from openpype.modules import ModulesManager from openpype import install, uninstall from openpype.api import Logger + from openpype.tools.utils.host_tools import show_publish + from openpype.tools.utils.lib import qt_app_context # Register target and host import pyblish.api import pyblish.util + log = Logger.get_logger() + + install() + + manager = ModulesManager() + + publish_paths = manager.collect_plugin_paths()["publish"] + + for path in publish_paths: + pyblish.api.register_plugin_path(path) + + if not any(paths): + raise RuntimeError("No publish paths specified") + env = get_app_environments_for_context( os.environ["AVALON_PROJECT"], os.environ["AVALON_ASSET"], @@ -83,37 +126,131 @@ class PypeCommands: ) os.environ.update(env) - log = Logger.get_logger() - - install() - - pyblish.api.register_target("filesequence") pyblish.api.register_host("shell") if targets: for target in targets: + print(f"setting target: {target}") pyblish.api.register_target(target) + else: + pyblish.api.register_target("filesequence") os.environ["OPENPYPE_PUBLISH_DATA"] = os.pathsep.join(paths) log.info("Running publish ...") - # Error exit as soon as any error occurs. - error_format = "Failed {plugin.__name__}: {error} -- {error.traceback}" + plugins = pyblish.api.discover() + print("Using plugins:") + for plugin in plugins: + print(plugin) - for result in pyblish.util.publish_iter(): - if result["error"]: - log.error(error_format.format(**result)) - uninstall() - sys.exit(1) + if gui: + with qt_app_context(): + show_publish() + else: + # Error exit as soon as any error occurs. + error_format = ("Failed {plugin.__name__}: " + "{error} -- {error.traceback}") + + for result in pyblish.util.publish_iter(): + if result["error"]: + log.error(error_format.format(**result)) + # uninstall() + sys.exit(1) log.info("Publish finished.") - uninstall() @staticmethod - def remotepublish(project, batch_path, host, user, targets=None): + def remotepublishfromapp(project, batch_dir, host_name, + user, targets=None): + """Opens installed variant of 'host' and run remote publish there. + + Currently implemented and tested for Photoshop where customer + wants to process uploaded .psd file and publish collected layers + from there. + + Checks if no other batches are running (status =='in_progress). If + so, it sleeps for SLEEP (this is separate process), + waits for WAIT_FOR seconds altogether. + + Requires installed host application on the machine. + + Runs publish process as user would, in automatic fashion. + """ + import pyblish.api + from openpype.api import Logger + from openpype.lib import ApplicationManager + + log = Logger.get_logger() + + log.info("remotepublishphotoshop command") + + task_data = get_task_data(batch_dir) + + workfile_path = os.path.join(batch_dir, + task_data["task"], + task_data["files"][0]) + + print("workfile_path {}".format(workfile_path)) + + batch_id = task_data["batch"] + dbcon = get_webpublish_conn() + # safer to start logging here, launch might be broken altogether + _id = start_webpublish_log(dbcon, batch_id, user) + + batches_in_progress = list(dbcon.find({"status": "in_progress"})) + if len(batches_in_progress) > 1: + fail_batch(_id, batches_in_progress, dbcon) + print("Another batch running, probably stuck, ask admin for help") + + asset, task_name, _ = get_batch_asset_task_info(task_data["context"]) + + application_manager = ApplicationManager() + found_variant_key = find_variant_key(application_manager, host_name) + app_name = "{}/{}".format(host_name, found_variant_key) + + # must have for proper launch of app + env = get_app_environments_for_context( + project, + asset, + task_name, + app_name + ) + os.environ.update(env) + + os.environ["OPENPYPE_PUBLISH_DATA"] = batch_dir + # must pass identifier to update log lines for a batch + os.environ["BATCH_LOG_ID"] = str(_id) + os.environ["HEADLESS_PUBLISH"] = 'true' # to use in app lib + + pyblish.api.register_host(host_name) + if targets: + if isinstance(targets, str): + targets = [targets] + current_targets = os.environ.get("PYBLISH_TARGETS", "").split( + os.pathsep) + for target in targets: + current_targets.append(target) + + os.environ["PYBLISH_TARGETS"] = os.pathsep.join( + set(current_targets)) + + data = { + "last_workfile_path": workfile_path, + "start_last_workfile": True + } + + launched_app = application_manager.launch(app_name, **data) + + while launched_app.poll() is None: + time.sleep(0.5) + + @staticmethod + def remotepublish(project, batch_path, user, targets=None): """Start headless publishing. + Used to publish rendered assets, workfiles etc. + Publish use json from passed paths argument. Args: @@ -121,10 +258,9 @@ class PypeCommands: per call of remotepublish batch_path (str): Path batch folder. Contains subfolders with resources (workfile, another subfolder 'renders' etc.) - targets (string): What module should be targeted - (to choose validator for example) - host (string) user (string): email address for webpublisher + targets (list): Pyblish targets + (to choose validator for example) Raises: RuntimeError: When there is no path to process. @@ -132,22 +268,22 @@ class PypeCommands: if not batch_path: raise RuntimeError("No publish paths specified") - from openpype import install, uninstall - from openpype.api import Logger - from openpype.lib import OpenPypeMongoConnection - # Register target and host import pyblish.api import pyblish.util + import avalon.api + from openpype.hosts.webpublisher import api as webpublisher - log = Logger.get_logger() + log = PypeLogger.get_logger() log.info("remotepublish command") - install() + host_name = "webpublisher" + os.environ["OPENPYPE_PUBLISH_DATA"] = batch_path + os.environ["AVALON_PROJECT"] = project + os.environ["AVALON_APP"] = host_name - if host: - pyblish.api.register_host(host) + pyblish.api.register_host(host_name) if targets: if isinstance(targets, str): @@ -155,76 +291,17 @@ class PypeCommands: for target in targets: pyblish.api.register_target(target) - os.environ["OPENPYPE_PUBLISH_DATA"] = batch_path - os.environ["AVALON_PROJECT"] = project - os.environ["AVALON_APP"] = host - - import avalon.api - from openpype.hosts.webpublisher import api as webpublisher - avalon.api.install(webpublisher) log.info("Running publish ...") - # Error exit as soon as any error occurs. - error_format = "Failed {plugin.__name__}: {error} -- {error.traceback}" - - mongo_client = OpenPypeMongoConnection.get_mongo_client() - database_name = os.environ["OPENPYPE_DATABASE_NAME"] - dbcon = mongo_client[database_name]["webpublishes"] - _, batch_id = os.path.split(batch_path) - _id = dbcon.insert_one({ - "batch_id": batch_id, - "start_date": datetime.now(), - "user": user, - "status": "in_progress" - }).inserted_id + dbcon = get_webpublish_conn() + _id = start_webpublish_log(dbcon, batch_id, user) - log_lines = [] - for result in pyblish.util.publish_iter(): - for record in result["records"]: - log_lines.append("{}: {}".format( - result["plugin"].label, record.msg)) - - if result["error"]: - log.error(error_format.format(**result)) - uninstall() - log_lines.append(error_format.format(**result)) - dbcon.update_one( - {"_id": _id}, - {"$set": - { - "finish_date": datetime.now(), - "status": "error", - "log": os.linesep.join(log_lines) - - }} - ) - sys.exit(1) - else: - dbcon.update_one( - {"_id": _id}, - {"$set": - { - "progress": max(result["progress"], 0.95), - "log": os.linesep.join(log_lines) - }} - ) - - dbcon.update_one( - {"_id": _id}, - {"$set": - { - "finish_date": datetime.now(), - "status": "finished_ok", - "progress": 1, - "log": os.linesep.join(log_lines) - }} - ) + publish_and_log(dbcon, _id, log) log.info("Publish finished.") - uninstall() @staticmethod def extractenvironments(output_json_path, project, asset, task, app): @@ -248,6 +325,12 @@ class PypeCommands: project_manager.main() + @staticmethod + def contextselection(output_path, project_name, asset_name, strict): + from openpype.tools.context_dialog import main + + main(output_path, project_name, asset_name, strict) + def texture_copy(self, project, asset, path): pass @@ -284,3 +367,28 @@ class PypeCommands: cmd = "pytest {} {} {}".format(folder, mark_str, pyargs_str) print("Running {}".format(cmd)) subprocess.run(cmd) + + def syncserver(self, active_site): + """Start running sync_server in background.""" + import signal + os.environ["OPENPYPE_LOCAL_ID"] = active_site + + def signal_handler(sig, frame): + print("You pressed Ctrl+C. Process ended.") + sync_server_module.server_exit() + sys.exit(0) + + signal.signal(signal.SIGINT, signal_handler) + signal.signal(signal.SIGTERM, signal_handler) + + from openpype.modules import ModulesManager + + manager = ModulesManager() + sync_server_module = manager.modules_by_name["sync_server"] + + sync_server_module.server_init() + sync_server_module.server_start() + + import time + while True: + time.sleep(1.0) diff --git a/openpype/resources/__init__.py b/openpype/resources/__init__.py index c6886fea73..f463933525 100644 --- a/openpype/resources/__init__.py +++ b/openpype/resources/__init__.py @@ -50,3 +50,11 @@ def get_openpype_splash_filepath(staging=None): else: splash_file_name = "openpype_splash.png" return get_resource("icons", splash_file_name) + + +def pype_icon_filepath(staging=None): + return get_openpype_icon_filepath(staging) + + +def pype_splash_filepath(staging=None): + return get_openpype_splash_filepath(staging) diff --git a/openpype/resources/app_icons/flame.png b/openpype/resources/app_icons/flame.png new file mode 100644 index 0000000000..ba9b69e45f Binary files /dev/null and b/openpype/resources/app_icons/flame.png differ diff --git a/openpype/scripts/otio_burnin.py b/openpype/scripts/otio_burnin.py index 206abfc0b4..68f4728bc7 100644 --- a/openpype/scripts/otio_burnin.py +++ b/openpype/scripts/otio_burnin.py @@ -37,7 +37,7 @@ TIMECODE_KEY = "{timecode}" SOURCE_TIMECODE_KEY = "{source_timecode}" -def _streams(source): +def _get_ffprobe_data(source): """Reimplemented from otio burnins to be able use full path to ffprobe :param str source: source media file :rtype: [{}, ...] @@ -47,7 +47,7 @@ def _streams(source): out = proc.communicate()[0] if proc.returncode != 0: raise RuntimeError("Failed to run: %s" % command) - return json.loads(out)['streams'] + return json.loads(out) def get_fps(str_value): @@ -69,10 +69,10 @@ def get_fps(str_value): return str(fps) -def _prores_codec_args(ffprobe_data, source_ffmpeg_cmd): +def _prores_codec_args(stream_data, source_ffmpeg_cmd): output = [] - tags = ffprobe_data.get("tags") or {} + tags = stream_data.get("tags") or {} encoder = tags.get("encoder") or "" if encoder.endswith("prores_ks"): codec_name = "prores_ks" @@ -85,7 +85,7 @@ def _prores_codec_args(ffprobe_data, source_ffmpeg_cmd): output.extend(["-codec:v", codec_name]) - pix_fmt = ffprobe_data.get("pix_fmt") + pix_fmt = stream_data.get("pix_fmt") if pix_fmt: output.extend(["-pix_fmt", pix_fmt]) @@ -99,7 +99,7 @@ def _prores_codec_args(ffprobe_data, source_ffmpeg_cmd): "ap4h": "4444", "ap4x": "4444xq" } - codec_tag_str = ffprobe_data.get("codec_tag_string") + codec_tag_str = stream_data.get("codec_tag_string") if codec_tag_str: profile = codec_tag_to_profile_map.get(codec_tag_str) if profile: @@ -108,7 +108,7 @@ def _prores_codec_args(ffprobe_data, source_ffmpeg_cmd): return output -def _h264_codec_args(ffprobe_data, source_ffmpeg_cmd): +def _h264_codec_args(stream_data, source_ffmpeg_cmd): output = ["-codec:v", "h264"] # Use arguments from source if are available source arguments @@ -125,7 +125,7 @@ def _h264_codec_args(ffprobe_data, source_ffmpeg_cmd): if arg in copy_args: output.extend([arg, args[idx + 1]]) - pix_fmt = ffprobe_data.get("pix_fmt") + pix_fmt = stream_data.get("pix_fmt") if pix_fmt: output.extend(["-pix_fmt", pix_fmt]) @@ -135,11 +135,11 @@ def _h264_codec_args(ffprobe_data, source_ffmpeg_cmd): return output -def _dnxhd_codec_args(ffprobe_data, source_ffmpeg_cmd): +def _dnxhd_codec_args(stream_data, source_ffmpeg_cmd): output = ["-codec:v", "dnxhd"] # Use source profile (profiles in metadata are not usable in args directly) - profile = ffprobe_data.get("profile") or "" + profile = stream_data.get("profile") or "" # Lower profile and replace space with underscore cleaned_profile = profile.lower().replace(" ", "_") dnx_profiles = { @@ -153,7 +153,7 @@ def _dnxhd_codec_args(ffprobe_data, source_ffmpeg_cmd): if cleaned_profile in dnx_profiles: output.extend(["-profile:v", cleaned_profile]) - pix_fmt = ffprobe_data.get("pix_fmt") + pix_fmt = stream_data.get("pix_fmt") if pix_fmt: output.extend(["-pix_fmt", pix_fmt]) @@ -162,28 +162,29 @@ def _dnxhd_codec_args(ffprobe_data, source_ffmpeg_cmd): def get_codec_args(ffprobe_data, source_ffmpeg_cmd): - codec_name = ffprobe_data.get("codec_name") + stream_data = ffprobe_data["streams"][0] + codec_name = stream_data.get("codec_name") # Codec "prores" if codec_name == "prores": - return _prores_codec_args(ffprobe_data, source_ffmpeg_cmd) + return _prores_codec_args(stream_data, source_ffmpeg_cmd) # Codec "h264" if codec_name == "h264": - return _h264_codec_args(ffprobe_data, source_ffmpeg_cmd) + return _h264_codec_args(stream_data, source_ffmpeg_cmd) # Coded DNxHD if codec_name == "dnxhd": - return _dnxhd_codec_args(ffprobe_data, source_ffmpeg_cmd) + return _dnxhd_codec_args(stream_data, source_ffmpeg_cmd) output = [] if codec_name: output.extend(["-codec:v", codec_name]) - bit_rate = ffprobe_data.get("bit_rate") + bit_rate = stream_data.get("bit_rate") if bit_rate: output.extend(["-b:v", bit_rate]) - pix_fmt = ffprobe_data.get("pix_fmt") + pix_fmt = stream_data.get("pix_fmt") if pix_fmt: output.extend(["-pix_fmt", pix_fmt]) @@ -244,15 +245,16 @@ class ModifiedBurnins(ffmpeg_burnins.Burnins): } def __init__( - self, source, streams=None, options_init=None, first_frame=None + self, source, ffprobe_data=None, options_init=None, first_frame=None ): - if not streams: - streams = _streams(source) + if not ffprobe_data: + ffprobe_data = _get_ffprobe_data(source) + self.ffprobe_data = ffprobe_data self.first_frame = first_frame self.input_args = [] - super().__init__(source, streams) + super().__init__(source, ffprobe_data["streams"]) if options_init: self.options_init.update(options_init) @@ -492,8 +494,6 @@ def example(input_path, output_path): 'bg_opacity': 0.5, 'font_size': 52 } - # First frame in burnin - start_frame = 2000 # Options init sets burnin look burnin = ModifiedBurnins(input_path, options_init=options_init) # Static text @@ -564,11 +564,11 @@ def burnins_from_data( "shot": "sh0010" } """ - streams = None + ffprobe_data = None if full_input_path: - streams = _streams(full_input_path) + ffprobe_data = _get_ffprobe_data(full_input_path) - burnin = ModifiedBurnins(input_path, streams, options, first_frame) + burnin = ModifiedBurnins(input_path, ffprobe_data, options, first_frame) frame_start = data.get("frame_start") frame_end = data.get("frame_end") @@ -595,6 +595,14 @@ def burnins_from_data( if source_timecode is None: source_timecode = stream.get("tags", {}).get("timecode") + if source_timecode is None: + # Use "format" key from ffprobe data + # - this is used e.g. in mxf extension + input_format = burnin.ffprobe_data.get("format") or {} + source_timecode = input_format.get("timecode") + if source_timecode is None: + source_timecode = input_format.get("tags", {}).get("timecode") + if source_timecode is not None: data[SOURCE_TIMECODE_KEY[1:-1]] = SOURCE_TIMECODE_KEY @@ -684,8 +692,9 @@ def burnins_from_data( ffmpeg_args.append("-g 1") else: - ffprobe_data = burnin._streams[0] - ffmpeg_args.extend(get_codec_args(ffprobe_data, source_ffmpeg_cmd)) + ffmpeg_args.extend( + get_codec_args(burnin.ffprobe_data, source_ffmpeg_cmd) + ) # Use group one (same as `-intra` argument, which is deprecated) ffmpeg_args_str = " ".join(ffmpeg_args) diff --git a/openpype/settings/defaults/project_anatomy/imageio.json b/openpype/settings/defaults/project_anatomy/imageio.json index 38313a3d84..fc34ef6813 100644 --- a/openpype/settings/defaults/project_anatomy/imageio.json +++ b/openpype/settings/defaults/project_anatomy/imageio.json @@ -162,9 +162,7 @@ ] } ], - "customNodes": [ - - ] + "customNodes": [] }, "regexInputs": { "inputs": [ @@ -174,5 +172,16 @@ } ] } + }, + "maya": { + "colorManagementPreference": { + "configFilePath": { + "windows": [], + "darwin": [], + "linux": [] + }, + "renderSpace": "scene-linear Rec 709/sRGB", + "viewTransform": "sRGB gamma" + } } } \ No newline at end of file diff --git a/openpype/settings/defaults/project_settings/global.json b/openpype/settings/defaults/project_settings/global.json index 45c1a59d17..9622f85a8e 100644 --- a/openpype/settings/defaults/project_settings/global.json +++ b/openpype/settings/defaults/project_settings/global.json @@ -1,5 +1,8 @@ { "publish": { + "CollectAnatomyInstanceData": { + "follow_workfile_version": false + }, "ValidateEditorialAssetName": { "enabled": true, "optional": false @@ -315,10 +318,11 @@ }, "project_folder_structure": "{\"__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]\": {}}}}", "sync_server": { - "enabled": true, + "enabled": false, "config": { "retry_cnt": "3", "loop_delay": "60", + "always_accessible_on": [], "active_site": "studio", "remote_site": "studio" }, diff --git a/openpype/settings/defaults/project_settings/maya.json b/openpype/settings/defaults/project_settings/maya.json index f8f3432d0f..689d6418ba 100644 --- a/openpype/settings/defaults/project_settings/maya.json +++ b/openpype/settings/defaults/project_settings/maya.json @@ -8,16 +8,10 @@ "yetiRig": "ma" }, "maya-dirmap": { - "enabled": true, + "enabled": false, "paths": { - "source-path": [ - "foo1", - "foo2" - ], - "destination-path": [ - "bar1", - "bar2" - ] + "source-path": [], + "destination-path": [] } }, "scriptsmenu": { @@ -255,6 +249,11 @@ "optional": true, "active": true }, + "ValidateMeshNgons": { + "enabled": false, + "optional": true, + "active": true + }, "ValidateMeshNonManifold": { "enabled": false, "optional": true, @@ -310,11 +309,21 @@ "optional": true, "active": true }, + "ValidateShapeZero": { + "enabled": false, + "optional": true, + "active": true + }, "ValidateTransformZero": { "enabled": false, "optional": true, "active": true }, + "ValidateUniqueNames": { + "enabled": false, + "optional": true, + "active": true + }, "ValidateRigContents": { "enabled": false, "optional": true, diff --git a/openpype/settings/defaults/project_settings/nuke.json b/openpype/settings/defaults/project_settings/nuke.json index e3c7834e4a..069994d0e8 100644 --- a/openpype/settings/defaults/project_settings/nuke.json +++ b/openpype/settings/defaults/project_settings/nuke.json @@ -8,6 +8,13 @@ "build_workfile": "ctrl+alt+b" } }, + "nuke-dirmap": { + "enabled": false, + "paths": { + "source-path": [], + "destination-path": [] + } + }, "create": { "CreateWriteRender": { "fpath_template": "{work}/renders/nuke/{subset}/{subset}.{frame}.{ext}", @@ -130,8 +137,7 @@ }, "LoadClip": { "enabled": true, - "_representations": [ - ], + "_representations": [], "node_name_template": "{class_name}_{ext}" } }, diff --git a/openpype/settings/defaults/project_settings/photoshop.json b/openpype/settings/defaults/project_settings/photoshop.json index 36c30bad6c..0c24c943ec 100644 --- a/openpype/settings/defaults/project_settings/photoshop.json +++ b/openpype/settings/defaults/project_settings/photoshop.json @@ -12,6 +12,16 @@ "optional": true, "active": true }, + "CollectRemoteInstances": { + "color_code_mapping": [ + { + "color_code": [], + "layer_name_regex": [], + "family": "", + "subset_template_name": "" + } + ] + }, "ExtractImage": { "formats": [ "png", @@ -20,8 +30,7 @@ }, "ExtractReview": { "jpg_options": { - "tags": [ - ] + "tags": [] }, "mov_options": { "tags": [ diff --git a/openpype/settings/defaults/system_settings/applications.json b/openpype/settings/defaults/system_settings/applications.json index cfdeca4b87..cc80a94d3f 100644 --- a/openpype/settings/defaults/system_settings/applications.json +++ b/openpype/settings/defaults/system_settings/applications.json @@ -97,6 +97,42 @@ } } }, + "flame": { + "enabled": true, + "label": "Flame", + "icon": "{}/app_icons/flame.png", + "host_name": "flame", + "environment": { + "FLAME_SCRIPT_DIRS": { + "windows": "", + "darwin": "", + "linux": "" + } + }, + "variants": { + "2021": { + "use_python_2": true, + "executables": { + "windows": [], + "darwin": [ + "/opt/Autodesk/flame_2021/bin/flame.app/Contents/MacOS/startApp" + ], + "linux": [ + "/opt/Autodesk/flame_2021/bin/flame" + ] + }, + "arguments": { + "windows": [], + "darwin": [], + "linux": [] + }, + "environment": {} + }, + "__dynamic_keys_labels__": { + "2021": "2021 (Testing Only)" + } + } + }, "nuke": { "enabled": true, "label": "Nuke", @@ -620,12 +656,12 @@ "FUSION_UTILITY_SCRIPTS_SOURCE_DIR": [], "FUSION_UTILITY_SCRIPTS_DIR": { "windows": "{PROGRAMDATA}/Blackmagic Design/Fusion/Scripts/Comp", - "darvin": "/Library/Application Support/Blackmagic Design/Fusion/Scripts/Comp", + "darwin": "/Library/Application Support/Blackmagic Design/Fusion/Scripts/Comp", "linux": "/opt/Fusion/Scripts/Comp" }, "PYTHON36": { "windows": "{LOCALAPPDATA}/Programs/Python/Python36", - "darvin": "~/Library/Python/3.6/bin", + "darwin": "~/Library/Python/3.6/bin", "linux": "/opt/Python/3.6/bin" }, "PYTHONPATH": [ @@ -686,22 +722,22 @@ "RESOLVE_UTILITY_SCRIPTS_SOURCE_DIR": [], "RESOLVE_SCRIPT_API": { "windows": "{PROGRAMDATA}/Blackmagic Design/DaVinci Resolve/Support/Developer/Scripting", - "darvin": "/Library/Application Support/Blackmagic Design/DaVinci Resolve/Developer/Scripting", + "darwin": "/Library/Application Support/Blackmagic Design/DaVinci Resolve/Developer/Scripting", "linux": "/opt/resolve/Developer/Scripting" }, "RESOLVE_SCRIPT_LIB": { "windows": "C:/Program Files/Blackmagic Design/DaVinci Resolve/fusionscript.dll", - "darvin": "/Applications/DaVinci Resolve/DaVinci Resolve.app/Contents/Libraries/Fusion/fusionscript.so", + "darwin": "/Applications/DaVinci Resolve/DaVinci Resolve.app/Contents/Libraries/Fusion/fusionscript.so", "linux": "/opt/resolve/libs/Fusion/fusionscript.so" }, "RESOLVE_UTILITY_SCRIPTS_DIR": { "windows": "{PROGRAMDATA}/Blackmagic Design/DaVinci Resolve/Fusion/Scripts/Comp", - "darvin": "/Library/Application Support/Blackmagic Design/DaVinci Resolve/Fusion/Scripts/Comp", + "darwin": "/Library/Application Support/Blackmagic Design/DaVinci Resolve/Fusion/Scripts/Comp", "linux": "/opt/resolve/Fusion/Scripts/Comp" }, "PYTHON36_RESOLVE": { "windows": "{LOCALAPPDATA}/Programs/Python/Python36", - "darvin": "~/Library/Python/3.6/bin", + "darwin": "~/Library/Python/3.6/bin", "linux": "/opt/Python/3.6/bin" }, "PYTHONPATH": [ @@ -973,8 +1009,6 @@ }, "variants": { "2020": { - "enabled": true, - "variant_label": "2020", "executables": { "windows": [ "C:\\Program Files\\Adobe\\Adobe Photoshop 2020\\Photoshop.exe" @@ -990,8 +1024,6 @@ "environment": {} }, "2021": { - "enabled": true, - "variant_label": "2021", "executables": { "windows": [ "C:\\Program Files\\Adobe\\Adobe Photoshop 2021\\Photoshop.exe" @@ -1005,6 +1037,21 @@ "linux": [] }, "environment": {} + }, + "2022": { + "executables": { + "windows": [ + "C:\\Program Files\\Adobe\\Adobe Photoshop 2022\\Photoshop.exe" + ], + "darwin": [], + "linux": [] + }, + "arguments": { + "windows": [], + "darwin": [], + "linux": [] + }, + "environment": {} } } }, diff --git a/openpype/settings/defaults/system_settings/modules.json b/openpype/settings/defaults/system_settings/modules.json index beb1eb4f24..9c72598ff2 100644 --- a/openpype/settings/defaults/system_settings/modules.json +++ b/openpype/settings/defaults/system_settings/modules.json @@ -167,6 +167,16 @@ "ffmpeg": 48 } }, + "royalrender": { + "enabled": false, + "rr_paths": { + "default": { + "windows": "", + "darwin": "", + "linux": "" + } + } + }, "log_viewer": { "enabled": true }, diff --git a/openpype/settings/entities/__init__.py b/openpype/settings/entities/__init__.py index aae2d1fa89..ccf2a5993e 100644 --- a/openpype/settings/entities/__init__.py +++ b/openpype/settings/entities/__init__.py @@ -110,7 +110,11 @@ from .enum_entity import ( ) from .list_entity import ListEntity -from .dict_immutable_keys_entity import DictImmutableKeysEntity +from .dict_immutable_keys_entity import ( + DictImmutableKeysEntity, + RootsDictEntity, + SyncServerSites +) from .dict_mutable_keys_entity import DictMutableKeysEntity from .dict_conditional import ( DictConditionalEntity, @@ -169,6 +173,8 @@ __all__ = ( "ListEntity", "DictImmutableKeysEntity", + "RootsDictEntity", + "SyncServerSites", "DictMutableKeysEntity", diff --git a/openpype/settings/entities/base_entity.py b/openpype/settings/entities/base_entity.py index 0e8274d374..341968bd75 100644 --- a/openpype/settings/entities/base_entity.py +++ b/openpype/settings/entities/base_entity.py @@ -510,7 +510,7 @@ class BaseItemEntity(BaseEntity): pass @abstractmethod - def _item_initalization(self): + def _item_initialization(self): """Entity specific initialization process.""" pass @@ -920,7 +920,7 @@ class ItemEntity(BaseItemEntity): _default_label_wrap["collapsed"] ) - self._item_initalization() + self._item_initialization() def save(self): """Call save on root item.""" diff --git a/openpype/settings/entities/color_entity.py b/openpype/settings/entities/color_entity.py index dfaa75e761..3becf2d865 100644 --- a/openpype/settings/entities/color_entity.py +++ b/openpype/settings/entities/color_entity.py @@ -9,7 +9,7 @@ from .exceptions import ( class ColorEntity(InputEntity): schema_types = ["color"] - def _item_initalization(self): + def _item_initialization(self): self.valid_value_types = (list, ) self.value_on_not_set = [0, 0, 0, 255] self.use_alpha = self.schema_data.get("use_alpha", True) diff --git a/openpype/settings/entities/dict_conditional.py b/openpype/settings/entities/dict_conditional.py index 6f27760570..5f1c172f31 100644 --- a/openpype/settings/entities/dict_conditional.py +++ b/openpype/settings/entities/dict_conditional.py @@ -107,7 +107,7 @@ class DictConditionalEntity(ItemEntity): for _key, _value in new_value.items(): self.non_gui_children[self.current_enum][_key].set(_value) - def _item_initalization(self): + def _item_initialization(self): self._default_metadata = NOT_SET self._studio_override_metadata = NOT_SET self._project_override_metadata = NOT_SET @@ -762,6 +762,17 @@ class SyncServerProviders(DictConditionalEntity): enum_children = [] for provider_code, configurables in system_settings_schema.items(): + # any site could be exposed or vendorized by different site + # eg studio site content could be mapped on sftp site, single file + # accessible via 2 different protocols (sites) + configurables.append( + { + "type": "list", + "key": "alternative_sites", + "label": "Alternative sites", + "object_type": "text" + } + ) label = provider_code_to_label.get(provider_code) or provider_code enum_children.append({ diff --git a/openpype/settings/entities/dict_immutable_keys_entity.py b/openpype/settings/entities/dict_immutable_keys_entity.py index 57e21ff5f3..6131fa2ac7 100644 --- a/openpype/settings/entities/dict_immutable_keys_entity.py +++ b/openpype/settings/entities/dict_immutable_keys_entity.py @@ -4,7 +4,8 @@ import collections from .lib import ( WRAPPER_TYPES, OverrideState, - NOT_SET + NOT_SET, + STRING_TYPE ) from openpype.settings.constants import ( METADATA_KEYS, @@ -18,6 +19,7 @@ from . import ( GUIEntity ) from .exceptions import ( + DefaultsNotDefined, SchemaDuplicatedKeys, EntitySchemaError, InvalidKeySymbols @@ -172,7 +174,7 @@ class DictImmutableKeysEntity(ItemEntity): for child_obj in added_children: self.gui_layout.append(child_obj) - def _item_initalization(self): + def _item_initialization(self): self._default_metadata = NOT_SET self._studio_override_metadata = NOT_SET self._project_override_metadata = NOT_SET @@ -547,3 +549,373 @@ class DictImmutableKeysEntity(ItemEntity): super(DictImmutableKeysEntity, self).reset_callbacks() for child_entity in self.children: child_entity.reset_callbacks() + + +class RootsDictEntity(DictImmutableKeysEntity): + """Entity that adds ability to fill value for roots of current project. + + Value schema is defined by `object_type`. + + It is not possible to change override state (Studio values will always + contain studio overrides and same for project). That is because roots can + be totally different for each project. + """ + _origin_schema_data = None + schema_types = ["dict-roots"] + + def _item_initialization(self): + origin_schema_data = self.schema_data + + self.separate_items = origin_schema_data.get("separate_items", True) + object_type = origin_schema_data.get("object_type") + if isinstance(object_type, STRING_TYPE): + object_type = {"type": object_type} + self.object_type = object_type + + if self.group_item is None and not self.is_group: + self.is_group = True + + schema_data = copy.deepcopy(self.schema_data) + schema_data["children"] = [] + + self.schema_data = schema_data + self._origin_schema_data = origin_schema_data + + self._default_value = NOT_SET + self._studio_value = NOT_SET + self._project_value = NOT_SET + + super(RootsDictEntity, self)._item_initialization() + + def schema_validations(self): + if self.object_type is None: + reason = ( + "Missing children definitions for root values" + " ('object_type' not filled)." + ) + raise EntitySchemaError(self, reason) + + if not isinstance(self.object_type, dict): + reason = ( + "Children definitions for root values must be dictionary" + " ('object_type' is \"{}\")." + ).format(str(type(self.object_type))) + raise EntitySchemaError(self, reason) + + super(RootsDictEntity, self).schema_validations() + + def set_override_state(self, state, ignore_missing_defaults): + self.children = [] + self.non_gui_children = {} + self.gui_layout = [] + + roots_entity = self.get_entity_from_path( + "project_anatomy/roots" + ) + children = [] + first = True + for key in roots_entity.keys(): + if first: + first = False + elif self.separate_items: + children.append({"type": "separator"}) + child = copy.deepcopy(self.object_type) + child["key"] = key + child["label"] = key + children.append(child) + + schema_data = copy.deepcopy(self.schema_data) + schema_data["children"] = children + + self._add_children(schema_data) + + self._set_children_values(state, ignore_missing_defaults) + + super(RootsDictEntity, self).set_override_state( + state, True + ) + + if state == OverrideState.STUDIO: + self.add_to_studio_default() + + elif state == OverrideState.PROJECT: + self.add_to_project_override() + + def on_child_change(self, child_obj): + if self._override_state is OverrideState.STUDIO: + if not child_obj.has_studio_override: + self.add_to_studio_default() + + elif self._override_state is OverrideState.PROJECT: + if not child_obj.has_project_override: + self.add_to_project_override() + + return super(RootsDictEntity, self).on_child_change(child_obj) + + def _set_children_values(self, state, ignore_missing_defaults): + if state >= OverrideState.DEFAULTS: + default_value = self._default_value + if default_value is NOT_SET: + if ( + not ignore_missing_defaults + and state > OverrideState.DEFAULTS + ): + raise DefaultsNotDefined(self) + else: + default_value = {} + + for key, child_obj in self.non_gui_children.items(): + child_value = default_value.get(key, NOT_SET) + child_obj.update_default_value(child_value) + + if state >= OverrideState.STUDIO: + value = self._studio_value + if value is NOT_SET: + value = {} + + for key, child_obj in self.non_gui_children.items(): + child_value = value.get(key, NOT_SET) + child_obj.update_studio_value(child_value) + + if state >= OverrideState.PROJECT: + value = self._project_value + if value is NOT_SET: + value = {} + + for key, child_obj in self.non_gui_children.items(): + child_value = value.get(key, NOT_SET) + child_obj.update_project_value(child_value) + + def _update_current_metadata(self): + """Override this method as this entity should not have metadata.""" + self._metadata_are_modified = False + self._current_metadata = {} + + def update_default_value(self, value): + """Update default values. + + Not an api method, should be called by parent. + """ + value = self._check_update_value(value, "default") + value, _ = self._prepare_value(value) + + self._default_value = value + self._default_metadata = {} + self.has_default_value = value is not NOT_SET + + def update_studio_value(self, value): + """Update studio override values. + + Not an api method, should be called by parent. + """ + value = self._check_update_value(value, "studio override") + value, _ = self._prepare_value(value) + + self._studio_value = value + self._studio_override_metadata = {} + self.had_studio_override = value is not NOT_SET + + def update_project_value(self, value): + """Update project override values. + + Not an api method, should be called by parent. + """ + + value = self._check_update_value(value, "project override") + value, _metadata = self._prepare_value(value) + + self._project_value = value + self._project_override_metadata = {} + self.had_project_override = value is not NOT_SET + + +class SyncServerSites(DictImmutableKeysEntity): + """Dictionary enity for sync sites. + + Can be used only in project settings. + + Is loading sites from system settings. Uses site name as key and by site's + provider loads project settings schemas calling method + `get_project_settings_schema` on provider. + + Each provider have `enabled` boolean entity to be able know if site should + be enabled for the project. Enabled is by default set to False. + """ + schema_types = ["sync-server-sites"] + + def _item_initialization(self): + # Make sure this is a group + if self.group_item is None and not self.is_group: + self.is_group = True + + # Fake children for `dict` validations + self.schema_data["children"] = [] + # Site names changed or were removed + # - to find out that site names was removed so project values + # contain more data than should + self._sites_changed = False + + super(SyncServerSites, self)._item_initialization() + + def set_override_state(self, state, ignore_missing_defaults): + # Cleanup children related attributes + self.children = [] + self.non_gui_children = {} + self.gui_layout = [] + + # Create copy of schema + schema_data = copy.deepcopy(self.schema_data) + # Collect children + children = self._get_children() + schema_data["children"] = children + + self._add_children(schema_data) + + self._sites_changed = False + self._set_children_values(state, ignore_missing_defaults) + + super(SyncServerSites, self).set_override_state(state, True) + + @property + def has_unsaved_changes(self): + if self._sites_changed: + return True + return super(SyncServerSites, self).has_unsaved_changes + + @property + def has_studio_override(self): + if self._sites_changed: + return True + return super(SyncServerSites, self).has_studio_override + + @property + def has_project_override(self): + if self._sites_changed: + return True + return super(SyncServerSites, self).has_project_override + + def _get_children(self): + from openpype_modules import sync_server + + # Load system settings to find out all created sites + modules_entity = self.get_entity_from_path("system_settings/modules") + sync_server_settings_entity = modules_entity.get("sync_server") + + # Get project settings configurations for all providers + project_settings_schema = ( + sync_server + .SyncServerModule + .get_project_settings_schema() + ) + + children = [] + # Add 'enabled' for each site to be able know if should be used for + # the project + checkbox_child = { + "type": "boolean", + "key": "enabled", + "default": False + } + if sync_server_settings_entity is not None: + sites_entity = sync_server_settings_entity["sites"] + for site_name, provider_entity in sites_entity.items(): + provider_name = provider_entity["provider"].value + provider_children = copy.deepcopy( + project_settings_schema.get(provider_name) + ) or [] + provider_children.insert(0, copy.deepcopy(checkbox_child)) + children.append({ + "type": "dict", + "key": site_name, + "label": site_name, + "checkbox_key": "enabled", + "children": provider_children + }) + + return children + + def _set_children_values(self, state, ignore_missing_defaults): + current_site_names = set(self.non_gui_children.keys()) + + if state >= OverrideState.DEFAULTS: + default_value = self._default_value + if default_value is NOT_SET: + if ( + not ignore_missing_defaults + and state > OverrideState.DEFAULTS + ): + raise DefaultsNotDefined(self) + else: + default_value = {} + + for key, child_obj in self.non_gui_children.items(): + child_value = default_value.get(key, NOT_SET) + child_obj.update_default_value(child_value) + + if state >= OverrideState.STUDIO: + value = self._studio_value + if value is NOT_SET: + value = {} + + for key, child_obj in self.non_gui_children.items(): + child_value = value.get(key, NOT_SET) + child_obj.update_studio_value(child_value) + + if state is OverrideState.STUDIO: + value_keys = set(value.keys()) + self._sites_changed = value_keys != current_site_names + + if state >= OverrideState.PROJECT: + value = self._project_value + if value is NOT_SET: + value = {} + + for key, child_obj in self.non_gui_children.items(): + child_value = value.get(key, NOT_SET) + child_obj.update_project_value(child_value) + + if state is OverrideState.PROJECT: + value_keys = set(value.keys()) + self._sites_changed = value_keys != current_site_names + + def _update_current_metadata(self): + """Override this method as this entity should not have metadata.""" + self._metadata_are_modified = False + self._current_metadata = {} + + def update_default_value(self, value): + """Update default values. + + Not an api method, should be called by parent. + """ + value = self._check_update_value(value, "default") + value, _ = self._prepare_value(value) + + self._default_value = value + self._default_metadata = {} + self.has_default_value = value is not NOT_SET + + def update_studio_value(self, value): + """Update studio override values. + + Not an api method, should be called by parent. + """ + value = self._check_update_value(value, "studio override") + value, _ = self._prepare_value(value) + + self._studio_value = value + self._studio_override_metadata = {} + self.had_studio_override = value is not NOT_SET + + def update_project_value(self, value): + """Update project override values. + + Not an api method, should be called by parent. + """ + + value = self._check_update_value(value, "project override") + value, _metadata = self._prepare_value(value) + + self._project_value = value + self._project_override_metadata = {} + self.had_project_override = value is not NOT_SET diff --git a/openpype/settings/entities/dict_mutable_keys_entity.py b/openpype/settings/entities/dict_mutable_keys_entity.py index f75fb23d82..cff346e9ea 100644 --- a/openpype/settings/entities/dict_mutable_keys_entity.py +++ b/openpype/settings/entities/dict_mutable_keys_entity.py @@ -191,7 +191,7 @@ class DictMutableKeysEntity(EndpointEntity): child_entity = self.children_by_key[key] self.set_child_label(child_entity, label) - def _item_initalization(self): + def _item_initialization(self): self._default_metadata = {} self._studio_override_metadata = {} self._project_override_metadata = {} diff --git a/openpype/settings/entities/enum_entity.py b/openpype/settings/entities/enum_entity.py index a5e734f039..ab3cebbd42 100644 --- a/openpype/settings/entities/enum_entity.py +++ b/openpype/settings/entities/enum_entity.py @@ -8,7 +8,7 @@ from .lib import ( class BaseEnumEntity(InputEntity): - def _item_initalization(self): + def _item_initialization(self): self.multiselection = True self.value_on_not_set = None self.enum_items = None @@ -70,7 +70,7 @@ class BaseEnumEntity(InputEntity): class EnumEntity(BaseEnumEntity): schema_types = ["enum"] - def _item_initalization(self): + def _item_initialization(self): self.multiselection = self.schema_data.get("multiselection", False) self.enum_items = self.schema_data.get("enum_items") # Default is optional and non breaking attribute @@ -143,6 +143,7 @@ class HostsEnumEntity(BaseEnumEntity): "aftereffects", "blender", "celaction", + "flame", "fusion", "harmony", "hiero", @@ -156,7 +157,7 @@ class HostsEnumEntity(BaseEnumEntity): "standalonepublisher" ] - def _item_initalization(self): + def _item_initialization(self): self.multiselection = self.schema_data.get("multiselection", True) use_empty_value = False if not self.multiselection: @@ -249,7 +250,7 @@ class HostsEnumEntity(BaseEnumEntity): class AppsEnumEntity(BaseEnumEntity): schema_types = ["apps-enum"] - def _item_initalization(self): + def _item_initialization(self): self.multiselection = True self.value_on_not_set = [] self.enum_items = [] @@ -316,7 +317,7 @@ class AppsEnumEntity(BaseEnumEntity): class ToolsEnumEntity(BaseEnumEntity): schema_types = ["tools-enum"] - def _item_initalization(self): + def _item_initialization(self): self.multiselection = True self.value_on_not_set = [] self.enum_items = [] @@ -375,7 +376,7 @@ class ToolsEnumEntity(BaseEnumEntity): class TaskTypeEnumEntity(BaseEnumEntity): schema_types = ["task-types-enum"] - def _item_initalization(self): + def _item_initialization(self): self.multiselection = self.schema_data.get("multiselection", True) if self.multiselection: self.valid_value_types = (list, ) @@ -451,7 +452,7 @@ class TaskTypeEnumEntity(BaseEnumEntity): class DeadlineUrlEnumEntity(BaseEnumEntity): schema_types = ["deadline_url-enum"] - def _item_initalization(self): + def _item_initialization(self): self.multiselection = self.schema_data.get("multiselection", True) self.enum_items = [] @@ -502,7 +503,7 @@ class DeadlineUrlEnumEntity(BaseEnumEntity): class AnatomyTemplatesEnumEntity(BaseEnumEntity): schema_types = ["anatomy-templates-enum"] - def _item_initalization(self): + def _item_initialization(self): self.multiselection = False self.enum_items = [] diff --git a/openpype/settings/entities/input_entities.py b/openpype/settings/entities/input_entities.py index 0ded3ab7e5..a0598d405e 100644 --- a/openpype/settings/entities/input_entities.py +++ b/openpype/settings/entities/input_entities.py @@ -362,7 +362,7 @@ class NumberEntity(InputEntity): float_number_regex = re.compile(r"^\d+\.\d+$") int_number_regex = re.compile(r"^\d+$") - def _item_initalization(self): + def _item_initialization(self): self.minimum = self.schema_data.get("minimum", -99999) self.maximum = self.schema_data.get("maximum", 99999) self.decimal = self.schema_data.get("decimal", 0) @@ -420,7 +420,7 @@ class NumberEntity(InputEntity): class BoolEntity(InputEntity): schema_types = ["boolean"] - def _item_initalization(self): + def _item_initialization(self): self.valid_value_types = (bool, ) value_on_not_set = self.convert_to_valid_type( self.schema_data.get("default", True) @@ -431,7 +431,7 @@ class BoolEntity(InputEntity): class TextEntity(InputEntity): schema_types = ["text"] - def _item_initalization(self): + def _item_initialization(self): self.valid_value_types = (STRING_TYPE, ) self.value_on_not_set = "" @@ -449,7 +449,7 @@ class TextEntity(InputEntity): class PathInput(InputEntity): schema_types = ["path-input"] - def _item_initalization(self): + def _item_initialization(self): self.valid_value_types = (STRING_TYPE, ) self.value_on_not_set = "" @@ -460,7 +460,7 @@ class PathInput(InputEntity): class RawJsonEntity(InputEntity): schema_types = ["raw-json"] - def _item_initalization(self): + def _item_initialization(self): # Schema must define if valid value is dict or list store_as_string = self.schema_data.get("store_as_string", False) is_list = self.schema_data.get("is_list", False) diff --git a/openpype/settings/entities/item_entities.py b/openpype/settings/entities/item_entities.py index c7c9c3097e..ff0a982900 100644 --- a/openpype/settings/entities/item_entities.py +++ b/openpype/settings/entities/item_entities.py @@ -48,7 +48,7 @@ class PathEntity(ItemEntity): raise AttributeError(self.attribute_error_msg.format("items")) return self.child_obj.items() - def _item_initalization(self): + def _item_initialization(self): if self.group_item is None and not self.is_group: self.is_group = True @@ -216,7 +216,7 @@ class ListStrictEntity(ItemEntity): return self.children[idx] return default - def _item_initalization(self): + def _item_initialization(self): self.valid_value_types = (list, ) self.require_key = True diff --git a/openpype/settings/entities/list_entity.py b/openpype/settings/entities/list_entity.py index b06f4d7a2e..5d89a81351 100644 --- a/openpype/settings/entities/list_entity.py +++ b/openpype/settings/entities/list_entity.py @@ -149,7 +149,7 @@ class ListEntity(EndpointEntity): return list(value) return NOT_SET - def _item_initalization(self): + def _item_initialization(self): self.valid_value_types = (list, ) self.children = [] self.value_on_not_set = [] diff --git a/openpype/settings/entities/root_entities.py b/openpype/settings/entities/root_entities.py index 05d20ee60b..b8baed8a93 100644 --- a/openpype/settings/entities/root_entities.py +++ b/openpype/settings/entities/root_entities.py @@ -65,7 +65,7 @@ class RootEntity(BaseItemEntity): super(RootEntity, self).__init__(schema_data) self._require_restart_callbacks = [] self._item_ids_require_restart = set() - self._item_initalization() + self._item_initialization() if reset: self.reset() @@ -176,7 +176,7 @@ class RootEntity(BaseItemEntity): for child_obj in added_children: self.gui_layout.append(child_obj) - def _item_initalization(self): + def _item_initialization(self): # Store `self` to `root_item` for children entities self.root_item = self diff --git a/openpype/settings/entities/schemas/README.md b/openpype/settings/entities/schemas/README.md index 5258fef9ec..4e8dcc36ce 100644 --- a/openpype/settings/entities/schemas/README.md +++ b/openpype/settings/entities/schemas/README.md @@ -208,6 +208,25 @@ } ``` +## dict-roots +- entity can be used only in Project settings +- keys of dictionary are based on current project roots +- they are not updated "live" it is required to save root changes and then + modify values on this entity + # TODO do live updates +``` +{ + "type": "dict-roots", + "key": "roots", + "label": "Roots", + "object_type": { + "type": "path", + "multiplatform": true, + "multipath": false + } +} +``` + ## dict-conditional - is similar to `dict` but has always available one enum entity - the enum entity has single selection and it's value define other children entities diff --git a/openpype/settings/entities/schemas/projects_schema/schema_project_nuke.json b/openpype/settings/entities/schemas/projects_schema/schema_project_nuke.json index e0b21f4037..22cb8a4ea3 100644 --- a/openpype/settings/entities/schemas/projects_schema/schema_project_nuke.json +++ b/openpype/settings/entities/schemas/projects_schema/schema_project_nuke.json @@ -46,6 +46,39 @@ } ] }, + { + "type": "dict", + "collapsible": true, + "checkbox_key": "enabled", + "key": "nuke-dirmap", + "label": "Nuke Directory Mapping", + "is_group": true, + "children": [ + { + "type": "boolean", + "key": "enabled", + "label": "Enabled" + }, + { + "type": "dict", + "key": "paths", + "children": [ + { + "type": "list", + "object_type": "text", + "key": "source-path", + "label": "Source Path" + }, + { + "type": "list", + "object_type": "text", + "key": "destination-path", + "label": "Destination Path" + } + ] + } + ] + }, { "type": "dict", "collapsible": true, diff --git a/openpype/settings/entities/schemas/projects_schema/schema_project_photoshop.json b/openpype/settings/entities/schemas/projects_schema/schema_project_photoshop.json index 6f5577650c..ca388de60c 100644 --- a/openpype/settings/entities/schemas/projects_schema/schema_project_photoshop.json +++ b/openpype/settings/entities/schemas/projects_schema/schema_project_photoshop.json @@ -43,6 +43,71 @@ } ] }, + { + "type": "dict", + "collapsible": true, + "is_group": true, + "key": "CollectRemoteInstances", + "label": "Collect Instances for Webpublish", + "children": [ + { + "type": "label", + "label": "Set color for publishable layers, set publishable families." + }, + { + "type": "list", + "key": "color_code_mapping", + "label": "Color code mappings", + "use_label_wrap": false, + "collapsible": false, + "object_type": { + "type": "dict", + "children": [ + { + "type": "list", + "key": "color_code", + "label": "Color codes for layers", + "type": "enum", + "multiselection": true, + "enum_items": [ + { "red": "red" }, + { "orange": "orange" }, + { "yellowColor": "yellow" }, + { "grain": "green" }, + { "blue": "blue" }, + { "violet": "violet" }, + { "gray": "gray" } + ] + }, + { + "type": "list", + "key": "layer_name_regex", + "label": "Layer name regex", + "object_type": "text" + }, + { + "type": "splitter" + }, + { + "key": "family", + "label": "Resulting family", + "type": "enum", + "enum_items": [ + { + "image": "image" + } + ] + }, + { + "type": "text", + "key": "subset_template_name", + "label": "Subset template name" + } + ] + } + } + ] + }, { "type": "dict", "collapsible": true, diff --git a/openpype/settings/entities/schemas/projects_schema/schema_project_syncserver.json b/openpype/settings/entities/schemas/projects_schema/schema_project_syncserver.json index 3211babd43..7e1b0114f5 100644 --- a/openpype/settings/entities/schemas/projects_schema/schema_project_syncserver.json +++ b/openpype/settings/entities/schemas/projects_schema/schema_project_syncserver.json @@ -26,113 +26,33 @@ "key": "loop_delay", "label": "Loop Delay" }, + { + "type": "list", + "key": "always_accessible_on", + "label": "Always accessible on sites", + "object_type": "text" + }, + { + "type": "splitter" + }, { "type": "text", "key": "active_site", - "label": "Active Site" + "label": "User Default Active Site" }, { "type": "text", "key": "remote_site", - "label": "Remote Site" + "label": "User Default Remote Site" } ] }, { - "type": "dict-modifiable", + "type": "sync-server-sites", "collapsible": true, "key": "sites", "label": "Sites", - "collapsible_key": false, - "object_type": { - "type": "dict", - "children": [ - { - "type": "dict", - "key": "gdrive", - "label": "Google Drive", - "collapsible": true, - "children": [ - { - "type": "path", - "key": "credentials_url", - "label": "Credentials url", - "multiplatform": true - } - ] - }, - { - "type": "dict", - "key": "dropbox", - "label": "Dropbox", - "collapsible": true, - "children": [ - { - "type": "text", - "key": "token", - "label": "Access Token" - }, - { - "type": "text", - "key": "team_folder_name", - "label": "Team Folder Name" - }, - { - "type": "text", - "key": "acting_as_member", - "label": "Acting As Member" - } - ] - }, - { - "type": "dict", - "key": "sftp", - "label": "SFTP", - "collapsible": true, - "children": [ - { - "type": "text", - "key": "sftp_host", - "label": "SFTP host" - }, - { - "type": "number", - "key": "sftp_port", - "label": "SFTP port" - }, - { - "type": "text", - "key": "sftp_user", - "label": "SFTP user" - }, - { - "type": "text", - "key": "sftp_pass", - "label": "SFTP pass" - }, - { - "type": "path", - "key": "sftp_key", - "label": "SFTP user ssh key", - "multiplatform": true - }, - { - "type": "text", - "key": "sftp_key_pass", - "label": "SFTP user ssh key password" - } - ] - }, - { - "type": "dict-modifiable", - "key": "root", - "label": "Roots", - "collapsable": false, - "collapsable_key": false, - "object_type": "text" - } - ] - } + "collapsible_key": false } ] } diff --git a/openpype/settings/entities/schemas/projects_schema/schemas/schema_anatomy_imageio.json b/openpype/settings/entities/schemas/projects_schema/schemas/schema_anatomy_imageio.json index 3c589f9492..7423d6fd3e 100644 --- a/openpype/settings/entities/schemas/projects_schema/schemas/schema_anatomy_imageio.json +++ b/openpype/settings/entities/schemas/projects_schema/schemas/schema_anatomy_imageio.json @@ -358,6 +358,38 @@ ] } ] + }, + { + "key": "maya", + "type": "dict", + "label": "Maya", + "children": [ + { + "key": "colorManagementPreference", + "type": "dict", + "label": "Color Managment Preference", + "collapsible": false, + "children": [ + { + "type": "path", + "key": "configFilePath", + "label": "OCIO Config File Path", + "multiplatform": true, + "multipath": true + }, + { + "type": "text", + "key": "renderSpace", + "label": "Rendering Space" + }, + { + "type": "text", + "key": "viewTransform", + "label": "Viewer Transform" + } + ] + } + ] } ] } diff --git a/openpype/settings/entities/schemas/projects_schema/schemas/schema_global_publish.json b/openpype/settings/entities/schemas/projects_schema/schemas/schema_global_publish.json index c50f383f02..375f0c26da 100644 --- a/openpype/settings/entities/schemas/projects_schema/schemas/schema_global_publish.json +++ b/openpype/settings/entities/schemas/projects_schema/schemas/schema_global_publish.json @@ -4,6 +4,20 @@ "key": "publish", "label": "Publish plugins", "children": [ + { + "type": "dict", + "collapsible": true, + "key": "CollectAnatomyInstanceData", + "label": "Collect Anatomy Instance Data", + "is_group": true, + "children": [ + { + "type": "boolean", + "key": "follow_workfile_version", + "label": "Follow workfile version" + } + ] + }, { "type": "dict", "collapsible": true, diff --git a/openpype/settings/entities/schemas/projects_schema/schemas/schema_maya_publish.json b/openpype/settings/entities/schemas/projects_schema/schemas/schema_maya_publish.json index cbacd12efa..9fd19d7be2 100644 --- a/openpype/settings/entities/schemas/projects_schema/schemas/schema_maya_publish.json +++ b/openpype/settings/entities/schemas/projects_schema/schemas/schema_maya_publish.json @@ -274,6 +274,10 @@ "key": "ValidateMeshLaminaFaces", "label": "ValidateMeshLaminaFaces" }, + { + "key": "ValidateMeshNgons", + "label": "ValidateMeshNgons" + }, { "key": "ValidateMeshNonManifold", "label": "ValidateMeshNonManifold" @@ -320,9 +324,17 @@ "key": "ValidateShapeRenderStats", "label": "ValidateShapeRenderStats" }, + { + "key": "ValidateShapeZero", + "label": "ValidateShapeZero" + }, { "key": "ValidateTransformZero", "label": "ValidateTransformZero" + }, + { + "key": "ValidateUniqueNames", + "label": "ValidateUniqueNames" } ] } diff --git a/openpype/settings/entities/schemas/system_schema/host_settings/schema_flame.json b/openpype/settings/entities/schemas/system_schema/host_settings/schema_flame.json new file mode 100644 index 0000000000..1a9d8d4716 --- /dev/null +++ b/openpype/settings/entities/schemas/system_schema/host_settings/schema_flame.json @@ -0,0 +1,39 @@ +{ + "type": "dict", + "key": "flame", + "label": "Autodesk Flame", + "collapsible": true, + "checkbox_key": "enabled", + "children": [ + { + "type": "boolean", + "key": "enabled", + "label": "Enabled" + }, + { + "type": "schema_template", + "name": "template_host_unchangables" + }, + { + "key": "environment", + "label": "Environment", + "type": "raw-json" + }, + { + "type": "dict-modifiable", + "key": "variants", + "collapsible_key": true, + "use_label_wrap": false, + "object_type": { + "type": "dict", + "collapsible": true, + "children": [ + { + "type": "schema_template", + "name": "template_host_variant_items" + } + ] + } + } + ] +} diff --git a/openpype/settings/entities/schemas/system_schema/host_settings/schema_photoshop.json b/openpype/settings/entities/schemas/system_schema/host_settings/schema_photoshop.json index 7bcd89c650..0687b9699b 100644 --- a/openpype/settings/entities/schemas/system_schema/host_settings/schema_photoshop.json +++ b/openpype/settings/entities/schemas/system_schema/host_settings/schema_photoshop.json @@ -20,26 +20,21 @@ "type": "raw-json" }, { - "type": "dict", + "type": "dict-modifiable", "key": "variants", - "children": [ - { - "type": "schema_template", - "name": "template_host_variant", - "template_data": [ - { - "app_variant_label": "2020", - "app_variant": "2020", - "variant_skip_paths": ["use_python_2"] - }, - { - "app_variant_label": "2021", - "app_variant": "2021", - "variant_skip_paths": ["use_python_2"] - } - ] - } - ] + "collapsible_key": true, + "use_label_wrap": false, + "object_type": { + "type": "dict", + "collapsible": true, + "children": [ + { + "type": "schema_template", + "name": "template_host_variant_items", + "skip_paths": ["use_python_2"] + } + ] + } } ] } diff --git a/openpype/settings/entities/schemas/system_schema/schema_applications.json b/openpype/settings/entities/schemas/system_schema/schema_applications.json index efdd021ede..1767250aae 100644 --- a/openpype/settings/entities/schemas/system_schema/schema_applications.json +++ b/openpype/settings/entities/schemas/system_schema/schema_applications.json @@ -9,6 +9,10 @@ "type": "schema", "name": "schema_maya" }, + { + "type": "schema", + "name": "schema_flame" + }, { "type": "schema_template", "name": "template_nuke", diff --git a/openpype/settings/entities/schemas/system_schema/schema_modules.json b/openpype/settings/entities/schemas/system_schema/schema_modules.json index a2b31772e9..aab1eea750 100644 --- a/openpype/settings/entities/schemas/system_schema/schema_modules.json +++ b/openpype/settings/entities/schemas/system_schema/schema_modules.json @@ -180,6 +180,31 @@ } ] }, + { + "type": "dict", + "key": "royalrender", + "label": "Royal Render", + "require_restart": true, + "collapsible": true, + "checkbox_key": "enabled", + "children": [ + { + "type": "boolean", + "key": "enabled", + "label": "Enabled" + }, + { + "type": "dict-modifiable", + "object_type": { + "type": "path", + "multiplatform": true + }, + "key": "rr_paths", + "required_keys": ["default"], + "label": "Royal Render Root Paths" + } + ] + }, { "type": "dict", "key": "log_viewer", diff --git a/openpype/style/__init__.py b/openpype/style/__init__.py index 0d7904d133..fd39e93b5d 100644 --- a/openpype/style/__init__.py +++ b/openpype/style/__init__.py @@ -2,6 +2,8 @@ import os import json import collections from openpype import resources +import six +from .color_defs import parse_color _STYLESHEET_CACHE = None @@ -10,7 +12,71 @@ _FONT_IDS = None current_dir = os.path.dirname(os.path.abspath(__file__)) +def _get_colors_raw_data(): + """Read data file with stylesheet fill values. + + Returns: + dict: Loaded data for stylesheet. + """ + data_path = os.path.join(current_dir, "data.json") + with open(data_path, "r") as data_stream: + data = json.load(data_stream) + return data + + +def get_colors_data(): + """Only color data from stylesheet data.""" + data = _get_colors_raw_data() + return data.get("color") or {} + + +def _convert_color_values_to_objects(value): + """Parse all string values in dictionary to Color definitions. + + Recursive function calling itself if value is dictionary. + + Args: + value (dict, str): String is parsed into color definition object and + dictionary is passed into this function. + + Raises: + TypeError: If value in color data do not contain string of dictionary. + """ + if isinstance(value, dict): + output = {} + for _key, _value in value.items(): + output[_key] = _convert_color_values_to_objects(_value) + return output + + if not isinstance(value, six.string_types): + raise TypeError(( + "Unexpected type in colors data '{}'. Expected 'str' or 'dict'." + ).format(str(type(value)))) + return parse_color(value) + + +def get_objected_colors(): + """Colors parsed from stylesheet data into color definitions. + + Returns: + dict: Parsed color objects by keys in data. + """ + colors_data = get_colors_data() + output = {} + for key, value in colors_data.items(): + output[key] = _convert_color_values_to_objects(value) + return output + + def _load_stylesheet(): + """Load strylesheet and trigger all related callbacks. + + Style require more than a stylesheet string. Stylesheet string + contains paths to resources which must be registered into Qt application + and load fonts used in stylesheets. + + Also replace values from stylesheet data into stylesheet text. + """ from . import qrc_resources qrc_resources.qInitResources() @@ -19,9 +85,7 @@ def _load_stylesheet(): with open(style_path, "r") as style_file: stylesheet = style_file.read() - data_path = os.path.join(current_dir, "data.json") - with open(data_path, "r") as data_stream: - data = json.load(data_stream) + data = _get_colors_raw_data() data_deque = collections.deque() for item in data.items(): @@ -44,6 +108,7 @@ def _load_stylesheet(): def _load_font(): + """Load and register fonts into Qt application.""" from Qt import QtGui global _FONT_IDS @@ -83,6 +148,7 @@ def _load_font(): def load_stylesheet(): + """Load and return OpenPype Qt stylesheet.""" global _STYLESHEET_CACHE if _STYLESHEET_CACHE is None: _STYLESHEET_CACHE = _load_stylesheet() @@ -91,4 +157,5 @@ def load_stylesheet(): def app_icon_path(): + """Path to OpenPype icon.""" return resources.get_openpype_icon_filepath() diff --git a/openpype/style/color_defs.py b/openpype/style/color_defs.py new file mode 100644 index 0000000000..0f4e145ca0 --- /dev/null +++ b/openpype/style/color_defs.py @@ -0,0 +1,391 @@ +"""Color definitions that can be used to parse strings for stylesheet. + +Each definition must have available method `get_qcolor` which should return +`QtGui.QColor` representation of the color. + +# TODO create abstract class to force this method implementation + +Usage: Some colors may be not be used only in stylesheet but is required to +use them in code too. To not hardcode these color values into code it is better +to use same colors that are available fro stylesheets. + +It is possible that some colors may not be used in stylesheet at all and thei +definition is used only in code. +""" + +import re + + +def parse_color(value): + """Parse string value of color to one of objected representation. + + Args: + value(str): Color definition usable in stylesheet. + """ + modified_value = value.strip().lower() + if modified_value.startswith("hsla"): + return HSLAColor(value) + + if modified_value.startswith("hsl"): + return HSLColor(value) + + if modified_value.startswith("#"): + return HEXColor(value) + + if modified_value.startswith("rgba"): + return RGBAColor(value) + + if modified_value.startswith("rgb"): + return RGBColor(value) + return UnknownColor(value) + + +def create_qcolor(*args): + """Create QtGui.QColor object. + + Args: + *args (tuple): It is possible to pass initialization arguments for + Qcolor. + """ + from Qt import QtGui + + return QtGui.QColor(*args) + + +def min_max_check(value, min_value, max_value): + """Validate number value if is in passed range. + + Args: + value (int, float): Value which is validated. + min_value (int, float): Minimum possible value. Validation is skipped + if passed value is None. + max_value (int, float): Maximum possible value. Validation is skipped + if passed value is None. + + Raises: + ValueError: When 'value' is out of specified range. + """ + if min_value is not None and value < min_value: + raise ValueError("Minimum expected value is '{}' got '{}'".format( + min_value, value + )) + + if max_value is not None and value > max_value: + raise ValueError("Maximum expected value is '{}' got '{}'".format( + min_value, value + )) + + +def int_validation(value, min_value=None, max_value=None): + """Validation of integer value within range. + + Args: + value (int): Validated value. + min_value (int): Minimum possible value. + max_value (int): Maximum possible value. + + Raises: + TypeError: If 'value' is not 'int' type. + """ + if not isinstance(value, int): + raise TypeError(( + "Invalid type of hue expected 'int' got {}" + ).format(str(type(value)))) + + min_max_check(value, min_value, max_value) + + +def float_validation(value, min_value=None, max_value=None): + """Validation of float value within range. + + Args: + value (float): Validated value. + min_value (float): Minimum possible value. + max_value (float): Maximum possible value. + + Raises: + TypeError: If 'value' is not 'float' type. + """ + if not isinstance(value, float): + raise TypeError(( + "Invalid type of hue expected 'int' got {}" + ).format(str(type(value)))) + + min_max_check(value, min_value, max_value) + + +class UnknownColor: + """Color from stylesheet data without known color definition. + + This is backup for unknown color definitions which may be for example + constants or definition not yet defined by class. + """ + def __init__(self, value): + self.value = value + + def get_qcolor(self): + return create_qcolor(self.value) + + +class HEXColor: + """Hex color definition. + + Hex color is defined by '#' and 3 or 6 hex values (0-F). + + Examples: + "#fff" + "#f3f3f3" + """ + regex = re.compile(r"[a-fA-F0-9]{3}(?:[a-fA-F0-9]{3})?$") + + def __init__(self, color_string): + red, green, blue = self.hex_to_rgb(color_string) + + self._color_string = color_string + self._red = red + self._green = green + self._blue = blue + + @property + def red(self): + return self._red + + @property + def green(self): + return self._green + + @property + def blue(self): + return self._blue + + def to_stylesheet_str(self): + return self._color_string + + @classmethod + def hex_to_rgb(cls, value): + """Convert hex value to rgb.""" + hex_value = value.lstrip("#") + if not cls.regex.match(hex_value): + raise ValueError("\"{}\" is not a valid HEX code.".format(value)) + + output = [] + if len(hex_value) == 3: + for char in hex_value: + output.append(int(char * 2, 16)) + else: + for idx in range(3): + start_idx = idx * 2 + output.append(int(hex_value[start_idx:start_idx + 2], 16)) + return output + + def get_qcolor(self): + return create_qcolor(self.red, self.green, self.blue) + + +class RGBColor: + """Color defined by red green and blue values. + + Each color has possible integer range 0-255. + + Examples: + "rgb(255, 127, 0)" + """ + def __init__(self, value): + modified_color = value.lower().strip() + content = modified_color.rstrip(")").lstrip("rgb(") + red_str, green_str, blue_str = ( + item.strip() for item in content.split(",") + ) + red = int(red_str) + green = int(green_str) + blue = int(blue_str) + + int_validation(red, 0, 255) + int_validation(green, 0, 255) + int_validation(blue, 0, 255) + + self._red = red + self._green = green + self._blue = blue + + @property + def red(self): + return self._red + + @property + def green(self): + return self._green + + @property + def blue(self): + return self._blue + + def get_qcolor(self): + return create_qcolor(self.red, self.green, self.blue) + + +class RGBAColor: + """Color defined by red green, blue and alpha values. + + Each color has possible integer range 0-255. + + Examples: + "rgba(255, 127, 0, 127)" + """ + def __init__(self, value): + modified_color = value.lower().strip() + content = modified_color.rstrip(")").lstrip("rgba(") + red_str, green_str, blue_str, alpha_str = ( + item.strip() for item in content.split(",") + ) + red = int(red_str) + green = int(green_str) + blue = int(blue_str) + if "." in alpha_str: + alpha = int(float(alpha_str) * 100) + else: + alpha = int(alpha_str) + + int_validation(red, 0, 255) + int_validation(green, 0, 255) + int_validation(blue, 0, 255) + int_validation(alpha, 0, 255) + + self._red = red + self._green = green + self._blue = blue + self._alpha = alpha + + @property + def red(self): + return self._red + + @property + def green(self): + return self._green + + @property + def blue(self): + return self._blue + + @property + def alpha(self): + return self._alpha + + def get_qcolor(self): + return create_qcolor(self.red, self.green, self.blue, self.alpha) + + +class HSLColor: + """Color defined by hue, saturation and light values. + + Hue is defined as integer in rage 0-360. Saturation and light can be + defined as float or percent value. + + Examples: + "hsl(27, 0.7, 0.3)" + "hsl(27, 70%, 30%)" + """ + def __init__(self, value): + modified_color = value.lower().strip() + content = modified_color.rstrip(")").lstrip("hsl(") + hue_str, sat_str, light_str = ( + item.strip() for item in content.split(",") + ) + hue = int(hue_str) % 360 + if "%" in sat_str: + sat = float(sat_str.rstrip("%")) / 100 + else: + sat = float(sat) + + if "%" in light_str: + light = float(light_str.rstrip("%")) / 100 + else: + light = float(light_str) + + int_validation(hue, 0, 360) + float_validation(sat, 0, 1) + float_validation(light, 0, 1) + + self._hue = hue + self._saturation = sat + self._light = light + + @property + def hue(self): + return self._hue + + @property + def saturation(self): + return self._saturation + + @property + def light(self): + return self._light + + def get_qcolor(self): + color = create_qcolor() + color.setHslF(self.hue / 360, self.saturation, self.light) + return color + + +class HSLAColor: + """Color defined by hue, saturation, light and alpha values. + + Hue is defined as integer in rage 0-360. Saturation and light can be + defined as float (0-1 range) or percent value(0-100%). And alpha + as float (0-1 range). + + Examples: + "hsl(27, 0.7, 0.3)" + "hsl(27, 70%, 30%)" + """ + def __init__(self, value): + modified_color = value.lower().strip() + content = modified_color.rstrip(")").lstrip("hsla(") + hue_str, sat_str, light_str, alpha_str = ( + item.strip() for item in content.split(",") + ) + hue = int(hue_str) % 360 + if "%" in sat_str: + sat = float(sat_str.rstrip("%")) / 100 + else: + sat = float(sat) + + if "%" in light_str: + light = float(light_str.rstrip("%")) / 100 + else: + light = float(light_str) + + alpha = float(alpha_str) + + int_validation(hue, 0, 360) + float_validation(sat, 0, 1) + float_validation(light, 0, 1) + float_validation(alpha, 0, 1) + + self._hue = hue + self._saturation = sat + self._light = light + self._alpha = alpha + + @property + def hue(self): + return self._hue + + @property + def saturation(self): + return self._saturation + + @property + def light(self): + return self._light + + @property + def alpha(self): + return self._alpha + + def get_qcolor(self): + color = create_qcolor() + color.setHslF(self.hue / 360, self.saturation, self.light, self.alpha) + return color diff --git a/openpype/style/data.json b/openpype/style/data.json index a58829d946..977de50be2 100644 --- a/openpype/style/data.json +++ b/openpype/style/data.json @@ -18,7 +18,6 @@ "green-light": "hsl(155, 80%, 80%)" }, "color": { - "font": "#D3D8DE", "font-hover": "#F0F2F5", "font-disabled": "#99A3B2", @@ -28,25 +27,58 @@ "bg": "#2C313A", "bg-inputs": "#21252B", "bg-buttons": "#434a56", - "bg-button-hover": "hsla(220, 14%, 70%, .3)", + "bg-button-hover": "rgba(168, 175, 189, 0.3)", "bg-inputs-disabled": "#2C313A", "bg-buttons-disabled": "#434a56", + "bg-splitter": "#434a56", + "bg-splitter-hover": "rgba(168, 175, 189, 0.3)", + "bg-menu-separator": "rgba(75, 83, 98, 127)", "bg-scroll-handle": "#4B5362", "bg-view": "#21252B", "bg-view-header": "#373D48", - "bg-view-hover": "hsla(220, 14%, 70%, .3)", + "bg-view-hover": "rgba(168, 175, 189, .3)", "bg-view-alternate": "rgb(36, 42, 50)", - "bg-view-disabled": "#434a56", + "bg-view-disabled": "#2C313A", "bg-view-alternate-disabled": "#2C313A", - "bg-view-selection": "hsla(200, 60%, 60%, .4)", - "bg-view-selection-hover": "hsla(200, 60%, 60%, .8)", + "bg-view-selection": "rgba(92, 173, 214, .4)", + "bg-view-selection-hover": "rgba(92, 173, 214, .8)", "border": "#373D48", - "border-hover": "hsla(220, 14%, 70%, .3)", - "border-focus": "hsl(200, 60%, 60%)" + "border-hover": "rgba(168, 175, 189, .3)", + "border-focus": "rgb(92, 173, 214)", + + "tab-widget": { + "bg": "#21252B", + "bg-selected": "#434a56", + "bg-hover": "#373D48", + "color": "#99A3B2", + "color-selected": "#F0F2F5", + "color-hover": "#F0F2F5" + }, + + "loader": { + "asset-view": { + "selected": "rgba(168, 175, 189, 0.6)", + "hover": "rgba(168, 175, 189, 0.3)", + "selected-hover": "rgba(168, 175, 189, 0.7)" + } + }, + "publisher": { + "error": "#AA5050", + "success": "#458056", + "warning": "#ffc671", + "list-view-group": { + "bg": "#434a56", + "bg-hover": "rgba(168, 175, 189, 0.3)", + "bg-selected-hover": "rgba(92, 173, 214, 0.4)", + "bg-expander": "#2C313A", + "bg-expander-hover": "#2d6c9f", + "bg-expander-selected-hover": "#3784c5" + } + } } } diff --git a/openpype/style/images/checkbox_checked.png b/openpype/style/images/checkbox_checked.png new file mode 100644 index 0000000000..8875dcaad6 Binary files /dev/null and b/openpype/style/images/checkbox_checked.png differ diff --git a/openpype/style/images/checkbox_checked_disabled.png b/openpype/style/images/checkbox_checked_disabled.png new file mode 100644 index 0000000000..5e136e30f1 Binary files /dev/null and b/openpype/style/images/checkbox_checked_disabled.png differ diff --git a/openpype/style/images/checkbox_checked_focus.png b/openpype/style/images/checkbox_checked_focus.png new file mode 100644 index 0000000000..356d46de12 Binary files /dev/null and b/openpype/style/images/checkbox_checked_focus.png differ diff --git a/openpype/style/images/checkbox_checked_hover.png b/openpype/style/images/checkbox_checked_hover.png new file mode 100644 index 0000000000..f0a2a783a4 Binary files /dev/null and b/openpype/style/images/checkbox_checked_hover.png differ diff --git a/openpype/style/images/checkbox_indeterminate.png b/openpype/style/images/checkbox_indeterminate.png new file mode 100644 index 0000000000..bd82661dc6 Binary files /dev/null and b/openpype/style/images/checkbox_indeterminate.png differ diff --git a/openpype/style/images/checkbox_indeterminate_disabled.png b/openpype/style/images/checkbox_indeterminate_disabled.png new file mode 100644 index 0000000000..c4de5ed270 Binary files /dev/null and b/openpype/style/images/checkbox_indeterminate_disabled.png differ diff --git a/openpype/style/images/checkbox_indeterminate_focus.png b/openpype/style/images/checkbox_indeterminate_focus.png new file mode 100644 index 0000000000..546862289a Binary files /dev/null and b/openpype/style/images/checkbox_indeterminate_focus.png differ diff --git a/openpype/style/images/checkbox_indeterminate_hover.png b/openpype/style/images/checkbox_indeterminate_hover.png new file mode 100644 index 0000000000..430a98098d Binary files /dev/null and b/openpype/style/images/checkbox_indeterminate_hover.png differ diff --git a/openpype/style/images/checkbox_unchecked.png b/openpype/style/images/checkbox_unchecked.png new file mode 100644 index 0000000000..eb5890f034 Binary files /dev/null and b/openpype/style/images/checkbox_unchecked.png differ diff --git a/openpype/style/images/checkbox_unchecked_disabled.png b/openpype/style/images/checkbox_unchecked_disabled.png new file mode 100644 index 0000000000..4b1d78874d Binary files /dev/null and b/openpype/style/images/checkbox_unchecked_disabled.png differ diff --git a/openpype/style/images/checkbox_unchecked_focus.png b/openpype/style/images/checkbox_unchecked_focus.png new file mode 100644 index 0000000000..76e32385e2 Binary files /dev/null and b/openpype/style/images/checkbox_unchecked_focus.png differ diff --git a/openpype/style/images/checkbox_unchecked_hover.png b/openpype/style/images/checkbox_unchecked_hover.png new file mode 100644 index 0000000000..6053315b2b Binary files /dev/null and b/openpype/style/images/checkbox_unchecked_hover.png differ diff --git a/openpype/style/images/transparent.png b/openpype/style/images/transparent.png new file mode 100644 index 0000000000..bf9514e88e Binary files /dev/null and b/openpype/style/images/transparent.png differ diff --git a/openpype/style/pyqt5_resources.py b/openpype/style/pyqt5_resources.py index 3dc21be12a..55d4e3efcc 100644 --- a/openpype/style/pyqt5_resources.py +++ b/openpype/style/pyqt5_resources.py @@ -10,160 +10,6 @@ from PyQt5 import QtCore qt_resource_data = b"\ -\x00\x00\x00\xa0\ -\x89\ -\x50\x4e\x47\x0d\x0a\x1a\x0a\x00\x00\x00\x0d\x49\x48\x44\x52\x00\ -\x00\x00\x06\x00\x00\x00\x09\x08\x04\x00\x00\x00\xbb\x93\x95\x16\ -\x00\x00\x00\x01\x73\x52\x47\x42\x00\xae\xce\x1c\xe9\x00\x00\x00\ -\x02\x62\x4b\x47\x44\x00\xff\x87\x8f\xcc\xbf\x00\x00\x00\x09\x70\ -\x48\x59\x73\x00\x00\x0b\x13\x00\x00\x0b\x13\x01\x00\x9a\x9c\x18\ -\x00\x00\x00\x07\x74\x49\x4d\x45\x07\xdc\x08\x17\x14\x1c\x1f\x24\ -\xc6\x09\x17\x00\x00\x00\x24\x49\x44\x41\x54\x08\xd7\x63\x60\x40\ -\x05\xff\xcf\xc3\x58\x4c\xc8\x5c\x26\x64\x59\x26\x64\xc5\x70\x0e\ -\xa3\x21\x9c\xc3\x68\x88\x61\x1a\x0a\x00\x00\x6d\x84\x09\x75\x37\ -\x9e\xd9\x23\x00\x00\x00\x00\x49\x45\x4e\x44\xae\x42\x60\x82\ -\x00\x00\x07\x30\ -\x89\ -\x50\x4e\x47\x0d\x0a\x1a\x0a\x00\x00\x00\x0d\x49\x48\x44\x52\x00\ -\x00\x00\x0a\x00\x00\x00\x07\x08\x06\x00\x00\x00\x31\xac\xdc\x63\ -\x00\x00\x04\xb0\x69\x54\x58\x74\x58\x4d\x4c\x3a\x63\x6f\x6d\x2e\ -\x61\x64\x6f\x62\x65\x2e\x78\x6d\x70\x00\x00\x00\x00\x00\x3c\x3f\ -\x78\x70\x61\x63\x6b\x65\x74\x20\x62\x65\x67\x69\x6e\x3d\x22\xef\ -\xbb\xbf\x22\x20\x69\x64\x3d\x22\x57\x35\x4d\x30\x4d\x70\x43\x65\ -\x68\x69\x48\x7a\x72\x65\x53\x7a\x4e\x54\x63\x7a\x6b\x63\x39\x64\ -\x22\x3f\x3e\x0a\x3c\x78\x3a\x78\x6d\x70\x6d\x65\x74\x61\x20\x78\ -\x6d\x6c\x6e\x73\x3a\x78\x3d\x22\x61\x64\x6f\x62\x65\x3a\x6e\x73\ -\x3a\x6d\x65\x74\x61\x2f\x22\x20\x78\x3a\x78\x6d\x70\x74\x6b\x3d\ -\x22\x58\x4d\x50\x20\x43\x6f\x72\x65\x20\x35\x2e\x35\x2e\x30\x22\ -\x3e\x0a\x20\x3c\x72\x64\x66\x3a\x52\x44\x46\x20\x78\x6d\x6c\x6e\ -\x73\x3a\x72\x64\x66\x3d\x22\x68\x74\x74\x70\x3a\x2f\x2f\x77\x77\ -\x77\x2e\x77\x33\x2e\x6f\x72\x67\x2f\x31\x39\x39\x39\x2f\x30\x32\ -\x2f\x32\x32\x2d\x72\x64\x66\x2d\x73\x79\x6e\x74\x61\x78\x2d\x6e\ -\x73\x23\x22\x3e\x0a\x20\x20\x3c\x72\x64\x66\x3a\x44\x65\x73\x63\ -\x72\x69\x70\x74\x69\x6f\x6e\x20\x72\x64\x66\x3a\x61\x62\x6f\x75\ -\x74\x3d\x22\x22\x0a\x20\x20\x20\x20\x78\x6d\x6c\x6e\x73\x3a\x65\ -\x78\x69\x66\x3d\x22\x68\x74\x74\x70\x3a\x2f\x2f\x6e\x73\x2e\x61\ -\x64\x6f\x62\x65\x2e\x63\x6f\x6d\x2f\x65\x78\x69\x66\x2f\x31\x2e\ -\x30\x2f\x22\x0a\x20\x20\x20\x20\x78\x6d\x6c\x6e\x73\x3a\x74\x69\ -\x66\x66\x3d\x22\x68\x74\x74\x70\x3a\x2f\x2f\x6e\x73\x2e\x61\x64\ -\x6f\x62\x65\x2e\x63\x6f\x6d\x2f\x74\x69\x66\x66\x2f\x31\x2e\x30\ -\x2f\x22\x0a\x20\x20\x20\x20\x78\x6d\x6c\x6e\x73\x3a\x70\x68\x6f\ -\x74\x6f\x73\x68\x6f\x70\x3d\x22\x68\x74\x74\x70\x3a\x2f\x2f\x6e\ -\x73\x2e\x61\x64\x6f\x62\x65\x2e\x63\x6f\x6d\x2f\x70\x68\x6f\x74\ -\x6f\x73\x68\x6f\x70\x2f\x31\x2e\x30\x2f\x22\x0a\x20\x20\x20\x20\ -\x78\x6d\x6c\x6e\x73\x3a\x78\x6d\x70\x3d\x22\x68\x74\x74\x70\x3a\ -\x2f\x2f\x6e\x73\x2e\x61\x64\x6f\x62\x65\x2e\x63\x6f\x6d\x2f\x78\ -\x61\x70\x2f\x31\x2e\x30\x2f\x22\x0a\x20\x20\x20\x20\x78\x6d\x6c\ -\x6e\x73\x3a\x78\x6d\x70\x4d\x4d\x3d\x22\x68\x74\x74\x70\x3a\x2f\ -\x2f\x6e\x73\x2e\x61\x64\x6f\x62\x65\x2e\x63\x6f\x6d\x2f\x78\x61\ -\x70\x2f\x31\x2e\x30\x2f\x6d\x6d\x2f\x22\x0a\x20\x20\x20\x20\x78\ -\x6d\x6c\x6e\x73\x3a\x73\x74\x45\x76\x74\x3d\x22\x68\x74\x74\x70\ -\x3a\x2f\x2f\x6e\x73\x2e\x61\x64\x6f\x62\x65\x2e\x63\x6f\x6d\x2f\ -\x78\x61\x70\x2f\x31\x2e\x30\x2f\x73\x54\x79\x70\x65\x2f\x52\x65\ -\x73\x6f\x75\x72\x63\x65\x45\x76\x65\x6e\x74\x23\x22\x0a\x20\x20\ -\x20\x65\x78\x69\x66\x3a\x50\x69\x78\x65\x6c\x58\x44\x69\x6d\x65\ -\x6e\x73\x69\x6f\x6e\x3d\x22\x31\x30\x22\x0a\x20\x20\x20\x65\x78\ -\x69\x66\x3a\x50\x69\x78\x65\x6c\x59\x44\x69\x6d\x65\x6e\x73\x69\ -\x6f\x6e\x3d\x22\x37\x22\x0a\x20\x20\x20\x65\x78\x69\x66\x3a\x43\ -\x6f\x6c\x6f\x72\x53\x70\x61\x63\x65\x3d\x22\x31\x22\x0a\x20\x20\ -\x20\x74\x69\x66\x66\x3a\x49\x6d\x61\x67\x65\x57\x69\x64\x74\x68\ -\x3d\x22\x31\x30\x22\x0a\x20\x20\x20\x74\x69\x66\x66\x3a\x49\x6d\ -\x61\x67\x65\x4c\x65\x6e\x67\x74\x68\x3d\x22\x37\x22\x0a\x20\x20\ -\x20\x74\x69\x66\x66\x3a\x52\x65\x73\x6f\x6c\x75\x74\x69\x6f\x6e\ -\x55\x6e\x69\x74\x3d\x22\x32\x22\x0a\x20\x20\x20\x74\x69\x66\x66\ -\x3a\x58\x52\x65\x73\x6f\x6c\x75\x74\x69\x6f\x6e\x3d\x22\x37\x32\ -\x2e\x30\x22\x0a\x20\x20\x20\x74\x69\x66\x66\x3a\x59\x52\x65\x73\ -\x6f\x6c\x75\x74\x69\x6f\x6e\x3d\x22\x37\x32\x2e\x30\x22\x0a\x20\ -\x20\x20\x70\x68\x6f\x74\x6f\x73\x68\x6f\x70\x3a\x43\x6f\x6c\x6f\ -\x72\x4d\x6f\x64\x65\x3d\x22\x33\x22\x0a\x20\x20\x20\x70\x68\x6f\ -\x74\x6f\x73\x68\x6f\x70\x3a\x49\x43\x43\x50\x72\x6f\x66\x69\x6c\ -\x65\x3d\x22\x73\x52\x47\x42\x20\x49\x45\x43\x36\x31\x39\x36\x36\ -\x2d\x32\x2e\x31\x22\x0a\x20\x20\x20\x78\x6d\x70\x3a\x4d\x6f\x64\ -\x69\x66\x79\x44\x61\x74\x65\x3d\x22\x32\x30\x32\x31\x2d\x30\x35\ -\x2d\x33\x31\x54\x31\x32\x3a\x33\x33\x3a\x31\x34\x2b\x30\x32\x3a\ -\x30\x30\x22\x0a\x20\x20\x20\x78\x6d\x70\x3a\x4d\x65\x74\x61\x64\ -\x61\x74\x61\x44\x61\x74\x65\x3d\x22\x32\x30\x32\x31\x2d\x30\x35\ -\x2d\x33\x31\x54\x31\x32\x3a\x33\x33\x3a\x31\x34\x2b\x30\x32\x3a\ -\x30\x30\x22\x3e\x0a\x20\x20\x20\x3c\x78\x6d\x70\x4d\x4d\x3a\x48\ -\x69\x73\x74\x6f\x72\x79\x3e\x0a\x20\x20\x20\x20\x3c\x72\x64\x66\ -\x3a\x53\x65\x71\x3e\x0a\x20\x20\x20\x20\x20\x3c\x72\x64\x66\x3a\ -\x6c\x69\x0a\x20\x20\x20\x20\x20\x20\x73\x74\x45\x76\x74\x3a\x61\ -\x63\x74\x69\x6f\x6e\x3d\x22\x70\x72\x6f\x64\x75\x63\x65\x64\x22\ -\x0a\x20\x20\x20\x20\x20\x20\x73\x74\x45\x76\x74\x3a\x73\x6f\x66\ -\x74\x77\x61\x72\x65\x41\x67\x65\x6e\x74\x3d\x22\x41\x66\x66\x69\ -\x6e\x69\x74\x79\x20\x44\x65\x73\x69\x67\x6e\x65\x72\x20\x31\x2e\ -\x39\x2e\x32\x22\x0a\x20\x20\x20\x20\x20\x20\x73\x74\x45\x76\x74\ -\x3a\x77\x68\x65\x6e\x3d\x22\x32\x30\x32\x31\x2d\x30\x35\x2d\x33\ -\x31\x54\x31\x32\x3a\x33\x33\x3a\x31\x34\x2b\x30\x32\x3a\x30\x30\ -\x22\x2f\x3e\x0a\x20\x20\x20\x20\x3c\x2f\x72\x64\x66\x3a\x53\x65\ -\x71\x3e\x0a\x20\x20\x20\x3c\x2f\x78\x6d\x70\x4d\x4d\x3a\x48\x69\ -\x73\x74\x6f\x72\x79\x3e\x0a\x20\x20\x3c\x2f\x72\x64\x66\x3a\x44\ -\x65\x73\x63\x72\x69\x70\x74\x69\x6f\x6e\x3e\x0a\x20\x3c\x2f\x72\ -\x64\x66\x3a\x52\x44\x46\x3e\x0a\x3c\x2f\x78\x3a\x78\x6d\x70\x6d\ -\x65\x74\x61\x3e\x0a\x3c\x3f\x78\x70\x61\x63\x6b\x65\x74\x20\x65\ -\x6e\x64\x3d\x22\x72\x22\x3f\x3e\x48\x8b\x5b\x5e\x00\x00\x01\x83\ -\x69\x43\x43\x50\x73\x52\x47\x42\x20\x49\x45\x43\x36\x31\x39\x36\ -\x36\x2d\x32\x2e\x31\x00\x00\x28\x91\x75\x91\xcf\x2b\x44\x51\x14\ -\xc7\x3f\x66\x68\xfc\x18\x8d\x62\x61\x31\x65\x12\x16\x42\x83\x12\ -\x1b\x8b\x99\x18\x0a\x8b\x99\x51\x7e\x6d\x66\x9e\x79\x33\x6a\xde\ -\x78\xbd\x37\xd2\x64\xab\x6c\xa7\x28\xb1\xf1\x6b\xc1\x5f\xc0\x56\ -\x59\x2b\x45\xa4\x64\xa7\xac\x89\x0d\x7a\xce\x9b\x51\x23\x99\x73\ -\x3b\xf7\x7c\xee\xf7\xde\x73\xba\xf7\x5c\x70\x44\xd3\x8a\x66\x56\ -\xfa\x41\xcb\x64\x8d\x70\x28\xe0\x9b\x99\x9d\xf3\xb9\x9e\xa8\xa2\ -\x85\x1a\x3a\xf1\xc6\x14\x53\x9f\x8c\x8c\x46\x29\x6b\xef\xb7\x54\ -\xd8\xf1\xba\xdb\xae\x55\xfe\xdc\xbf\x56\xb7\x98\x30\x15\xa8\xa8\ -\x16\x1e\x56\x74\x23\x2b\x3c\x26\x3c\xb1\x9a\xd5\x6d\xde\x12\x6e\ -\x52\x52\xb1\x45\xe1\x13\xe1\x2e\x43\x2e\x28\x7c\x63\xeb\xf1\x22\ -\x3f\xdb\x9c\x2c\xf2\xa7\xcd\x46\x34\x1c\x04\x47\x83\xb0\x2f\xf9\ -\x8b\xe3\xbf\x58\x49\x19\x9a\xb0\xbc\x9c\x36\x2d\xbd\xa2\xfc\xdc\ -\xc7\x7e\x89\x3b\x91\x99\x8e\x48\x6c\x15\xf7\x62\x12\x26\x44\x00\ -\x1f\xe3\x8c\x10\x64\x80\x5e\x86\x64\x1e\xa0\x9b\x3e\x7a\x64\x45\ -\x99\x7c\x7f\x21\x7f\x8a\x65\xc9\x55\x64\xd6\xc9\x61\xb0\x44\x92\ -\x14\x59\xba\x44\x5d\x91\xea\x09\x89\xaa\xe8\x09\x19\x69\x72\x76\ -\xff\xff\xf6\xd5\x54\xfb\xfb\x8a\xd5\xdd\x01\xa8\x7a\xb4\xac\xd7\ -\x76\x70\x6d\xc2\x57\xde\xb2\x3e\x0e\x2c\xeb\xeb\x10\x9c\x0f\x70\ -\x9e\x29\xe5\x2f\xef\xc3\xe0\x9b\xe8\xf9\x92\xd6\xb6\x07\x9e\x75\ -\x38\xbd\x28\x69\xf1\x6d\x38\xdb\x80\xe6\x7b\x3d\x66\xc4\x0a\x92\ -\x53\xdc\xa1\xaa\xf0\x72\x0c\xf5\xb3\xd0\x78\x05\xb5\xf3\xc5\x9e\ -\xfd\xec\x73\x74\x07\xd1\x35\xf9\xaa\x4b\xd8\xd9\x85\x0e\x39\xef\ -\x59\xf8\x06\x8e\xfd\x67\xf8\xfd\x8a\x18\x97\x00\x00\x00\x09\x70\ -\x48\x59\x73\x00\x00\x0b\x13\x00\x00\x0b\x13\x01\x00\x9a\x9c\x18\ -\x00\x00\x00\x97\x49\x44\x41\x54\x18\x95\x6d\xcf\xb1\x6a\x02\x41\ -\x14\x85\xe1\x6f\xb7\xb6\xd0\x27\x48\x3d\x56\x69\x03\xb1\xb4\x48\ -\x3b\x6c\xa5\xf1\x39\xf6\x59\x02\x56\x42\xba\x61\x0a\x0b\x3b\x1b\ -\x1b\x6b\x41\x18\x02\x29\x6d\xe3\xbe\x82\xcd\x06\x16\xd9\xdb\xdd\ -\x9f\xff\x5c\xee\xa9\x62\x2a\x13\x4c\x73\x13\x6e\x46\x26\xa6\xf2\ -\x82\xae\x46\x8b\xdf\x98\xca\xfb\x88\xb4\xc0\x0f\xda\x1a\x5b\x74\ -\xd8\xc7\x54\xc2\x40\x9a\x63\x8f\x3f\x7c\x55\x3d\x7c\xc5\x09\x77\ -\xbc\xa1\xc2\x19\x33\x2c\x72\x13\x2e\xd5\xe0\xc2\x12\x07\x5c\x51\ -\x23\xe0\x23\x37\xe1\xa8\x4f\x0e\x7f\xda\x60\xd7\xaf\x9f\xb9\x09\ -\xdf\x63\x05\xff\xe5\x75\x4c\x65\xf5\xcc\x1f\x0d\x33\x2c\x83\xb6\ -\x06\x44\x83\x00\x00\x00\x00\x49\x45\x4e\x44\xae\x42\x60\x82\ -\x00\x00\x00\xa5\ -\x89\ -\x50\x4e\x47\x0d\x0a\x1a\x0a\x00\x00\x00\x0d\x49\x48\x44\x52\x00\ -\x00\x00\x09\x00\x00\x00\x06\x08\x04\x00\x00\x00\xbb\xce\x7c\x4e\ -\x00\x00\x00\x01\x73\x52\x47\x42\x00\xae\xce\x1c\xe9\x00\x00\x00\ -\x02\x62\x4b\x47\x44\x00\x9c\x53\x34\xfc\x5d\x00\x00\x00\x09\x70\ -\x48\x59\x73\x00\x00\x0b\x13\x00\x00\x0b\x13\x01\x00\x9a\x9c\x18\ -\x00\x00\x00\x07\x74\x49\x4d\x45\x07\xdc\x08\x17\x0b\x02\x04\x6d\ -\x98\x1b\x69\x00\x00\x00\x29\x49\x44\x41\x54\x08\xd7\x63\x60\xc0\ -\x00\x8c\x0c\x0c\xff\xcf\xa3\x08\x18\x32\x32\x30\x20\x0b\x32\x1a\ -\x32\x30\x30\x42\x98\x10\x41\x46\x43\x14\x13\x50\xb5\xa3\x01\x00\ -\xd6\x10\x07\xd2\x2f\x48\xdf\x4a\x00\x00\x00\x00\x49\x45\x4e\x44\ -\xae\x42\x60\x82\ -\x00\x00\x00\xa0\ -\x89\ -\x50\x4e\x47\x0d\x0a\x1a\x0a\x00\x00\x00\x0d\x49\x48\x44\x52\x00\ -\x00\x00\x06\x00\x00\x00\x09\x08\x04\x00\x00\x00\xbb\x93\x95\x16\ -\x00\x00\x00\x01\x73\x52\x47\x42\x00\xae\xce\x1c\xe9\x00\x00\x00\ -\x02\x62\x4b\x47\x44\x00\xff\x87\x8f\xcc\xbf\x00\x00\x00\x09\x70\ -\x48\x59\x73\x00\x00\x0b\x13\x00\x00\x0b\x13\x01\x00\x9a\x9c\x18\ -\x00\x00\x00\x07\x74\x49\x4d\x45\x07\xdc\x08\x17\x14\x1f\x0d\xfc\ -\x52\x2b\x9c\x00\x00\x00\x24\x49\x44\x41\x54\x08\xd7\x63\x60\x40\ -\x05\x73\x3e\xc0\x58\x4c\xc8\x5c\x26\x64\x59\x26\x64\xc5\x70\x4e\ -\x8a\x00\x9c\x93\x22\x80\x61\x1a\x0a\x00\x00\x29\x95\x08\xaf\x88\ -\xac\xba\x34\x00\x00\x00\x00\x49\x45\x4e\x44\xae\x42\x60\x82\ \x00\x00\x07\xad\ \x89\ \x50\x4e\x47\x0d\x0a\x1a\x0a\x00\x00\x00\x0d\x49\x48\x44\x52\x00\ @@ -289,42 +135,186 @@ qt_resource_data = b"\ \x5e\x78\xa2\x9e\x0e\xa7\x20\x74\x47\x39\x1d\xf6\xe1\x95\x2b\xd6\ \xb1\x44\x8e\x0e\xcb\x58\xf0\x0f\x52\x8a\x79\x18\xdc\xe2\x02\x70\ \x00\x00\x00\x00\x49\x45\x4e\x44\xae\x42\x60\x82\ -\x00\x00\x00\xa0\ +\x00\x00\x07\x06\ \x89\ \x50\x4e\x47\x0d\x0a\x1a\x0a\x00\x00\x00\x0d\x49\x48\x44\x52\x00\ -\x00\x00\x06\x00\x00\x00\x09\x08\x04\x00\x00\x00\xbb\x93\x95\x16\ -\x00\x00\x00\x01\x73\x52\x47\x42\x00\xae\xce\x1c\xe9\x00\x00\x00\ -\x02\x62\x4b\x47\x44\x00\xff\x87\x8f\xcc\xbf\x00\x00\x00\x09\x70\ +\x00\x00\x0a\x00\x00\x00\x07\x08\x06\x00\x00\x00\x31\xac\xdc\x63\ +\x00\x00\x04\xb0\x69\x54\x58\x74\x58\x4d\x4c\x3a\x63\x6f\x6d\x2e\ +\x61\x64\x6f\x62\x65\x2e\x78\x6d\x70\x00\x00\x00\x00\x00\x3c\x3f\ +\x78\x70\x61\x63\x6b\x65\x74\x20\x62\x65\x67\x69\x6e\x3d\x22\xef\ +\xbb\xbf\x22\x20\x69\x64\x3d\x22\x57\x35\x4d\x30\x4d\x70\x43\x65\ +\x68\x69\x48\x7a\x72\x65\x53\x7a\x4e\x54\x63\x7a\x6b\x63\x39\x64\ +\x22\x3f\x3e\x0a\x3c\x78\x3a\x78\x6d\x70\x6d\x65\x74\x61\x20\x78\ +\x6d\x6c\x6e\x73\x3a\x78\x3d\x22\x61\x64\x6f\x62\x65\x3a\x6e\x73\ +\x3a\x6d\x65\x74\x61\x2f\x22\x20\x78\x3a\x78\x6d\x70\x74\x6b\x3d\ +\x22\x58\x4d\x50\x20\x43\x6f\x72\x65\x20\x35\x2e\x35\x2e\x30\x22\ +\x3e\x0a\x20\x3c\x72\x64\x66\x3a\x52\x44\x46\x20\x78\x6d\x6c\x6e\ +\x73\x3a\x72\x64\x66\x3d\x22\x68\x74\x74\x70\x3a\x2f\x2f\x77\x77\ +\x77\x2e\x77\x33\x2e\x6f\x72\x67\x2f\x31\x39\x39\x39\x2f\x30\x32\ +\x2f\x32\x32\x2d\x72\x64\x66\x2d\x73\x79\x6e\x74\x61\x78\x2d\x6e\ +\x73\x23\x22\x3e\x0a\x20\x20\x3c\x72\x64\x66\x3a\x44\x65\x73\x63\ +\x72\x69\x70\x74\x69\x6f\x6e\x20\x72\x64\x66\x3a\x61\x62\x6f\x75\ +\x74\x3d\x22\x22\x0a\x20\x20\x20\x20\x78\x6d\x6c\x6e\x73\x3a\x65\ +\x78\x69\x66\x3d\x22\x68\x74\x74\x70\x3a\x2f\x2f\x6e\x73\x2e\x61\ +\x64\x6f\x62\x65\x2e\x63\x6f\x6d\x2f\x65\x78\x69\x66\x2f\x31\x2e\ +\x30\x2f\x22\x0a\x20\x20\x20\x20\x78\x6d\x6c\x6e\x73\x3a\x74\x69\ +\x66\x66\x3d\x22\x68\x74\x74\x70\x3a\x2f\x2f\x6e\x73\x2e\x61\x64\ +\x6f\x62\x65\x2e\x63\x6f\x6d\x2f\x74\x69\x66\x66\x2f\x31\x2e\x30\ +\x2f\x22\x0a\x20\x20\x20\x20\x78\x6d\x6c\x6e\x73\x3a\x70\x68\x6f\ +\x74\x6f\x73\x68\x6f\x70\x3d\x22\x68\x74\x74\x70\x3a\x2f\x2f\x6e\ +\x73\x2e\x61\x64\x6f\x62\x65\x2e\x63\x6f\x6d\x2f\x70\x68\x6f\x74\ +\x6f\x73\x68\x6f\x70\x2f\x31\x2e\x30\x2f\x22\x0a\x20\x20\x20\x20\ +\x78\x6d\x6c\x6e\x73\x3a\x78\x6d\x70\x3d\x22\x68\x74\x74\x70\x3a\ +\x2f\x2f\x6e\x73\x2e\x61\x64\x6f\x62\x65\x2e\x63\x6f\x6d\x2f\x78\ +\x61\x70\x2f\x31\x2e\x30\x2f\x22\x0a\x20\x20\x20\x20\x78\x6d\x6c\ +\x6e\x73\x3a\x78\x6d\x70\x4d\x4d\x3d\x22\x68\x74\x74\x70\x3a\x2f\ +\x2f\x6e\x73\x2e\x61\x64\x6f\x62\x65\x2e\x63\x6f\x6d\x2f\x78\x61\ +\x70\x2f\x31\x2e\x30\x2f\x6d\x6d\x2f\x22\x0a\x20\x20\x20\x20\x78\ +\x6d\x6c\x6e\x73\x3a\x73\x74\x45\x76\x74\x3d\x22\x68\x74\x74\x70\ +\x3a\x2f\x2f\x6e\x73\x2e\x61\x64\x6f\x62\x65\x2e\x63\x6f\x6d\x2f\ +\x78\x61\x70\x2f\x31\x2e\x30\x2f\x73\x54\x79\x70\x65\x2f\x52\x65\ +\x73\x6f\x75\x72\x63\x65\x45\x76\x65\x6e\x74\x23\x22\x0a\x20\x20\ +\x20\x65\x78\x69\x66\x3a\x50\x69\x78\x65\x6c\x58\x44\x69\x6d\x65\ +\x6e\x73\x69\x6f\x6e\x3d\x22\x31\x30\x22\x0a\x20\x20\x20\x65\x78\ +\x69\x66\x3a\x50\x69\x78\x65\x6c\x59\x44\x69\x6d\x65\x6e\x73\x69\ +\x6f\x6e\x3d\x22\x37\x22\x0a\x20\x20\x20\x65\x78\x69\x66\x3a\x43\ +\x6f\x6c\x6f\x72\x53\x70\x61\x63\x65\x3d\x22\x31\x22\x0a\x20\x20\ +\x20\x74\x69\x66\x66\x3a\x49\x6d\x61\x67\x65\x57\x69\x64\x74\x68\ +\x3d\x22\x31\x30\x22\x0a\x20\x20\x20\x74\x69\x66\x66\x3a\x49\x6d\ +\x61\x67\x65\x4c\x65\x6e\x67\x74\x68\x3d\x22\x37\x22\x0a\x20\x20\ +\x20\x74\x69\x66\x66\x3a\x52\x65\x73\x6f\x6c\x75\x74\x69\x6f\x6e\ +\x55\x6e\x69\x74\x3d\x22\x32\x22\x0a\x20\x20\x20\x74\x69\x66\x66\ +\x3a\x58\x52\x65\x73\x6f\x6c\x75\x74\x69\x6f\x6e\x3d\x22\x37\x32\ +\x2e\x30\x22\x0a\x20\x20\x20\x74\x69\x66\x66\x3a\x59\x52\x65\x73\ +\x6f\x6c\x75\x74\x69\x6f\x6e\x3d\x22\x37\x32\x2e\x30\x22\x0a\x20\ +\x20\x20\x70\x68\x6f\x74\x6f\x73\x68\x6f\x70\x3a\x43\x6f\x6c\x6f\ +\x72\x4d\x6f\x64\x65\x3d\x22\x33\x22\x0a\x20\x20\x20\x70\x68\x6f\ +\x74\x6f\x73\x68\x6f\x70\x3a\x49\x43\x43\x50\x72\x6f\x66\x69\x6c\ +\x65\x3d\x22\x73\x52\x47\x42\x20\x49\x45\x43\x36\x31\x39\x36\x36\ +\x2d\x32\x2e\x31\x22\x0a\x20\x20\x20\x78\x6d\x70\x3a\x4d\x6f\x64\ +\x69\x66\x79\x44\x61\x74\x65\x3d\x22\x32\x30\x32\x31\x2d\x30\x35\ +\x2d\x33\x31\x54\x31\x32\x3a\x33\x30\x3a\x31\x31\x2b\x30\x32\x3a\ +\x30\x30\x22\x0a\x20\x20\x20\x78\x6d\x70\x3a\x4d\x65\x74\x61\x64\ +\x61\x74\x61\x44\x61\x74\x65\x3d\x22\x32\x30\x32\x31\x2d\x30\x35\ +\x2d\x33\x31\x54\x31\x32\x3a\x33\x30\x3a\x31\x31\x2b\x30\x32\x3a\ +\x30\x30\x22\x3e\x0a\x20\x20\x20\x3c\x78\x6d\x70\x4d\x4d\x3a\x48\ +\x69\x73\x74\x6f\x72\x79\x3e\x0a\x20\x20\x20\x20\x3c\x72\x64\x66\ +\x3a\x53\x65\x71\x3e\x0a\x20\x20\x20\x20\x20\x3c\x72\x64\x66\x3a\ +\x6c\x69\x0a\x20\x20\x20\x20\x20\x20\x73\x74\x45\x76\x74\x3a\x61\ +\x63\x74\x69\x6f\x6e\x3d\x22\x70\x72\x6f\x64\x75\x63\x65\x64\x22\ +\x0a\x20\x20\x20\x20\x20\x20\x73\x74\x45\x76\x74\x3a\x73\x6f\x66\ +\x74\x77\x61\x72\x65\x41\x67\x65\x6e\x74\x3d\x22\x41\x66\x66\x69\ +\x6e\x69\x74\x79\x20\x44\x65\x73\x69\x67\x6e\x65\x72\x20\x31\x2e\ +\x39\x2e\x32\x22\x0a\x20\x20\x20\x20\x20\x20\x73\x74\x45\x76\x74\ +\x3a\x77\x68\x65\x6e\x3d\x22\x32\x30\x32\x31\x2d\x30\x35\x2d\x33\ +\x31\x54\x31\x32\x3a\x33\x30\x3a\x31\x31\x2b\x30\x32\x3a\x30\x30\ +\x22\x2f\x3e\x0a\x20\x20\x20\x20\x3c\x2f\x72\x64\x66\x3a\x53\x65\ +\x71\x3e\x0a\x20\x20\x20\x3c\x2f\x78\x6d\x70\x4d\x4d\x3a\x48\x69\ +\x73\x74\x6f\x72\x79\x3e\x0a\x20\x20\x3c\x2f\x72\x64\x66\x3a\x44\ +\x65\x73\x63\x72\x69\x70\x74\x69\x6f\x6e\x3e\x0a\x20\x3c\x2f\x72\ +\x64\x66\x3a\x52\x44\x46\x3e\x0a\x3c\x2f\x78\x3a\x78\x6d\x70\x6d\ +\x65\x74\x61\x3e\x0a\x3c\x3f\x78\x70\x61\x63\x6b\x65\x74\x20\x65\ +\x6e\x64\x3d\x22\x72\x22\x3f\x3e\x85\x9d\x9f\x08\x00\x00\x01\x83\ +\x69\x43\x43\x50\x73\x52\x47\x42\x20\x49\x45\x43\x36\x31\x39\x36\ +\x36\x2d\x32\x2e\x31\x00\x00\x28\x91\x75\x91\xcf\x2b\x44\x51\x14\ +\xc7\x3f\x66\x68\xfc\x18\x8d\x62\x61\x31\x65\x12\x16\x42\x83\x12\ +\x1b\x8b\x99\x18\x0a\x8b\x99\x51\x7e\x6d\x66\x9e\x79\x33\x6a\xde\ +\x78\xbd\x37\xd2\x64\xab\x6c\xa7\x28\xb1\xf1\x6b\xc1\x5f\xc0\x56\ +\x59\x2b\x45\xa4\x64\xa7\xac\x89\x0d\x7a\xce\x9b\x51\x23\x99\x73\ +\x3b\xf7\x7c\xee\xf7\xde\x73\xba\xf7\x5c\x70\x44\xd3\x8a\x66\x56\ +\xfa\x41\xcb\x64\x8d\x70\x28\xe0\x9b\x99\x9d\xf3\xb9\x9e\xa8\xa2\ +\x85\x1a\x3a\xf1\xc6\x14\x53\x9f\x8c\x8c\x46\x29\x6b\xef\xb7\x54\ +\xd8\xf1\xba\xdb\xae\x55\xfe\xdc\xbf\x56\xb7\x98\x30\x15\xa8\xa8\ +\x16\x1e\x56\x74\x23\x2b\x3c\x26\x3c\xb1\x9a\xd5\x6d\xde\x12\x6e\ +\x52\x52\xb1\x45\xe1\x13\xe1\x2e\x43\x2e\x28\x7c\x63\xeb\xf1\x22\ +\x3f\xdb\x9c\x2c\xf2\xa7\xcd\x46\x34\x1c\x04\x47\x83\xb0\x2f\xf9\ +\x8b\xe3\xbf\x58\x49\x19\x9a\xb0\xbc\x9c\x36\x2d\xbd\xa2\xfc\xdc\ +\xc7\x7e\x89\x3b\x91\x99\x8e\x48\x6c\x15\xf7\x62\x12\x26\x44\x00\ +\x1f\xe3\x8c\x10\x64\x80\x5e\x86\x64\x1e\xa0\x9b\x3e\x7a\x64\x45\ +\x99\x7c\x7f\x21\x7f\x8a\x65\xc9\x55\x64\xd6\xc9\x61\xb0\x44\x92\ +\x14\x59\xba\x44\x5d\x91\xea\x09\x89\xaa\xe8\x09\x19\x69\x72\x76\ +\xff\xff\xf6\xd5\x54\xfb\xfb\x8a\xd5\xdd\x01\xa8\x7a\xb4\xac\xd7\ +\x76\x70\x6d\xc2\x57\xde\xb2\x3e\x0e\x2c\xeb\xeb\x10\x9c\x0f\x70\ +\x9e\x29\xe5\x2f\xef\xc3\xe0\x9b\xe8\xf9\x92\xd6\xb6\x07\x9e\x75\ +\x38\xbd\x28\x69\xf1\x6d\x38\xdb\x80\xe6\x7b\x3d\x66\xc4\x0a\x92\ +\x53\xdc\xa1\xaa\xf0\x72\x0c\xf5\xb3\xd0\x78\x05\xb5\xf3\xc5\x9e\ +\xfd\xec\x73\x74\x07\xd1\x35\xf9\xaa\x4b\xd8\xd9\x85\x0e\x39\xef\ +\x59\xf8\x06\x8e\xfd\x67\xf8\xfd\x8a\x18\x97\x00\x00\x00\x09\x70\ \x48\x59\x73\x00\x00\x0b\x13\x00\x00\x0b\x13\x01\x00\x9a\x9c\x18\ -\x00\x00\x00\x07\x74\x49\x4d\x45\x07\xdc\x08\x17\x14\x1c\x1f\x24\ -\xc6\x09\x17\x00\x00\x00\x24\x49\x44\x41\x54\x08\xd7\x63\x60\x40\ -\x05\xff\xcf\xc3\x58\x4c\xc8\x5c\x26\x64\x59\x26\x64\xc5\x70\x0e\ -\xa3\x21\x9c\xc3\x68\x88\x61\x1a\x0a\x00\x00\x6d\x84\x09\x75\x37\ -\x9e\xd9\x23\x00\x00\x00\x00\x49\x45\x4e\x44\xae\x42\x60\x82\ -\x00\x00\x00\x9f\ +\x00\x00\x00\x6d\x49\x44\x41\x54\x18\x95\x75\xcf\xc1\x09\xc2\x50\ +\x10\x84\xe1\xd7\x85\x07\x9b\xd0\x43\x40\xd2\x82\x78\x14\x7b\x30\ +\x57\x21\x8d\x84\x60\x3f\x62\x4b\x7a\x48\xcc\x97\x83\xfb\x30\x04\ +\xdf\x9c\x86\x7f\x67\x99\xdd\x84\x0d\xaa\x54\x10\x6a\x6c\x13\x1e\ +\xbe\xba\xfe\x09\x35\x31\x7b\xe6\x8d\x0f\x26\x1c\x17\xa1\x53\xb0\ +\x11\x87\x0c\x2f\x01\x07\xec\xb0\x0f\x3f\xe1\xbc\xae\x69\xa3\xe6\ +\x85\x77\xf8\x5b\xe9\xf0\xbb\x9f\xfa\xd2\x83\x39\xdc\xa3\x5b\xf3\ +\x19\x2e\xa8\x89\xb5\x30\xf7\x43\xa0\x00\x00\x00\x00\x49\x45\x4e\ +\x44\xae\x42\x60\x82\ +\x00\x00\x01\xdc\ \x89\ \x50\x4e\x47\x0d\x0a\x1a\x0a\x00\x00\x00\x0d\x49\x48\x44\x52\x00\ -\x00\x00\x09\x00\x00\x00\x06\x08\x04\x00\x00\x00\xbb\xce\x7c\x4e\ -\x00\x00\x00\x01\x73\x52\x47\x42\x00\xae\xce\x1c\xe9\x00\x00\x00\ -\x02\x62\x4b\x47\x44\x00\xff\x87\x8f\xcc\xbf\x00\x00\x00\x09\x70\ -\x48\x59\x73\x00\x00\x0b\x13\x00\x00\x0b\x13\x01\x00\x9a\x9c\x18\ -\x00\x00\x00\x07\x74\x49\x4d\x45\x07\xdc\x08\x17\x08\x14\x1f\xf9\ -\x23\xd9\x0b\x00\x00\x00\x23\x49\x44\x41\x54\x08\xd7\x63\x60\xc0\ -\x0d\xe6\x7c\x80\xb1\x18\x91\x05\x52\x04\xe0\x42\x08\x15\x29\x02\ -\x0c\x0c\x8c\xc8\x02\x08\x95\x68\x00\x00\xac\xac\x07\x90\x4e\x65\ -\x34\xac\x00\x00\x00\x00\x49\x45\x4e\x44\xae\x42\x60\x82\ -\x00\x00\x00\x9e\ +\x00\x00\x30\x00\x00\x00\x30\x08\x06\x00\x00\x00\x57\x02\xf9\x87\ +\x00\x00\x00\x09\x70\x48\x59\x73\x00\x00\x0b\x13\x00\x00\x0b\x13\ +\x01\x00\x9a\x9c\x18\x00\x00\x01\x8e\x49\x44\x41\x54\x68\x81\xed\ +\x9a\xaf\x4e\xc3\x50\x14\x87\xbf\x6e\x1d\x0a\x1c\x41\x1e\x83\x04\ +\xc4\x0c\x6a\x41\x10\x04\x82\x80\x9e\xe7\x05\x78\x80\x21\x78\x01\ +\x5e\x00\x8f\xc2\x00\x72\x41\x90\x3d\xc2\x40\x31\x73\xe4\x32\x14\ +\xc1\xec\x4f\x82\x68\x1b\xb6\x65\xed\x28\xeb\x76\xda\xe5\x7e\xae\ +\xf7\x5c\xf1\xfb\xda\x7b\x6f\x9a\xdc\xe3\x11\xa2\xaa\xdb\xc0\x35\ +\x50\x03\xf6\x81\x0a\xf9\x62\x00\xb4\x81\x16\x70\x23\x22\x3d\x00\ +\x0f\x40\x55\x8f\x81\x7b\x60\xc7\x2c\x5e\x3a\xba\x40\x5d\x44\x5e\ +\xbc\xf0\xcd\xbf\x51\x9c\xf0\x11\x5d\x60\xaf\x44\xb0\x6c\x8a\x16\ +\x1e\x82\xcc\x0d\x9f\x60\xcd\x4f\x33\x5a\x71\x98\xbf\x52\x9e\x7a\ +\xae\xf9\x04\x1b\x76\x9c\x91\x88\xf8\x2b\x0a\x94\x0a\x55\x1d\x32\ +\x29\x71\x50\x22\x7f\xa7\x4d\x1a\x2a\x25\xeb\x04\x8b\xe2\x04\xac\ +\x71\x02\xd6\x38\x01\x6b\x9c\x80\x35\x4e\xc0\x1a\x27\x60\xcd\xdc\ +\xdf\x66\x55\x3d\x01\x0e\x81\xcd\xe5\xc7\x99\x60\x08\xbc\x03\x4f\ +\x22\xf2\x1d\x37\x29\x56\x40\x55\x7d\xe0\x01\x38\xcf\x3e\x5b\x2a\ +\x3a\xaa\x7a\x2a\x22\x1f\xb3\x8a\x49\x4b\xe8\x0a\xfb\xf0\x00\xbb\ +\xc0\x5d\x5c\x31\x49\xe0\x2c\xfb\x2c\xff\xe6\x48\x55\xb7\x66\x15\ +\x0a\xbf\x89\x93\x04\x9e\x57\x96\x62\x3e\xaf\x22\xf2\x35\xab\x90\ +\x24\x70\x0b\x3c\x2e\x27\x4f\x2a\x3a\xc0\x65\x5c\x31\xf6\x14\x12\ +\x91\x21\x70\x51\xd8\x63\x34\x42\x44\x9a\x40\x33\xc3\x60\x99\xb2\ +\xd6\x9b\xb8\x10\x38\x01\x6b\x9c\x80\x35\x4e\xc0\x1a\x27\x60\x8d\ +\x13\xb0\x66\x2d\x04\x06\xd6\x21\x16\xa0\xef\x13\x5c\xdf\x57\xc7\ +\x06\xcb\xe1\x6d\x60\x1e\x99\xbe\x66\x6d\xfb\x04\xbd\x07\xd5\x39\ +\x13\xf3\x4a\xab\xf8\xad\x06\x61\xd7\x47\x3d\x1c\x28\x0a\x51\xb3\ +\x47\xcf\x8b\x46\xc2\x2f\xd1\xe0\xb7\xdd\x66\xc3\x28\x5c\x1c\x7d\ +\x26\xdb\x6d\x3e\x01\x7e\x00\x25\xf8\x5a\x43\x55\x4e\x3a\x7f\x00\ +\x00\x00\x00\x49\x45\x4e\x44\xae\x42\x60\x82\ +\x00\x00\x01\xef\ \x89\ \x50\x4e\x47\x0d\x0a\x1a\x0a\x00\x00\x00\x0d\x49\x48\x44\x52\x00\ -\x00\x00\x09\x00\x00\x00\x06\x08\x04\x00\x00\x00\xbb\xce\x7c\x4e\ -\x00\x00\x00\x01\x73\x52\x47\x42\x00\xae\xce\x1c\xe9\x00\x00\x00\ -\x02\x62\x4b\x47\x44\x00\xff\x87\x8f\xcc\xbf\x00\x00\x00\x09\x70\ -\x48\x59\x73\x00\x00\x0b\x13\x00\x00\x0b\x13\x01\x00\x9a\x9c\x18\ -\x00\x00\x00\x07\x74\x49\x4d\x45\x07\xdc\x08\x17\x08\x15\x0f\xfd\ -\x8f\xf8\x2e\x00\x00\x00\x22\x49\x44\x41\x54\x08\xd7\x63\x60\xc0\ -\x0d\xfe\x9f\x87\xb1\x18\x91\x05\x18\x0d\xe1\x42\x48\x2a\x0c\x19\ -\x18\x18\x91\x05\x10\x2a\xd1\x00\x00\xca\xb5\x07\xd2\x76\xbb\xb2\ -\xc5\x00\x00\x00\x00\x49\x45\x4e\x44\xae\x42\x60\x82\ +\x00\x00\x30\x00\x00\x00\x30\x08\x06\x00\x00\x00\x57\x02\xf9\x87\ +\x00\x00\x00\x09\x70\x48\x59\x73\x00\x00\x0b\x13\x00\x00\x0b\x13\ +\x01\x00\x9a\x9c\x18\x00\x00\x01\xa1\x49\x44\x41\x54\x68\x81\xed\ +\x9a\xbf\x4e\xc2\x50\x14\x87\xbf\x96\xe2\xa4\x9b\x71\xbc\x8b\x1b\ +\xea\xc0\xe2\x44\x1c\x8c\x83\x83\xd1\x81\x89\x84\xd1\x17\xf0\x01\ +\x70\xc0\x07\xf0\x05\x1c\x49\x9c\xba\xa8\x23\x71\x30\x3c\x02\x76\ +\x92\xe5\x8e\x04\x27\xe3\xc2\x9f\xc4\xa1\x6d\x04\x42\x8b\x95\xc2\ +\xa1\xe4\x7e\x5b\x7b\xee\xf0\xfb\x9a\x7b\x6f\x9a\x9c\x63\x11\x50\ +\x75\xbd\x5d\xe0\x16\x28\x01\x87\x40\x9e\xf5\x62\x00\xb4\x81\x16\ +\x50\x6f\x94\x0b\x3d\x00\x0b\xa0\xea\x7a\xa7\xc0\x23\xb0\x27\x16\ +\x2f\x19\x5d\xa0\xd2\x28\x17\x5e\xad\xe0\xcb\xbf\x93\x9d\xf0\x21\ +\x5d\xe0\xc0\xc6\xdf\x36\x59\x0b\x0f\x7e\xe6\x9a\x83\xbf\xe7\xa7\ +\x19\xad\x38\xcc\x5f\xc9\x4d\x3d\x97\x1c\xfc\x03\x3b\xce\xa8\x51\ +\x2e\x38\x2b\x0a\x94\x88\xaa\xeb\x0d\x99\x94\x38\xb2\x59\xbf\xdb\ +\x26\x09\x79\x5b\x3a\xc1\xa2\x18\x01\x69\x8c\x80\x34\x46\x40\x1a\ +\x23\x20\x8d\x11\x90\xc6\x08\x48\x33\xf7\xb7\x59\x6b\x7d\x06\x1c\ +\x03\xdb\xcb\x8f\x33\xc1\x10\xf0\x80\x67\xa5\xd4\x77\xd4\xa2\x48\ +\x01\xad\xb5\x03\xb8\xc0\x65\xfa\xd9\x12\xd1\xd1\x5a\x9f\x2b\xa5\ +\x3e\x66\x15\xe3\xb6\xd0\x0d\xf2\xe1\x01\xf6\x81\x87\xa8\x62\x9c\ +\xc0\x45\xfa\x59\xfe\xcd\x89\xd6\x7a\x67\x56\x21\xf3\x87\x38\x4e\ +\xe0\x65\x65\x29\xe6\xf3\xa6\x94\xfa\x9a\x55\x88\x13\xb8\x07\x9e\ +\x96\x93\x27\x11\x1d\xe0\x3a\xaa\x18\x79\x0b\x29\xa5\x86\xc0\x55\ +\x66\xaf\xd1\x10\xa5\x54\x13\x68\xa6\x18\x2c\x55\x36\xfa\x10\x67\ +\x02\x23\x20\x8d\x11\x90\xc6\x08\x48\x63\x04\xa4\x31\x02\xd2\x6c\ +\x84\xc0\x40\x3a\xc4\x02\xf4\x1d\xfc\xf6\x7d\x71\xec\x65\x2e\xe8\ +\x06\xae\x23\xd3\x6d\xd6\xb6\x83\x3f\x7b\x50\x9c\xb3\x70\x5d\x69\ +\xd9\x40\x1d\xbf\x6d\x9f\x35\xba\xc0\x9d\x1d\x4c\x7d\x54\xc8\x96\ +\x44\x38\xec\xd1\xb3\xc2\x37\xc1\xd0\x47\x8d\xdf\x71\x9b\x2d\xa1\ +\x70\x51\xf4\x99\x1c\xb7\xf9\x04\xf8\x01\x6f\xed\x58\x63\x2d\xfd\ +\xb2\x59\x00\x00\x00\x00\x49\x45\x4e\x44\xae\x42\x60\x82\ \x00\x00\x00\xa5\ \x89\ \x50\x4e\x47\x0d\x0a\x1a\x0a\x00\x00\x00\x0d\x49\x48\x44\x52\x00\ @@ -338,6 +328,56 @@ qt_resource_data = b"\ \x32\x30\x30\x42\x98\x10\x41\x46\x43\x14\x13\x50\xb5\xa3\x01\x00\ \xd6\x10\x07\xd2\x2f\x48\xdf\x4a\x00\x00\x00\x00\x49\x45\x4e\x44\ \xae\x42\x60\x82\ +\x00\x00\x00\xa0\ +\x89\ +\x50\x4e\x47\x0d\x0a\x1a\x0a\x00\x00\x00\x0d\x49\x48\x44\x52\x00\ +\x00\x00\x06\x00\x00\x00\x09\x08\x04\x00\x00\x00\xbb\x93\x95\x16\ +\x00\x00\x00\x01\x73\x52\x47\x42\x00\xae\xce\x1c\xe9\x00\x00\x00\ +\x02\x62\x4b\x47\x44\x00\xff\x87\x8f\xcc\xbf\x00\x00\x00\x09\x70\ +\x48\x59\x73\x00\x00\x0b\x13\x00\x00\x0b\x13\x01\x00\x9a\x9c\x18\ +\x00\x00\x00\x07\x74\x49\x4d\x45\x07\xdc\x08\x17\x14\x1c\x1f\x24\ +\xc6\x09\x17\x00\x00\x00\x24\x49\x44\x41\x54\x08\xd7\x63\x60\x40\ +\x05\xff\xcf\xc3\x58\x4c\xc8\x5c\x26\x64\x59\x26\x64\xc5\x70\x0e\ +\xa3\x21\x9c\xc3\x68\x88\x61\x1a\x0a\x00\x00\x6d\x84\x09\x75\x37\ +\x9e\xd9\x23\x00\x00\x00\x00\x49\x45\x4e\x44\xae\x42\x60\x82\ +\x00\x00\x00\xa5\ +\x89\ +\x50\x4e\x47\x0d\x0a\x1a\x0a\x00\x00\x00\x0d\x49\x48\x44\x52\x00\ +\x00\x00\x09\x00\x00\x00\x06\x08\x04\x00\x00\x00\xbb\xce\x7c\x4e\ +\x00\x00\x00\x01\x73\x52\x47\x42\x00\xae\xce\x1c\xe9\x00\x00\x00\ +\x02\x62\x4b\x47\x44\x00\x9c\x53\x34\xfc\x5d\x00\x00\x00\x09\x70\ +\x48\x59\x73\x00\x00\x0b\x13\x00\x00\x0b\x13\x01\x00\x9a\x9c\x18\ +\x00\x00\x00\x07\x74\x49\x4d\x45\x07\xdc\x08\x17\x0b\x02\x04\x6d\ +\x98\x1b\x69\x00\x00\x00\x29\x49\x44\x41\x54\x08\xd7\x63\x60\xc0\ +\x00\x8c\x0c\x0c\xff\xcf\xa3\x08\x18\x32\x32\x30\x20\x0b\x32\x1a\ +\x32\x30\x30\x42\x98\x10\x41\x46\x43\x14\x13\x50\xb5\xa3\x01\x00\ +\xd6\x10\x07\xd2\x2f\x48\xdf\x4a\x00\x00\x00\x00\x49\x45\x4e\x44\ +\xae\x42\x60\x82\ +\x00\x00\x01\x69\ +\x89\ +\x50\x4e\x47\x0d\x0a\x1a\x0a\x00\x00\x00\x0d\x49\x48\x44\x52\x00\ +\x00\x00\x30\x00\x00\x00\x30\x08\x06\x00\x00\x00\x57\x02\xf9\x87\ +\x00\x00\x00\x09\x70\x48\x59\x73\x00\x00\x0b\x13\x00\x00\x0b\x13\ +\x01\x00\x9a\x9c\x18\x00\x00\x01\x1b\x49\x44\x41\x54\x68\x81\xed\ +\xda\xb1\x6d\xc2\x40\x14\x87\xf1\xcf\xc7\x91\x09\x50\x86\x70\x42\ +\x41\x4f\xc5\x0a\xae\x90\xbc\x0a\x29\xc8\x2a\x96\x52\x79\x05\x2a\ +\x46\x20\x1e\xc2\x82\x05\x48\x90\x52\xdc\x59\x01\x4b\x51\x62\x45\ +\xe2\xef\x93\xde\xaf\xb3\x45\xf1\x3e\xcb\xa6\xb9\x97\x11\x95\x75\ +\x33\x03\x5e\x80\x25\xf0\x0c\x4c\x19\x97\x0f\xe0\x00\xec\x81\x6d\ +\x55\xe4\x47\x80\x0c\xa0\xac\x9b\x15\xf0\x06\x3c\xca\xc6\x1b\xa6\ +\x05\xd6\x55\x91\xef\xb2\xf8\xe4\xdf\x49\x67\xf8\x4e\x0b\x3c\x39\ +\xc2\x6b\x93\xda\xf0\x10\x66\xde\x78\xc2\x3b\xdf\x77\xb9\xf3\x30\ +\x7f\x35\xe9\x5d\x2f\x3d\xe1\x83\xbd\x76\xa9\x8a\xdc\xdf\x69\xa0\ +\x41\xca\xba\xf9\xe4\x36\x62\xee\x18\xdf\xbf\xcd\x10\x53\xa7\x9e\ +\xe0\xbf\x2c\x40\xcd\x02\xd4\x2c\x40\xcd\x02\xd4\x2c\x40\xcd\x02\ +\xd4\x2c\x40\xcd\x02\xd4\x2c\x40\xcd\x02\xd4\x2c\x40\xcd\x02\xd4\ +\x2c\x40\xcd\x02\xd4\x2c\x40\xcd\x02\xd4\x2c\x40\xcd\x11\x4e\xc0\ +\x53\x75\xf6\x84\xe3\xfb\xc5\xd5\xcd\x49\x3c\x0d\x1c\xa3\xfe\x31\ +\xeb\xc1\x13\x76\x0f\x16\xbf\xfc\x70\xac\xf6\x0e\xd8\x12\x8e\xed\ +\x53\xd3\x02\xaf\x2e\x6e\x7d\xac\x49\x2b\xa2\x5b\xf6\x38\x66\xdd\ +\x9d\xb8\xf4\xb1\xe1\x7b\xdd\xe6\x41\x34\xdc\x4f\xce\xdc\xae\xdb\ +\x9c\x00\xbe\x00\x9f\xf6\x34\x3e\x36\x4f\x37\x81\x00\x00\x00\x00\ +\x49\x45\x4e\x44\xae\x42\x60\x82\ \x00\x00\x00\xa6\ \x89\ \x50\x4e\x47\x0d\x0a\x1a\x0a\x00\x00\x00\x0d\x49\x48\x44\x52\x00\ @@ -351,6 +391,343 @@ qt_resource_data = b"\ \x08\x30\x30\x30\x42\x98\x10\xc1\x14\x01\x14\x13\x50\xb5\xa3\x01\ \x00\xc6\xb9\x07\x90\x5d\x66\x1f\x83\x00\x00\x00\x00\x49\x45\x4e\ \x44\xae\x42\x60\x82\ +\x00\x00\x00\xa6\ +\x89\ +\x50\x4e\x47\x0d\x0a\x1a\x0a\x00\x00\x00\x0d\x49\x48\x44\x52\x00\ +\x00\x00\x09\x00\x00\x00\x06\x08\x04\x00\x00\x00\xbb\xce\x7c\x4e\ +\x00\x00\x00\x01\x73\x52\x47\x42\x00\xae\xce\x1c\xe9\x00\x00\x00\ +\x02\x62\x4b\x47\x44\x00\xff\x87\x8f\xcc\xbf\x00\x00\x00\x09\x70\ +\x48\x59\x73\x00\x00\x0b\x13\x00\x00\x0b\x13\x01\x00\x9a\x9c\x18\ +\x00\x00\x00\x07\x74\x49\x4d\x45\x07\xdc\x08\x17\x08\x15\x3b\xdc\ +\x3b\x0c\x9b\x00\x00\x00\x2a\x49\x44\x41\x54\x08\xd7\x63\x60\xc0\ +\x00\x8c\x0c\x0c\x73\x3e\x20\x0b\xa4\x08\x30\x32\x30\x20\x0b\xa6\ +\x08\x30\x30\x30\x42\x98\x10\xc1\x14\x01\x14\x13\x50\xb5\xa3\x01\ +\x00\xc6\xb9\x07\x90\x5d\x66\x1f\x83\x00\x00\x00\x00\x49\x45\x4e\ +\x44\xae\x42\x60\x82\ +\x00\x00\x04\x33\ +\x89\ +\x50\x4e\x47\x0d\x0a\x1a\x0a\x00\x00\x00\x0d\x49\x48\x44\x52\x00\ +\x00\x00\x30\x00\x00\x00\x30\x08\x06\x00\x00\x00\x57\x02\xf9\x87\ +\x00\x00\x00\x09\x70\x48\x59\x73\x00\x00\x0b\x13\x00\x00\x0b\x13\ +\x01\x00\x9a\x9c\x18\x00\x00\x03\xe5\x49\x44\x41\x54\x68\x81\xed\ +\x9a\x4d\x88\x1c\x45\x14\xc7\x7f\x33\x3b\x89\x2e\x18\xf1\x33\x1e\ +\x92\x8b\x8b\xa2\x26\x06\x14\x2f\xa2\x39\x88\x15\x89\xa9\x18\x15\ +\xd1\xca\x06\x0f\x91\x05\x3d\x88\x62\x6e\x42\xc0\x1c\x12\xc4\x83\ +\x07\x15\x0d\x42\x14\x89\x04\xa2\x96\x44\x59\x22\x2f\x46\x7d\x8a\ +\xb0\x22\x08\x1e\x34\xbb\x22\x46\x8c\x2c\x88\xba\xc4\x88\x82\x5f\ +\xc9\x46\x3d\x54\x0f\x8c\xbd\xdd\x5d\xdd\xd3\x61\xa7\x07\xfc\xdd\ +\xa6\xfa\xd5\xab\xf7\xea\xf3\xdf\xd5\xd3\x22\xc1\x58\x77\x11\xb0\ +\x03\x58\x0b\x5c\x0d\x2c\xa1\x59\x9c\x02\xa6\x81\x29\x60\xa7\x8a\ +\x3f\x0e\xd0\x02\x30\xd6\xdd\x0c\xbc\x02\x2c\x1f\x58\x78\xd5\x98\ +\x03\xb6\xa8\xf8\xf7\x5b\x49\xcf\xcf\x30\x3c\xc1\x77\x99\x03\x56\ +\xb7\x09\xd3\x66\xd8\x82\x87\x10\xf3\x63\x1d\xc2\x9c\x4f\x73\x7a\ +\x91\x83\x29\xcb\x48\xea\xf7\xda\x0e\x61\xc1\xf6\x72\x5a\xc5\x77\ +\x16\x29\xa0\x4a\x18\xeb\xe6\xf9\x6f\x12\x6b\xda\x34\x6f\xb7\xa9\ +\xc2\x92\xf6\xa0\x23\xa8\xcb\xff\x09\x0c\x9a\xa1\x4f\xa0\xa9\xbb\ +\xcd\x3d\xc0\x5d\xc0\x4b\x2a\xfe\xdd\x22\xdb\xc6\x8d\x80\xb1\xee\ +\x11\xc0\x03\xe3\xc0\x61\x63\xdd\x6e\x63\x5d\xee\x4e\xd9\xa8\x04\ +\x8c\x75\xe3\xc0\x53\x3d\x45\x2d\xe0\x41\xe0\x85\xbc\x3a\x8d\x99\ +\x42\xc6\xba\x75\xc0\xcb\x24\x02\x33\xc5\x56\x63\xdd\x91\xac\x7a\ +\x8d\x18\x01\x63\xdd\x75\xc0\x1b\xc0\xd2\x02\xb3\x0d\x59\x85\x03\ +\x4f\xc0\x58\x77\x19\x20\xc0\xb2\x88\xe9\x8b\x59\x85\x03\x4d\xc0\ +\x58\x77\x09\x70\x98\xb8\x1a\x7e\x52\xc5\xbf\x9a\xf5\x60\x60\x09\ +\x18\xeb\x96\x01\x87\x80\xb1\x88\xe9\x3e\xe0\xd1\xbc\x87\x03\x49\ +\xc0\x58\xb7\x14\x78\x13\xb8\x36\x62\xfa\x36\x30\xa1\xe2\xff\xc9\ +\x33\x58\xf4\x04\x8c\x75\x6d\x42\xaf\x9a\x88\xe9\x27\xc0\xdd\x2a\ +\x7e\xbe\xc8\x68\x10\x23\xf0\x34\xe0\x22\x36\x5f\x01\x1b\x55\xfc\ +\x6f\x31\x67\xa5\x12\x30\xd6\xb5\x92\x9e\xab\x85\xb1\x6e\x3b\xf0\ +\x70\xc4\xec\x7b\x60\x7d\xf7\xd6\x21\x46\x61\x50\x49\xe0\xdb\x80\ +\x3f\x80\x69\x63\xdd\x9a\x52\x91\x66\xfb\x9a\x00\x1e\x8f\x98\xfd\ +\x02\xdc\xaa\xe2\xbf\x2d\xeb\x37\x37\x81\x44\x7f\x4c\x12\x8e\xf6\ +\xb3\x80\xab\x80\xf7\x8c\x75\x57\x94\x75\xde\xe3\x6b\x13\xb0\x27\ +\x62\xf6\x17\x70\x87\x8a\xff\xbc\x8a\xef\xa2\x11\x78\x0e\xd8\x94\ +\x2a\x5b\x0e\xa8\xb1\xee\xd2\xb2\x0d\x18\xeb\x6e\x00\x5e\x63\xe1\ +\x0b\x79\x2f\x7f\x03\xf7\xaa\xf8\x0f\xcb\xfa\xed\x92\x99\x40\xd2\ +\x63\x0f\xe4\xd4\x59\x41\x18\x89\x15\x31\xe7\xc6\xba\x55\xc0\x41\ +\x60\x34\x62\xfa\x90\x8a\x3f\x10\xf3\x97\x45\xde\x08\x5c\x10\xa9\ +\x37\x46\x48\xe2\xe2\x3c\x03\x63\xdd\x4a\xc2\x29\x1b\xf3\xb5\x4b\ +\xc5\x3f\x1f\xb1\xc9\x25\x2f\x81\xfd\x84\x7d\xb8\x88\x2b\x81\x77\ +\x8c\x75\xe7\xa5\x1f\x18\xeb\xce\x27\x04\xbf\x32\xe2\x63\x8f\x8a\ +\xdf\x11\x8d\xb2\x80\xcc\x04\x54\xfc\x29\x82\xfa\xcb\x94\xb0\x3d\ +\x5c\x03\x1c\x32\xd6\x9d\xd3\x2d\x30\xd6\x8d\x12\xa6\xcd\xaa\x48\ +\xdd\x49\x82\xd6\xaf\x45\xee\x22\x56\xf1\x27\x80\x5b\x80\xa3\x11\ +\x1f\xd7\x03\x07\x8d\x75\xa3\xc6\xba\x11\xc2\x82\xbd\x31\x52\x67\ +\x0a\x18\x57\xf1\xb5\x6f\x00\x0b\xcf\x01\x15\xff\x23\xb0\x0e\x98\ +\x8d\xf8\xb9\x09\x38\x40\xd8\x2a\xd3\x3b\x57\x9a\x69\xe0\x76\x15\ +\xff\x67\xc9\x18\x0b\x89\x9e\xae\x2a\x7e\x96\xa0\x5b\x7e\x88\x98\ +\x6e\x00\x26\x22\x36\xb3\x84\x83\xea\xe7\x72\xe1\xc5\x29\x25\x0f\ +\x54\xfc\xd7\x84\x91\xf8\xa9\x46\x5b\x27\x08\x12\xe1\xbb\x1a\x3e\ +\x16\x50\x5a\xdf\xa8\xf8\x19\x60\x3d\xf0\x6b\x1f\xed\xfc\x0e\xdc\ +\xa6\xe2\xbf\xec\xa3\x6e\x21\x95\x04\x9a\x8a\xff\x14\xd8\x98\x04\ +\x54\x96\x79\x60\xb3\x8a\xff\xb8\x4a\x5b\x65\xa9\xac\x30\x55\xfc\ +\x14\x70\x27\x41\xbb\x94\xe1\x7e\x15\xff\x56\xd5\x76\xca\xd2\x97\ +\x44\x4e\x6e\xcb\x36\x13\x7a\xb7\x88\xed\x2a\x7e\x6f\x3f\x6d\x94\ +\xa5\x6f\x8d\xaf\xe2\x27\x81\xad\x04\x21\x96\xc5\xb3\x2a\xfe\x89\ +\x7e\xfd\x97\xa5\xd6\x4b\x8a\x8a\xdf\x0f\xdc\xc7\xc2\x35\xb1\x1b\ +\xd8\x56\xc7\x77\x59\x6a\xdf\xcc\xa9\xf8\x7d\xc6\xba\x8f\x08\x07\ +\xd8\xb9\xc0\x07\xc9\x3a\x59\x14\xce\xc8\xd5\xa2\x8a\xff\x06\x78\ +\xe6\x4c\xf8\xaa\x4a\x9b\xf0\x05\x7c\x58\x39\xd9\x21\x68\x93\xde\ +\xfb\x99\x91\xe4\x6b\x60\x13\x49\xbf\xd5\x4d\x77\x08\xca\x30\x7d\ +\xc1\x54\xf4\xfa\xd7\x24\xa6\xda\xc0\x4e\xc2\x67\xfb\x61\x63\x0e\ +\xd8\xd5\x4e\xee\x5f\xb6\x30\x5c\x49\x74\xff\xec\x71\x7c\x04\xe0\ +\xd8\xd1\x99\x63\x63\x97\xaf\xde\x0b\x9c\x4d\xf8\xf0\x7d\x21\xcd\ +\x9b\x46\x27\x81\xcf\x80\xd7\x01\xa7\xe2\xbf\x00\xf8\x17\x5d\x81\ +\x0b\x38\xb3\xfa\x20\x9c\x00\x00\x00\x00\x49\x45\x4e\x44\xae\x42\ +\x60\x82\ +\x00\x00\x01\xfc\ +\x89\ +\x50\x4e\x47\x0d\x0a\x1a\x0a\x00\x00\x00\x0d\x49\x48\x44\x52\x00\ +\x00\x00\x30\x00\x00\x00\x30\x08\x06\x00\x00\x00\x57\x02\xf9\x87\ +\x00\x00\x00\x09\x70\x48\x59\x73\x00\x00\x0b\x13\x00\x00\x0b\x13\ +\x01\x00\x9a\x9c\x18\x00\x00\x01\xae\x49\x44\x41\x54\x68\x81\xed\ +\x9a\xbd\x4a\x03\x41\x14\x46\x4f\x36\x1b\xb0\xd0\x4a\xf1\x01\x14\ +\xab\x68\x91\xc6\x2a\x58\xb8\x16\xb2\x88\x76\x0b\xe9\x7d\x01\x1f\ +\x40\x8b\xf8\x00\xbe\x80\x85\x9d\x30\x62\xa3\x32\x55\xc6\x42\xf2\ +\x02\x42\x92\x46\x83\x7d\x48\x27\x36\xf9\x01\x8b\xdd\x40\x12\xb2\ +\x89\x6b\x7e\x66\x37\xcc\xe9\x76\xef\x14\xdf\x59\xee\x0c\x0b\x77\ +\x52\x04\x38\xae\xb7\x01\x5c\x01\x79\x60\x17\xc8\x10\x2f\xda\x40\ +\x05\x28\x03\x45\x25\x45\x13\x20\x05\xe0\xb8\xde\x21\x70\x0f\x6c\ +\x6a\x8b\x17\x8d\x06\x50\x50\x52\xbc\xa6\x82\x2f\x5f\x25\x39\xe1\ +\x7b\x34\x80\xac\x85\xdf\x36\x49\x0b\x0f\x7e\xe6\x4b\x1b\xbf\xe7\ +\x87\xe9\x2e\x38\xcc\x5f\x49\x0f\x3d\xe7\x6d\xfc\x0d\xdb\x4f\x57\ +\x49\x61\x2f\x28\x50\x24\x1c\xd7\xeb\x30\x28\xb1\x67\x11\xbf\xd3\ +\x26\x0a\x19\x4b\x77\x82\x69\x31\x02\xba\x31\x02\xba\x31\x02\xba\ +\x31\x02\xba\x31\x02\xba\x31\x02\xba\x99\xf8\xdb\xec\xb8\xde\x11\ +\xb0\x0f\xac\xce\x3f\xce\x00\x1d\xa0\x06\x3c\x2b\x29\x7e\xc2\x16\ +\x85\x0a\x38\xae\x67\x03\x8f\xc0\xe9\xec\xb3\x45\xa2\xee\xb8\xde\ +\xb1\x92\xe2\x73\x54\x71\x5c\x0b\x5d\xa0\x3f\x3c\xc0\x36\x70\x1b\ +\x56\x1c\x27\x70\x32\xfb\x2c\xff\xe6\xc0\x71\xbd\xb5\x51\x85\xc4\ +\x6f\xe2\x71\x02\x2f\x0b\x4b\x31\x99\x37\x25\xc5\xf7\xa8\xc2\x38\ +\x81\x1b\xe0\x69\x3e\x79\x22\x51\x07\xce\xc3\x8a\xa1\xa7\x90\x92\ +\xa2\x03\x9c\x25\xf6\x18\xed\xa1\xa4\x28\x01\xa5\x19\x06\x9b\x29\ +\x4b\xbd\x89\x13\x81\x11\xd0\x8d\x11\xd0\x8d\x11\xd0\x8d\x11\xd0\ +\x8d\x11\xd0\xcd\x52\x08\xb4\x75\x87\x98\x82\x96\x8d\x3f\xbe\xcf\ +\xf5\xbd\x4c\x07\xd3\xc0\x38\x32\x3c\x66\xad\xd8\xf8\x77\x0f\x72\ +\x13\x16\xc6\x95\xb2\x05\x14\xf1\xc7\xf6\x49\xa3\x01\x5c\x5b\xc1\ +\xad\x8f\x02\xc9\x92\xe8\x5d\xf6\x68\xa6\x01\xbe\x3e\xaa\x5f\x5b\ +\x3b\xd9\x3b\x60\x05\x7f\xf0\xbd\x4e\xfc\xda\xa8\x05\xbc\x03\x0f\ +\x80\xa7\xa4\xa8\x01\xfc\x02\x51\xab\x5c\x8a\x3f\xde\xe3\x59\x00\ +\x00\x00\x00\x49\x45\x4e\x44\xae\x42\x60\x82\ +\x00\x00\x00\xa6\ +\x89\ +\x50\x4e\x47\x0d\x0a\x1a\x0a\x00\x00\x00\x0d\x49\x48\x44\x52\x00\ +\x00\x00\x06\x00\x00\x00\x09\x08\x04\x00\x00\x00\xbb\x93\x95\x16\ +\x00\x00\x00\x01\x73\x52\x47\x42\x00\xae\xce\x1c\xe9\x00\x00\x00\ +\x02\x62\x4b\x47\x44\x00\xff\x87\x8f\xcc\xbf\x00\x00\x00\x09\x70\ +\x48\x59\x73\x00\x00\x0b\x13\x00\x00\x0b\x13\x01\x00\x9a\x9c\x18\ +\x00\x00\x00\x07\x74\x49\x4d\x45\x07\xdc\x08\x17\x14\x1f\x20\xb9\ +\x8d\x77\xe9\x00\x00\x00\x2a\x49\x44\x41\x54\x08\xd7\x63\x60\xc0\ +\x06\xe6\x7c\x60\x60\x60\x42\x30\xa1\x1c\x08\x93\x81\x81\x09\xc1\ +\x64\x60\x60\x62\x60\x48\x11\x40\xe2\x20\x73\x19\x90\x8d\x40\x02\ +\x00\x23\xed\x08\xaf\x64\x9f\x0f\x15\x00\x00\x00\x00\x49\x45\x4e\ +\x44\xae\x42\x60\x82\ +\x00\x00\x07\x30\ +\x89\ +\x50\x4e\x47\x0d\x0a\x1a\x0a\x00\x00\x00\x0d\x49\x48\x44\x52\x00\ +\x00\x00\x0a\x00\x00\x00\x07\x08\x06\x00\x00\x00\x31\xac\xdc\x63\ +\x00\x00\x04\xb0\x69\x54\x58\x74\x58\x4d\x4c\x3a\x63\x6f\x6d\x2e\ +\x61\x64\x6f\x62\x65\x2e\x78\x6d\x70\x00\x00\x00\x00\x00\x3c\x3f\ +\x78\x70\x61\x63\x6b\x65\x74\x20\x62\x65\x67\x69\x6e\x3d\x22\xef\ +\xbb\xbf\x22\x20\x69\x64\x3d\x22\x57\x35\x4d\x30\x4d\x70\x43\x65\ +\x68\x69\x48\x7a\x72\x65\x53\x7a\x4e\x54\x63\x7a\x6b\x63\x39\x64\ +\x22\x3f\x3e\x0a\x3c\x78\x3a\x78\x6d\x70\x6d\x65\x74\x61\x20\x78\ +\x6d\x6c\x6e\x73\x3a\x78\x3d\x22\x61\x64\x6f\x62\x65\x3a\x6e\x73\ +\x3a\x6d\x65\x74\x61\x2f\x22\x20\x78\x3a\x78\x6d\x70\x74\x6b\x3d\ +\x22\x58\x4d\x50\x20\x43\x6f\x72\x65\x20\x35\x2e\x35\x2e\x30\x22\ +\x3e\x0a\x20\x3c\x72\x64\x66\x3a\x52\x44\x46\x20\x78\x6d\x6c\x6e\ +\x73\x3a\x72\x64\x66\x3d\x22\x68\x74\x74\x70\x3a\x2f\x2f\x77\x77\ +\x77\x2e\x77\x33\x2e\x6f\x72\x67\x2f\x31\x39\x39\x39\x2f\x30\x32\ +\x2f\x32\x32\x2d\x72\x64\x66\x2d\x73\x79\x6e\x74\x61\x78\x2d\x6e\ +\x73\x23\x22\x3e\x0a\x20\x20\x3c\x72\x64\x66\x3a\x44\x65\x73\x63\ +\x72\x69\x70\x74\x69\x6f\x6e\x20\x72\x64\x66\x3a\x61\x62\x6f\x75\ +\x74\x3d\x22\x22\x0a\x20\x20\x20\x20\x78\x6d\x6c\x6e\x73\x3a\x65\ +\x78\x69\x66\x3d\x22\x68\x74\x74\x70\x3a\x2f\x2f\x6e\x73\x2e\x61\ +\x64\x6f\x62\x65\x2e\x63\x6f\x6d\x2f\x65\x78\x69\x66\x2f\x31\x2e\ +\x30\x2f\x22\x0a\x20\x20\x20\x20\x78\x6d\x6c\x6e\x73\x3a\x74\x69\ +\x66\x66\x3d\x22\x68\x74\x74\x70\x3a\x2f\x2f\x6e\x73\x2e\x61\x64\ +\x6f\x62\x65\x2e\x63\x6f\x6d\x2f\x74\x69\x66\x66\x2f\x31\x2e\x30\ +\x2f\x22\x0a\x20\x20\x20\x20\x78\x6d\x6c\x6e\x73\x3a\x70\x68\x6f\ +\x74\x6f\x73\x68\x6f\x70\x3d\x22\x68\x74\x74\x70\x3a\x2f\x2f\x6e\ +\x73\x2e\x61\x64\x6f\x62\x65\x2e\x63\x6f\x6d\x2f\x70\x68\x6f\x74\ +\x6f\x73\x68\x6f\x70\x2f\x31\x2e\x30\x2f\x22\x0a\x20\x20\x20\x20\ +\x78\x6d\x6c\x6e\x73\x3a\x78\x6d\x70\x3d\x22\x68\x74\x74\x70\x3a\ +\x2f\x2f\x6e\x73\x2e\x61\x64\x6f\x62\x65\x2e\x63\x6f\x6d\x2f\x78\ +\x61\x70\x2f\x31\x2e\x30\x2f\x22\x0a\x20\x20\x20\x20\x78\x6d\x6c\ +\x6e\x73\x3a\x78\x6d\x70\x4d\x4d\x3d\x22\x68\x74\x74\x70\x3a\x2f\ +\x2f\x6e\x73\x2e\x61\x64\x6f\x62\x65\x2e\x63\x6f\x6d\x2f\x78\x61\ +\x70\x2f\x31\x2e\x30\x2f\x6d\x6d\x2f\x22\x0a\x20\x20\x20\x20\x78\ +\x6d\x6c\x6e\x73\x3a\x73\x74\x45\x76\x74\x3d\x22\x68\x74\x74\x70\ +\x3a\x2f\x2f\x6e\x73\x2e\x61\x64\x6f\x62\x65\x2e\x63\x6f\x6d\x2f\ +\x78\x61\x70\x2f\x31\x2e\x30\x2f\x73\x54\x79\x70\x65\x2f\x52\x65\ +\x73\x6f\x75\x72\x63\x65\x45\x76\x65\x6e\x74\x23\x22\x0a\x20\x20\ +\x20\x65\x78\x69\x66\x3a\x50\x69\x78\x65\x6c\x58\x44\x69\x6d\x65\ +\x6e\x73\x69\x6f\x6e\x3d\x22\x31\x30\x22\x0a\x20\x20\x20\x65\x78\ +\x69\x66\x3a\x50\x69\x78\x65\x6c\x59\x44\x69\x6d\x65\x6e\x73\x69\ +\x6f\x6e\x3d\x22\x37\x22\x0a\x20\x20\x20\x65\x78\x69\x66\x3a\x43\ +\x6f\x6c\x6f\x72\x53\x70\x61\x63\x65\x3d\x22\x31\x22\x0a\x20\x20\ +\x20\x74\x69\x66\x66\x3a\x49\x6d\x61\x67\x65\x57\x69\x64\x74\x68\ +\x3d\x22\x31\x30\x22\x0a\x20\x20\x20\x74\x69\x66\x66\x3a\x49\x6d\ +\x61\x67\x65\x4c\x65\x6e\x67\x74\x68\x3d\x22\x37\x22\x0a\x20\x20\ +\x20\x74\x69\x66\x66\x3a\x52\x65\x73\x6f\x6c\x75\x74\x69\x6f\x6e\ +\x55\x6e\x69\x74\x3d\x22\x32\x22\x0a\x20\x20\x20\x74\x69\x66\x66\ +\x3a\x58\x52\x65\x73\x6f\x6c\x75\x74\x69\x6f\x6e\x3d\x22\x37\x32\ +\x2e\x30\x22\x0a\x20\x20\x20\x74\x69\x66\x66\x3a\x59\x52\x65\x73\ +\x6f\x6c\x75\x74\x69\x6f\x6e\x3d\x22\x37\x32\x2e\x30\x22\x0a\x20\ +\x20\x20\x70\x68\x6f\x74\x6f\x73\x68\x6f\x70\x3a\x43\x6f\x6c\x6f\ +\x72\x4d\x6f\x64\x65\x3d\x22\x33\x22\x0a\x20\x20\x20\x70\x68\x6f\ +\x74\x6f\x73\x68\x6f\x70\x3a\x49\x43\x43\x50\x72\x6f\x66\x69\x6c\ +\x65\x3d\x22\x73\x52\x47\x42\x20\x49\x45\x43\x36\x31\x39\x36\x36\ +\x2d\x32\x2e\x31\x22\x0a\x20\x20\x20\x78\x6d\x70\x3a\x4d\x6f\x64\ +\x69\x66\x79\x44\x61\x74\x65\x3d\x22\x32\x30\x32\x31\x2d\x30\x35\ +\x2d\x33\x31\x54\x31\x32\x3a\x33\x33\x3a\x31\x34\x2b\x30\x32\x3a\ +\x30\x30\x22\x0a\x20\x20\x20\x78\x6d\x70\x3a\x4d\x65\x74\x61\x64\ +\x61\x74\x61\x44\x61\x74\x65\x3d\x22\x32\x30\x32\x31\x2d\x30\x35\ +\x2d\x33\x31\x54\x31\x32\x3a\x33\x33\x3a\x31\x34\x2b\x30\x32\x3a\ +\x30\x30\x22\x3e\x0a\x20\x20\x20\x3c\x78\x6d\x70\x4d\x4d\x3a\x48\ +\x69\x73\x74\x6f\x72\x79\x3e\x0a\x20\x20\x20\x20\x3c\x72\x64\x66\ +\x3a\x53\x65\x71\x3e\x0a\x20\x20\x20\x20\x20\x3c\x72\x64\x66\x3a\ +\x6c\x69\x0a\x20\x20\x20\x20\x20\x20\x73\x74\x45\x76\x74\x3a\x61\ +\x63\x74\x69\x6f\x6e\x3d\x22\x70\x72\x6f\x64\x75\x63\x65\x64\x22\ +\x0a\x20\x20\x20\x20\x20\x20\x73\x74\x45\x76\x74\x3a\x73\x6f\x66\ +\x74\x77\x61\x72\x65\x41\x67\x65\x6e\x74\x3d\x22\x41\x66\x66\x69\ +\x6e\x69\x74\x79\x20\x44\x65\x73\x69\x67\x6e\x65\x72\x20\x31\x2e\ +\x39\x2e\x32\x22\x0a\x20\x20\x20\x20\x20\x20\x73\x74\x45\x76\x74\ +\x3a\x77\x68\x65\x6e\x3d\x22\x32\x30\x32\x31\x2d\x30\x35\x2d\x33\ +\x31\x54\x31\x32\x3a\x33\x33\x3a\x31\x34\x2b\x30\x32\x3a\x30\x30\ +\x22\x2f\x3e\x0a\x20\x20\x20\x20\x3c\x2f\x72\x64\x66\x3a\x53\x65\ +\x71\x3e\x0a\x20\x20\x20\x3c\x2f\x78\x6d\x70\x4d\x4d\x3a\x48\x69\ +\x73\x74\x6f\x72\x79\x3e\x0a\x20\x20\x3c\x2f\x72\x64\x66\x3a\x44\ +\x65\x73\x63\x72\x69\x70\x74\x69\x6f\x6e\x3e\x0a\x20\x3c\x2f\x72\ +\x64\x66\x3a\x52\x44\x46\x3e\x0a\x3c\x2f\x78\x3a\x78\x6d\x70\x6d\ +\x65\x74\x61\x3e\x0a\x3c\x3f\x78\x70\x61\x63\x6b\x65\x74\x20\x65\ +\x6e\x64\x3d\x22\x72\x22\x3f\x3e\x48\x8b\x5b\x5e\x00\x00\x01\x83\ +\x69\x43\x43\x50\x73\x52\x47\x42\x20\x49\x45\x43\x36\x31\x39\x36\ +\x36\x2d\x32\x2e\x31\x00\x00\x28\x91\x75\x91\xcf\x2b\x44\x51\x14\ +\xc7\x3f\x66\x68\xfc\x18\x8d\x62\x61\x31\x65\x12\x16\x42\x83\x12\ +\x1b\x8b\x99\x18\x0a\x8b\x99\x51\x7e\x6d\x66\x9e\x79\x33\x6a\xde\ +\x78\xbd\x37\xd2\x64\xab\x6c\xa7\x28\xb1\xf1\x6b\xc1\x5f\xc0\x56\ +\x59\x2b\x45\xa4\x64\xa7\xac\x89\x0d\x7a\xce\x9b\x51\x23\x99\x73\ +\x3b\xf7\x7c\xee\xf7\xde\x73\xba\xf7\x5c\x70\x44\xd3\x8a\x66\x56\ +\xfa\x41\xcb\x64\x8d\x70\x28\xe0\x9b\x99\x9d\xf3\xb9\x9e\xa8\xa2\ +\x85\x1a\x3a\xf1\xc6\x14\x53\x9f\x8c\x8c\x46\x29\x6b\xef\xb7\x54\ +\xd8\xf1\xba\xdb\xae\x55\xfe\xdc\xbf\x56\xb7\x98\x30\x15\xa8\xa8\ +\x16\x1e\x56\x74\x23\x2b\x3c\x26\x3c\xb1\x9a\xd5\x6d\xde\x12\x6e\ +\x52\x52\xb1\x45\xe1\x13\xe1\x2e\x43\x2e\x28\x7c\x63\xeb\xf1\x22\ +\x3f\xdb\x9c\x2c\xf2\xa7\xcd\x46\x34\x1c\x04\x47\x83\xb0\x2f\xf9\ +\x8b\xe3\xbf\x58\x49\x19\x9a\xb0\xbc\x9c\x36\x2d\xbd\xa2\xfc\xdc\ +\xc7\x7e\x89\x3b\x91\x99\x8e\x48\x6c\x15\xf7\x62\x12\x26\x44\x00\ +\x1f\xe3\x8c\x10\x64\x80\x5e\x86\x64\x1e\xa0\x9b\x3e\x7a\x64\x45\ +\x99\x7c\x7f\x21\x7f\x8a\x65\xc9\x55\x64\xd6\xc9\x61\xb0\x44\x92\ +\x14\x59\xba\x44\x5d\x91\xea\x09\x89\xaa\xe8\x09\x19\x69\x72\x76\ +\xff\xff\xf6\xd5\x54\xfb\xfb\x8a\xd5\xdd\x01\xa8\x7a\xb4\xac\xd7\ +\x76\x70\x6d\xc2\x57\xde\xb2\x3e\x0e\x2c\xeb\xeb\x10\x9c\x0f\x70\ +\x9e\x29\xe5\x2f\xef\xc3\xe0\x9b\xe8\xf9\x92\xd6\xb6\x07\x9e\x75\ +\x38\xbd\x28\x69\xf1\x6d\x38\xdb\x80\xe6\x7b\x3d\x66\xc4\x0a\x92\ +\x53\xdc\xa1\xaa\xf0\x72\x0c\xf5\xb3\xd0\x78\x05\xb5\xf3\xc5\x9e\ +\xfd\xec\x73\x74\x07\xd1\x35\xf9\xaa\x4b\xd8\xd9\x85\x0e\x39\xef\ +\x59\xf8\x06\x8e\xfd\x67\xf8\xfd\x8a\x18\x97\x00\x00\x00\x09\x70\ +\x48\x59\x73\x00\x00\x0b\x13\x00\x00\x0b\x13\x01\x00\x9a\x9c\x18\ +\x00\x00\x00\x97\x49\x44\x41\x54\x18\x95\x6d\xcf\xb1\x6a\x02\x41\ +\x14\x85\xe1\x6f\xb7\xb6\xd0\x27\x48\x3d\x56\x69\x03\xb1\xb4\x48\ +\x3b\x6c\xa5\xf1\x39\xf6\x59\x02\x56\x42\xba\x61\x0a\x0b\x3b\x1b\ +\x1b\x6b\x41\x18\x02\x29\x6d\xe3\xbe\x82\xcd\x06\x16\xd9\xdb\xdd\ +\x9f\xff\x5c\xee\xa9\x62\x2a\x13\x4c\x73\x13\x6e\x46\x26\xa6\xf2\ +\x82\xae\x46\x8b\xdf\x98\xca\xfb\x88\xb4\xc0\x0f\xda\x1a\x5b\x74\ +\xd8\xc7\x54\xc2\x40\x9a\x63\x8f\x3f\x7c\x55\x3d\x7c\xc5\x09\x77\ +\xbc\xa1\xc2\x19\x33\x2c\x72\x13\x2e\xd5\xe0\xc2\x12\x07\x5c\x51\ +\x23\xe0\x23\x37\xe1\xa8\x4f\x0e\x7f\xda\x60\xd7\xaf\x9f\xb9\x09\ +\xdf\x63\x05\xff\xe5\x75\x4c\x65\xf5\xcc\x1f\x0d\x33\x2c\x83\xb6\ +\x06\x44\x83\x00\x00\x00\x00\x49\x45\x4e\x44\xae\x42\x60\x82\ +\x00\x00\x03\xfb\ +\x89\ +\x50\x4e\x47\x0d\x0a\x1a\x0a\x00\x00\x00\x0d\x49\x48\x44\x52\x00\ +\x00\x00\x30\x00\x00\x00\x30\x08\x06\x00\x00\x00\x57\x02\xf9\x87\ +\x00\x00\x00\x09\x70\x48\x59\x73\x00\x00\x0b\x13\x00\x00\x0b\x13\ +\x01\x00\x9a\x9c\x18\x00\x00\x03\xad\x49\x44\x41\x54\x68\x81\xed\ +\x9a\x4f\xa8\x15\x55\x1c\xc7\x3f\xf7\xbe\xab\xf2\x20\xa3\x22\x6d\ +\xa1\x7c\x21\x09\x2a\x4b\x28\xda\x44\xb9\x88\x52\x4c\xcc\x6a\x51\ +\xf9\xa4\x85\xf1\xa0\x16\x51\xe4\x2e\x10\x74\x51\x44\x8b\x16\x15\ +\x25\x81\xb5\x28\x04\xad\xc0\xe2\x61\x64\xf6\x97\xe0\x45\x10\xb4\ +\xa9\x27\x44\x45\xc4\x17\xa2\x12\x35\x0a\x2a\xff\x3c\xab\xc5\x99\ +\x5b\xd7\x79\x33\x73\xce\xdc\x6b\x6f\xee\x05\x3f\xbb\x39\xf3\x3b\ +\xbf\xf3\xfb\x9d\x33\xe7\x9c\xef\x9c\x99\x16\x19\xb6\x2f\x06\x76\ +\x00\xab\x81\xab\x81\x05\x0c\x17\xa7\x80\x19\x60\x1a\x78\x4c\xd2\ +\x11\x80\x16\x80\xed\x9b\x81\xbd\xc0\xd2\xc6\xc2\xab\xc7\x61\x60\ +\xb3\xa4\x0f\x5b\x59\xcf\x1f\x62\x74\x82\xef\x72\x18\xb8\xaa\x4d\ +\x78\x6c\x46\x2d\x78\x08\x31\x6f\xef\x10\x9e\xf9\x3c\xa7\xe7\x39\ +\x98\x54\xc6\x72\xd7\xab\x3b\x84\x09\xdb\xcb\x69\x49\x9d\x79\x0a\ +\xa8\x16\xb6\x67\x39\x33\x89\x55\x6d\x86\x6f\xb5\xa9\xc3\x82\x76\ +\xd3\x11\x0c\xca\xb9\x04\x9a\xe6\x5c\x02\xff\x07\xb6\xef\xb6\xbd\ +\xd7\xf6\xda\x98\xed\xd0\x25\x60\xfb\x11\xe0\x75\x60\x02\x38\x68\ +\x7b\xa7\xed\xd2\x95\x72\xa8\x12\xb0\x3d\x01\x3c\xdd\x53\xd4\x02\ +\x1e\x04\x5e\x2c\xab\x33\x34\x1b\x96\xed\x35\xc0\x2b\x64\x02\x33\ +\xc7\x16\xdb\x5f\x16\xd5\x1b\x8a\x11\xb0\x7d\x1d\xf0\x06\xb0\xb0\ +\xc2\x6c\x7d\x51\x61\xe3\x09\xd8\xbe\x0c\x78\x1b\x58\x1c\x31\x7d\ +\xa9\xa8\xb0\xd1\x04\x6c\x5f\x02\x1c\x24\xae\x86\x9f\x92\xf4\x6a\ +\xd1\x8d\xc6\x12\xb0\xbd\x18\x38\x00\xac\x88\x98\xee\x06\x1e\x2d\ +\xbb\xd9\x48\x02\xb6\x17\x02\x6f\x02\xd7\x46\x4c\xdf\x01\x26\x25\ +\xfd\x5d\x66\x30\xef\x09\xd8\x6e\x13\x7a\xf5\x96\x88\xe9\x67\xc0\ +\x5d\x92\x66\xab\x8c\x9a\x18\x81\x67\x80\x7b\x22\x36\x5f\x03\x1b\ +\x24\xfd\x1e\x73\x96\x94\x80\xed\x56\xd6\x73\x03\x61\x7b\x1b\xf0\ +\x70\xc4\xec\x47\x60\x5d\xf7\xd4\x21\x46\x65\x50\x59\xe0\x5b\x81\ +\x3f\x81\x19\xdb\xab\x92\x22\x2d\xf6\x35\x09\x3c\x11\x31\xfb\x15\ +\xb8\x55\xd2\xf7\xa9\x7e\x4b\x13\xc8\xf4\xc7\x14\x61\x6b\x5f\x04\ +\x5c\x09\xbc\x6f\xfb\xf2\x54\xe7\x3d\xbe\x36\x02\xbb\x22\x66\x27\ +\x80\x3b\x24\x7d\x51\xc7\x77\xd5\x08\x3c\x0f\x6c\xcc\x95\x2d\x05\ +\x3e\xb0\x7d\x69\x6a\x03\xb6\x6f\x00\x5e\x63\xee\x0b\x79\x2f\x7f\ +\x01\xf7\x4a\xfa\x38\xd5\x6f\x97\xc2\x04\xb2\x1e\x7b\xa0\xa4\xce\ +\x32\xc2\x48\x2c\x8b\x39\xb7\xbd\x12\xd8\x0f\x8c\x47\x4c\x1f\x92\ +\xb4\x2f\xe6\xaf\x88\xb2\x11\xb8\x28\x52\x6f\x05\x21\x89\x25\x65\ +\x06\xb6\x97\x13\x76\xd9\x98\xaf\xc7\x25\xbd\x10\xb1\x29\xa5\x2c\ +\x81\x3d\x84\x75\xb8\x8a\x2b\x80\x77\x6d\x5f\x90\xbf\x61\xfb\x42\ +\x42\xf0\xcb\x23\x3e\x76\x49\xda\x11\x8d\xb2\x82\xc2\x04\x24\x9d\ +\x22\xa8\xbf\x42\x09\xdb\xc3\x35\xc0\x01\xdb\xe7\x75\x0b\x6c\x8f\ +\x13\x1e\x9b\x95\x91\xba\x53\x04\xad\x3f\x10\xa5\x93\x58\xd2\x31\ +\x60\x2d\xf0\x4d\xc4\xc7\xf5\xc0\x7e\xdb\xe3\xb6\xc7\x08\x13\xf6\ +\xc6\x48\x9d\x69\x60\x42\xd2\xc0\x27\x80\x95\xfb\x80\xa4\x9f\x81\ +\x35\x80\x23\x7e\x6e\x02\xf6\x11\x96\xca\xfc\xca\x95\x67\x06\xb8\ +\x5d\xd2\xf1\xc4\x18\x2b\x89\xee\xae\x92\x4c\xd0\x2d\x3f\x45\x4c\ +\xd7\x03\x93\x11\x1b\x13\x36\xaa\x5f\xd2\xc2\x8b\x93\x24\x0f\x24\ +\x7d\x4b\x18\x89\xa3\x03\xb4\x75\x8c\x20\x11\x7e\x18\xc0\xc7\x1c\ +\x92\xf5\x8d\xa4\x43\xc0\x3a\xe0\xb7\x3e\xda\xf9\x03\xb8\x4d\xd2\ +\x57\x7d\xd4\xad\xa4\x96\x40\x93\xf4\x39\xb0\x21\x0b\x28\x95\x59\ +\x60\x93\xa4\x4f\xeb\xb4\x95\x4a\x6d\x85\x29\x69\x1a\xb8\x93\xa0\ +\x5d\x52\xb8\x5f\xd2\x5b\x75\xdb\x49\xa5\x2f\x89\x2c\xe9\x3d\x60\ +\x13\xa1\x77\xab\xd8\x26\xe9\xe5\x7e\xda\x48\xa5\x6f\x8d\x2f\x69\ +\x0a\xd8\x42\x10\x62\x45\x3c\x27\xe9\xc9\x7e\xfd\xa7\x32\xd0\x4b\ +\x8a\xa4\x3d\xc0\x7d\xcc\x9d\x13\x3b\x81\xad\x83\xf8\x4e\x65\xe0\ +\x93\x39\x49\xbb\x6d\x7f\x42\xd8\xc0\xce\x07\x3e\xca\xe6\xc9\xbc\ +\x70\x56\x8e\x16\x25\x7d\x07\x3c\x7b\x36\x7c\xd5\xa5\x4d\xf8\x02\ +\x3e\xaa\x9c\xec\x10\xb4\x49\xef\xf9\xcc\x58\xf6\x35\x70\x18\xc9\ +\xbf\xd5\xcd\x74\x08\xca\x30\x7f\xc0\x54\xf5\xfa\x37\x4c\x4c\x8f\ +\xfe\xaf\x06\xd9\xf9\xcb\xe6\xac\x60\x54\xe8\xfe\xec\x71\xe4\xdf\ +\x8f\x09\xd9\x48\x6c\xe7\xbf\xdf\x6d\xaa\xce\xea\x9b\xe0\x24\x67\ +\xfe\x6e\x73\x14\xe0\x1f\x0a\x43\x12\x6b\x4f\xfd\x3f\x13\x00\x00\ +\x00\x00\x49\x45\x4e\x44\xae\x42\x60\x82\ +\x00\x00\x01\x5b\ +\x89\ +\x50\x4e\x47\x0d\x0a\x1a\x0a\x00\x00\x00\x0d\x49\x48\x44\x52\x00\ +\x00\x00\x30\x00\x00\x00\x30\x08\x06\x00\x00\x00\x57\x02\xf9\x87\ +\x00\x00\x00\x09\x70\x48\x59\x73\x00\x00\x0b\x13\x00\x00\x0b\x13\ +\x01\x00\x9a\x9c\x18\x00\x00\x01\x0d\x49\x44\x41\x54\x68\x81\xed\ +\xda\xb1\x6d\x02\x41\x10\x46\xe1\x77\xc7\xe2\x0a\x2c\x87\xd3\x00\ +\x38\x20\x27\x72\x17\x14\x83\x03\x37\xe3\x2e\x1c\x51\x02\x34\x30\ +\x21\xc2\x0d\x60\x90\x1c\xec\x9e\x0c\x27\x59\xf6\x09\x89\xff\x56\ +\x9a\x2f\x63\x45\x30\x0f\x0e\x92\x9d\x86\xc2\xdd\x1f\x81\x57\x60\ +\x09\xcc\x81\x29\xe3\xf2\x05\x6c\x81\x0d\xf0\x66\x66\x07\x80\x06\ +\xc0\xdd\x5f\x80\x77\xe0\x49\x36\xde\x30\x7b\x60\x65\x66\x1f\x4d\ +\xf9\xe4\x77\xd4\x33\x7c\x67\x0f\xcc\x5a\xf2\x63\x53\xdb\xf0\x90\ +\x67\x5e\x27\xf2\x33\xdf\x77\xbe\xf3\x30\xff\x35\xe9\xbd\x5e\x26\ +\xf2\x0f\xf6\xd2\xd9\xcc\xd2\x9d\x06\x1a\xc4\xdd\x4f\x5c\x47\x3c\ +\xb7\x8c\xef\xdf\x66\x88\x69\xab\x9e\xe0\x56\x11\xa0\x16\x01\x6a\ +\x11\xa0\x16\x01\x6a\x11\xa0\x16\x01\x6a\x11\xa0\x16\x01\x6a\x11\ +\xa0\x16\x01\x6a\x11\xa0\x16\x01\x6a\x11\xa0\x16\x01\x6a\x11\xa0\ +\x16\x01\x6a\x11\xa0\xd6\x92\x6f\xc0\x6b\x75\x4c\xe4\xeb\xfb\xc5\ +\xc5\xe1\xa4\xdc\x06\x8e\x51\xff\x9a\x75\x9b\xc8\xbb\x07\x8b\x3f\ +\xde\x38\x56\x9b\xfa\x57\x0d\xca\xd6\xc7\xaa\x1c\xd4\xa2\x5b\xf6\ +\x38\x34\xdd\x49\xf9\x26\xd6\xfc\xac\xdb\x3c\x88\x86\xfb\xcd\x91\ +\xeb\x75\x9b\x4f\x80\x6f\x56\x01\x36\x1e\x77\x0d\xa5\x42\x00\x00\ +\x00\x00\x49\x45\x4e\x44\xae\x42\x60\x82\ \x00\x00\x07\xdd\ \x89\ \x50\x4e\x47\x0d\x0a\x1a\x0a\x00\x00\x00\x0d\x49\x48\x44\x52\x00\ @@ -479,45 +856,6 @@ qt_resource_data = b"\ \x71\x5b\x73\x5c\x40\x48\xa5\xdd\x61\x81\x0d\x9e\x6b\x8e\xff\xfd\ \xcf\x3f\xcc\x31\xe9\x01\x1c\x00\x73\x52\x2d\x71\xe4\x4a\x1b\x69\ \x00\x00\x00\x00\x49\x45\x4e\x44\xae\x42\x60\x82\ -\x00\x00\x00\xa6\ -\x89\ -\x50\x4e\x47\x0d\x0a\x1a\x0a\x00\x00\x00\x0d\x49\x48\x44\x52\x00\ -\x00\x00\x09\x00\x00\x00\x06\x08\x04\x00\x00\x00\xbb\xce\x7c\x4e\ -\x00\x00\x00\x01\x73\x52\x47\x42\x00\xae\xce\x1c\xe9\x00\x00\x00\ -\x02\x62\x4b\x47\x44\x00\xff\x87\x8f\xcc\xbf\x00\x00\x00\x09\x70\ -\x48\x59\x73\x00\x00\x0b\x13\x00\x00\x0b\x13\x01\x00\x9a\x9c\x18\ -\x00\x00\x00\x07\x74\x49\x4d\x45\x07\xdc\x08\x17\x08\x15\x3b\xdc\ -\x3b\x0c\x9b\x00\x00\x00\x2a\x49\x44\x41\x54\x08\xd7\x63\x60\xc0\ -\x00\x8c\x0c\x0c\x73\x3e\x20\x0b\xa4\x08\x30\x32\x30\x20\x0b\xa6\ -\x08\x30\x30\x30\x42\x98\x10\xc1\x14\x01\x14\x13\x50\xb5\xa3\x01\ -\x00\xc6\xb9\x07\x90\x5d\x66\x1f\x83\x00\x00\x00\x00\x49\x45\x4e\ -\x44\xae\x42\x60\x82\ -\x00\x00\x00\xa5\ -\x89\ -\x50\x4e\x47\x0d\x0a\x1a\x0a\x00\x00\x00\x0d\x49\x48\x44\x52\x00\ -\x00\x00\x09\x00\x00\x00\x06\x08\x04\x00\x00\x00\xbb\xce\x7c\x4e\ -\x00\x00\x00\x01\x73\x52\x47\x42\x00\xae\xce\x1c\xe9\x00\x00\x00\ -\x02\x62\x4b\x47\x44\x00\x9c\x53\x34\xfc\x5d\x00\x00\x00\x09\x70\ -\x48\x59\x73\x00\x00\x0b\x13\x00\x00\x0b\x13\x01\x00\x9a\x9c\x18\ -\x00\x00\x00\x07\x74\x49\x4d\x45\x07\xdc\x08\x17\x0b\x02\x04\x6d\ -\x98\x1b\x69\x00\x00\x00\x29\x49\x44\x41\x54\x08\xd7\x63\x60\xc0\ -\x00\x8c\x0c\x0c\xff\xcf\xa3\x08\x18\x32\x32\x30\x20\x0b\x32\x1a\ -\x32\x30\x30\x42\x98\x10\x41\x46\x43\x14\x13\x50\xb5\xa3\x01\x00\ -\xd6\x10\x07\xd2\x2f\x48\xdf\x4a\x00\x00\x00\x00\x49\x45\x4e\x44\ -\xae\x42\x60\x82\ -\x00\x00\x00\xa6\ -\x89\ -\x50\x4e\x47\x0d\x0a\x1a\x0a\x00\x00\x00\x0d\x49\x48\x44\x52\x00\ -\x00\x00\x06\x00\x00\x00\x09\x08\x04\x00\x00\x00\xbb\x93\x95\x16\ -\x00\x00\x00\x01\x73\x52\x47\x42\x00\xae\xce\x1c\xe9\x00\x00\x00\ -\x02\x62\x4b\x47\x44\x00\xff\x87\x8f\xcc\xbf\x00\x00\x00\x09\x70\ -\x48\x59\x73\x00\x00\x0b\x13\x00\x00\x0b\x13\x01\x00\x9a\x9c\x18\ -\x00\x00\x00\x07\x74\x49\x4d\x45\x07\xdc\x08\x17\x14\x1f\x20\xb9\ -\x8d\x77\xe9\x00\x00\x00\x2a\x49\x44\x41\x54\x08\xd7\x63\x60\xc0\ -\x06\xe6\x7c\x60\x60\x60\x42\x30\xa1\x1c\x08\x93\x81\x81\x09\xc1\ -\x64\x60\x60\x62\x60\x48\x11\x40\xe2\x20\x73\x19\x90\x8d\x40\x02\ -\x00\x23\xed\x08\xaf\x64\x9f\x0f\x15\x00\x00\x00\x00\x49\x45\x4e\ -\x44\xae\x42\x60\x82\ \x00\x00\x00\x9e\ \x89\ \x50\x4e\x47\x0d\x0a\x1a\x0a\x00\x00\x00\x0d\x49\x48\x44\x52\x00\ @@ -533,6 +871,334 @@ qt_resource_data = b"\ \x00\x00\x00\xa6\ \x89\ \x50\x4e\x47\x0d\x0a\x1a\x0a\x00\x00\x00\x0d\x49\x48\x44\x52\x00\ +\x00\x00\x06\x00\x00\x00\x09\x08\x04\x00\x00\x00\xbb\x93\x95\x16\ +\x00\x00\x00\x01\x73\x52\x47\x42\x00\xae\xce\x1c\xe9\x00\x00\x00\ +\x02\x62\x4b\x47\x44\x00\xff\x87\x8f\xcc\xbf\x00\x00\x00\x09\x70\ +\x48\x59\x73\x00\x00\x0b\x13\x00\x00\x0b\x13\x01\x00\x9a\x9c\x18\ +\x00\x00\x00\x07\x74\x49\x4d\x45\x07\xdc\x08\x17\x14\x1d\x00\xb0\ +\xd5\x35\xa3\x00\x00\x00\x2a\x49\x44\x41\x54\x08\xd7\x63\x60\xc0\ +\x06\xfe\x9f\x67\x60\x60\x42\x30\xa1\x1c\x08\x93\x81\x81\x09\xc1\ +\x64\x60\x60\x62\x60\x60\x34\x44\xe2\x20\x73\x19\x90\x8d\x40\x02\ +\x00\x64\x40\x09\x75\x86\xb3\xad\x9c\x00\x00\x00\x00\x49\x45\x4e\ +\x44\xae\x42\x60\x82\ +\x00\x00\x00\xa5\ +\x89\ +\x50\x4e\x47\x0d\x0a\x1a\x0a\x00\x00\x00\x0d\x49\x48\x44\x52\x00\ +\x00\x00\x09\x00\x00\x00\x06\x08\x04\x00\x00\x00\xbb\xce\x7c\x4e\ +\x00\x00\x00\x01\x73\x52\x47\x42\x00\xae\xce\x1c\xe9\x00\x00\x00\ +\x02\x62\x4b\x47\x44\x00\x9c\x53\x34\xfc\x5d\x00\x00\x00\x09\x70\ +\x48\x59\x73\x00\x00\x0b\x13\x00\x00\x0b\x13\x01\x00\x9a\x9c\x18\ +\x00\x00\x00\x07\x74\x49\x4d\x45\x07\xdc\x08\x17\x0b\x02\x04\x6d\ +\x98\x1b\x69\x00\x00\x00\x29\x49\x44\x41\x54\x08\xd7\x63\x60\xc0\ +\x00\x8c\x0c\x0c\xff\xcf\xa3\x08\x18\x32\x32\x30\x20\x0b\x32\x1a\ +\x32\x30\x30\x42\x98\x10\x41\x46\x43\x14\x13\x50\xb5\xa3\x01\x00\ +\xd6\x10\x07\xd2\x2f\x48\xdf\x4a\x00\x00\x00\x00\x49\x45\x4e\x44\ +\xae\x42\x60\x82\ +\x00\x00\x00\x9e\ +\x89\ +\x50\x4e\x47\x0d\x0a\x1a\x0a\x00\x00\x00\x0d\x49\x48\x44\x52\x00\ +\x00\x00\x09\x00\x00\x00\x06\x08\x04\x00\x00\x00\xbb\xce\x7c\x4e\ +\x00\x00\x00\x01\x73\x52\x47\x42\x00\xae\xce\x1c\xe9\x00\x00\x00\ +\x02\x62\x4b\x47\x44\x00\xff\x87\x8f\xcc\xbf\x00\x00\x00\x09\x70\ +\x48\x59\x73\x00\x00\x0b\x13\x00\x00\x0b\x13\x01\x00\x9a\x9c\x18\ +\x00\x00\x00\x07\x74\x49\x4d\x45\x07\xdc\x08\x17\x08\x15\x0f\xfd\ +\x8f\xf8\x2e\x00\x00\x00\x22\x49\x44\x41\x54\x08\xd7\x63\x60\xc0\ +\x0d\xfe\x9f\x87\xb1\x18\x91\x05\x18\x0d\xe1\x42\x48\x2a\x0c\x19\ +\x18\x18\x91\x05\x10\x2a\xd1\x00\x00\xca\xb5\x07\xd2\x76\xbb\xb2\ +\xc5\x00\x00\x00\x00\x49\x45\x4e\x44\xae\x42\x60\x82\ +\x00\x00\x01\x57\ +\x89\ +\x50\x4e\x47\x0d\x0a\x1a\x0a\x00\x00\x00\x0d\x49\x48\x44\x52\x00\ +\x00\x00\x30\x00\x00\x00\x30\x08\x06\x00\x00\x00\x57\x02\xf9\x87\ +\x00\x00\x00\x09\x70\x48\x59\x73\x00\x00\x0b\x13\x00\x00\x0b\x13\ +\x01\x00\x9a\x9c\x18\x00\x00\x01\x09\x49\x44\x41\x54\x68\x81\xed\ +\xda\xcd\x6d\xc2\x40\x14\x45\xe1\xf3\x8c\x49\x05\x51\x9a\x48\x36\ +\xec\x59\xd1\x05\xc5\x90\x45\x6a\xa3\x04\x52\x04\x88\x34\x60\x82\ +\x6e\x16\x33\xf9\xb1\xa5\x28\x44\x48\x5c\x5b\x7a\xdf\x8e\xc1\x8b\ +\x77\x8c\xcd\x66\x26\xa8\x24\xdd\x03\xcf\xc0\x12\x78\x02\xe6\x8c\ +\xcb\x09\xd8\x01\x5b\xe0\x25\x22\x8e\x5f\xdf\x48\x5a\x49\xda\x6b\ +\x3a\xf6\x92\x56\x00\xa1\x72\xe7\x5f\x81\x07\xc7\x6d\xbd\xc2\x01\ +\x78\x6c\x28\x8f\xcd\xd4\x86\x87\x32\xf3\xa6\xa5\x3c\xf3\x43\xe7\ +\x1b\x0f\x73\xa9\xd9\xe0\xf3\x32\x24\x75\xf4\x5f\xd8\x73\x44\xb4\ +\x37\x1c\xea\x62\x92\xde\xe9\x47\x9c\x1a\xc6\xf7\x6f\xf3\x1f\xf3\ +\xc6\x3d\xc1\xb5\x32\xc0\x2d\x03\xdc\x32\xc0\x2d\x03\xdc\x32\xc0\ +\x2d\x03\xdc\x32\xc0\x2d\x03\xdc\x32\xc0\x2d\x03\xdc\x32\xc0\x2d\ +\x03\xdc\x32\xc0\x2d\x03\xdc\x32\xc0\x2d\x03\xdc\x32\xc0\xad\xa1\ +\xec\x80\x4f\x55\xd7\x52\xb6\xef\x17\x3f\x16\x67\x75\x37\x70\x8c\ +\x86\xdb\xac\xbb\x96\x72\xf6\x60\xf1\xc7\x85\x63\xb5\x9d\xfe\x51\ +\x83\x7a\xea\x63\x5d\x17\xa6\xe2\x00\xac\x23\xe2\x18\x9f\x2b\xf5\ +\x97\xd8\xf0\x7d\xdc\xe6\xce\x34\xdc\x6f\x3a\xfa\xc7\x6d\xde\x00\ +\x3e\x00\x47\xd7\xea\xb1\xad\x69\xe1\xd6\x00\x00\x00\x00\x49\x45\ +\x4e\x44\xae\x42\x60\x82\ +\x00\x00\x04\x12\ +\x89\ +\x50\x4e\x47\x0d\x0a\x1a\x0a\x00\x00\x00\x0d\x49\x48\x44\x52\x00\ +\x00\x00\x30\x00\x00\x00\x30\x08\x06\x00\x00\x00\x57\x02\xf9\x87\ +\x00\x00\x00\x09\x70\x48\x59\x73\x00\x00\x0b\x13\x00\x00\x0b\x13\ +\x01\x00\x9a\x9c\x18\x00\x00\x03\xc4\x49\x44\x41\x54\x68\x81\xed\ +\x9a\x5f\x88\x94\x55\x18\xc6\x7f\x33\x3b\x1a\x0b\x19\x15\x66\x17\ +\xca\x03\x49\x50\x4d\x09\x4a\x37\x51\x5e\x44\x29\x26\x66\x05\x5b\ +\xb9\xd2\x82\xb1\x50\x17\x91\x24\x74\x11\x08\x7a\xa1\x44\x17\x5d\ +\x54\x94\x04\xd6\x45\xe1\xa2\x15\x4c\xb1\x18\x99\xfd\x25\xd8\x08\ +\x82\x6e\x6a\x5d\xa4\x22\xe2\x81\xa8\x96\xd5\x28\xe8\x9f\xae\xd5\ +\xc5\xf9\xb6\xc6\xd9\xf9\xbe\x73\x66\xc6\x76\x66\xc0\xdf\xdd\x9c\ +\xef\x3d\xef\x79\x9f\x73\xe6\x9c\xf3\x7e\xdf\x39\x25\x32\x46\x6a\ +\x53\x4b\x81\xdd\xc0\x5a\xe0\x3a\x60\x11\xbd\xc5\x69\x60\x12\x98\ +\x00\xf6\x8c\x0d\x55\x67\x00\x4a\x00\x23\xb5\xa9\x5b\x80\x43\xc0\ +\xb2\xae\x85\xd7\x1a\xd3\xc0\xd6\xb1\xa1\xea\x07\xa5\xac\xe7\x8f\ +\xd1\x3f\xc1\xcf\x31\x0d\x5c\x5b\x26\xfc\x6d\xfa\x2d\x78\x08\x31\ +\xef\xaa\x10\xfe\xf3\x8d\x9c\x59\xe0\x60\x52\x19\x68\xf8\xbd\xb6\ +\x42\x98\xb0\xf5\x9c\x19\x1b\xaa\x56\x16\x28\xa0\x96\x18\xa9\x4d\ +\xcd\x72\xb6\x88\x55\x65\x7a\x6f\xb5\x69\x85\x45\xe5\x6e\x47\xd0\ +\x29\xe7\x05\x74\x9b\xf3\x02\xfe\x0f\x6c\xdf\x63\xfb\x90\xed\xf5\ +\x31\xdb\x9e\x13\x60\xfb\x11\xe0\x35\x60\x18\x38\x6a\x7b\x9f\xed\ +\xdc\x95\xb2\xa7\x04\xd8\x1e\x06\x9e\xaa\x2b\x2a\x01\x0f\x01\x2f\ +\xe4\xd5\xe9\x19\x01\xb6\xd7\x01\x2f\x93\x25\x98\x0d\x6c\xb3\xfd\ +\x68\xb3\x7a\x3d\x21\xc0\xf6\xf5\xc0\xeb\xc0\xe2\x02\xb3\x8d\xcd\ +\x0a\xbb\x2e\xc0\xf6\x95\xc0\x5b\xc0\x92\x88\xe9\x8b\xcd\x0a\xbb\ +\x2a\xc0\xf6\xe5\xc0\x51\xe2\xd9\xf0\x93\x92\x5e\x69\xf6\xa0\x6b\ +\x02\x6c\x2f\x01\x8e\x00\x2b\x23\xa6\x07\x80\xc7\xf2\x1e\x76\x45\ +\x80\xed\xc5\xc0\x1b\xc0\x9a\x88\xe9\xdb\xc0\xa8\xa4\xbf\xf3\x0c\ +\x16\x5c\x80\xed\x32\xa1\x57\x6f\x8d\x98\x7e\x0a\xdc\x2d\x69\xb6\ +\xc8\xa8\x1b\x23\xf0\x34\x70\x6f\xc4\xe6\x4b\x60\x93\xa4\x5f\x63\ +\xce\x92\x04\xd8\x2e\x65\x3d\xd7\x11\xb6\x77\x02\xdb\x23\x66\xdf\ +\x03\x1b\x24\xcd\xa4\xf8\x2c\x0c\x2a\x0b\x7c\x07\xf0\x3b\x30\x69\ +\x7b\x55\x52\xa4\xcd\x7d\x8d\x02\x8f\x47\xcc\x7e\x06\x6e\x93\xf4\ +\x6d\xaa\xdf\x5c\x01\x59\xfe\x31\x4e\xd8\xda\x2f\x00\xae\x01\xde\ +\xb3\x7d\x55\xaa\xf3\x3a\x5f\x9b\x81\xfd\x11\xb3\x3f\x81\x3b\x25\ +\x7d\xde\x8a\xef\xa2\x11\x78\x0e\xd8\xdc\x50\xb6\x0c\x78\xdf\xf6\ +\x15\xa9\x0d\xd8\xbe\x11\x78\x95\xf9\x2f\xe4\xf5\xfc\x05\xdc\x27\ +\xe9\xa3\x54\xbf\x73\x34\x15\x90\xf5\xd8\x83\x39\x75\x96\x13\x46\ +\x62\x79\xcc\xb9\xed\x2a\x70\x18\x18\x8c\x98\x3e\x2c\xa9\x16\xf3\ +\xd7\x8c\xbc\x11\xb8\x34\x52\x6f\x25\x41\xc4\x65\x79\x06\xb6\x57\ +\x10\x76\xd9\x98\xaf\xbd\x92\x9e\x8f\xd8\xe4\x92\x27\xe0\x20\x61\ +\x1d\x2e\xe2\x6a\xe0\x1d\xdb\x17\x37\x3e\xb0\x7d\x09\x21\xf8\x15\ +\x11\x1f\xfb\x25\xed\x8e\x46\x59\x40\x53\x01\x92\x4e\x13\xb2\xbf\ +\x2f\x22\xf5\x57\x03\x47\x6c\x5f\x38\x57\x60\x7b\x90\xf0\xb7\xa9\ +\x46\xea\x8e\x13\x72\xfd\x8e\xc8\x9d\xc4\x92\x4e\x02\xeb\x81\xaf\ +\x22\x3e\x6e\x00\x0e\xdb\x1e\xb4\x3d\x40\x98\xb0\x37\x45\xea\x4c\ +\x00\xc3\x92\x3a\xfe\x02\x58\xb8\x0f\x48\xfa\x11\x58\x07\x38\xe2\ +\xe7\x66\xa0\x46\x58\x2a\x1b\x57\xae\x46\x26\x81\x3b\x24\xfd\x91\ +\x18\x63\x21\xd1\xdd\x55\x92\x09\x79\xcb\x0f\x11\xd3\x8d\xc0\x68\ +\xc4\xc6\x84\x8d\xea\xa7\xb4\xf0\xe2\x24\xa5\x07\x92\xbe\x26\x8c\ +\xc4\x89\x0e\xda\x3a\x49\x48\x11\xbe\xeb\xc0\xc7\x3c\x92\xf3\x1b\ +\x49\xc7\x80\x0d\xc0\x2f\x6d\xb4\xf3\x1b\x70\xbb\xa4\xe3\x6d\xd4\ +\x2d\xa4\xa5\x04\x4d\xd2\x67\xc0\xa6\x2c\xa0\x54\x66\x81\x2d\x92\ +\x3e\x69\xa5\xad\x54\x5a\xce\x30\x25\x4d\x00\x77\x11\x72\x97\x14\ +\x1e\x90\xf4\x66\xab\xed\xa4\xd2\x56\x8a\x2c\xe9\x5d\x60\x0b\xa1\ +\x77\x8b\xd8\x29\xe9\xa5\x76\xda\x48\xa5\xed\x1c\x5f\xd2\x38\xb0\ +\x8d\x90\x88\x35\xe3\x59\x49\x4f\xb4\xeb\x3f\x95\x8e\x5e\x52\x24\ +\x1d\x04\xee\x67\xfe\x9c\xd8\x07\xec\xe8\xc4\x77\x2a\x1d\x1f\x25\ +\x49\x3a\x60\xfb\x63\xc2\x06\x76\x11\xf0\x61\x36\x4f\x16\x84\x73\ +\x72\x16\x26\xe9\x1b\xe0\x99\x73\xe1\xab\x55\xca\x84\x13\xf0\x7e\ +\xe5\x54\x85\x90\x9b\xd4\x7f\x9f\x19\xc8\x4e\x03\x7b\x91\xc6\xb7\ +\xba\xc9\x0a\x21\x33\x6c\xfc\xc0\x54\xf4\xfa\xd7\x4b\x4c\x94\x81\ +\x3d\x84\x63\xfb\x7e\x63\x1a\xd8\x5b\xce\x6e\x7d\x6c\xa5\xbf\x44\ +\xcc\x5d\xf6\x98\xf9\xf7\x30\x21\xbb\xf4\xb1\x8b\xff\xae\xdb\x14\ +\x7d\xab\xef\x06\xa7\x38\xfb\xba\xcd\x09\x80\x7f\x00\xc4\x1e\x10\ +\x29\x33\x5b\x85\xf7\x00\x00\x00\x00\x49\x45\x4e\x44\xae\x42\x60\ +\x82\ +\x00\x00\x01\xe1\ +\x89\ +\x50\x4e\x47\x0d\x0a\x1a\x0a\x00\x00\x00\x0d\x49\x48\x44\x52\x00\ +\x00\x00\x30\x00\x00\x00\x30\x08\x06\x00\x00\x00\x57\x02\xf9\x87\ +\x00\x00\x00\x09\x70\x48\x59\x73\x00\x00\x0b\x13\x00\x00\x0b\x13\ +\x01\x00\x9a\x9c\x18\x00\x00\x01\x93\x49\x44\x41\x54\x68\x81\xed\ +\x9a\x3b\x4e\xc3\x40\x10\x86\xbf\x71\x1c\x2a\xe8\x10\xe5\x36\x94\ +\xd0\xa4\xa1\x8a\x28\x22\x0a\x0a\x44\x9f\x9e\x0b\x70\x80\x50\x70\ +\x01\x2e\xc0\x15\x68\x80\x13\xa0\x1c\x21\x50\x91\x66\xbb\x44\xa1\ +\x42\x34\x79\x68\x28\x6c\x1e\xb1\xfc\x48\x08\xc9\xda\xd2\x7e\x9d\ +\x77\x5c\xfc\x9f\xb3\x1e\x39\xda\x11\x62\x54\x75\x17\xb8\x02\x9a\ +\xc0\x21\x50\xa7\x5c\x4c\x80\x1e\xd0\x05\xae\x45\x64\xf4\x5d\x51\ +\xd5\x96\xaa\x0e\xb4\x3a\x0c\x54\xb5\x05\x20\x1a\x3d\xf9\x67\x60\ +\xcf\xc5\x63\x5d\x81\x21\x70\x10\x10\x6d\x9b\xaa\x85\x87\x28\x73\ +\x27\x24\xda\xf3\x49\x66\x1b\x0e\xb3\x28\xb5\xc4\x75\x53\x54\x75\ +\xcc\xfc\x0b\x3b\x13\x91\x70\x83\xa1\x16\x46\x55\xa7\xcc\x4b\x4c\ +\x02\xca\xd7\x6d\x96\xa1\x1e\xb8\x4e\xb0\x2a\x5e\xc0\x35\x5e\xc0\ +\x35\x5e\xc0\x35\x5e\xc0\x35\x5e\xc0\x35\x5e\xc0\x35\x85\x9f\xcd\ +\xd6\xda\x13\xe0\x08\xd8\x5e\x7f\x9c\x39\xa6\xc0\x0b\xf0\x60\x8c\ +\xf9\xc8\xba\x49\x54\x55\x13\x6b\x33\x11\x09\xad\xb5\x21\x70\x07\ +\x9c\xaf\x31\xe4\x22\xf4\x81\x53\x63\xcc\x6b\xca\xff\x81\xdc\x2d\ +\x74\x89\xfb\xf0\x00\xfb\xc0\x6d\x56\x31\x4f\xe0\xec\xff\xb3\xfc\ +\x99\x63\x6b\xed\x4e\x5a\xa1\xf2\x2f\x71\x9e\xc0\xe3\xc6\x52\x14\ +\xf3\x64\x8c\x79\x4f\x2b\xe4\x09\xdc\x00\xf7\xeb\xc9\xb3\x14\x7d\ +\xe0\x22\xab\x98\xd9\x85\xbe\x2e\xca\xd4\x46\xd3\xba\x50\xa1\x40\ +\x99\x58\xb6\x8d\x56\x02\x2f\xe0\x1a\x2f\xe0\x1a\x2f\xe0\x1a\x2f\ +\xe0\x1a\x2f\xe0\x1a\x2f\xe0\x9a\x80\xe8\x04\xbc\xaa\x8c\x43\xa2\ +\xe3\xfb\xc6\xaf\xc5\x5a\xfc\xd9\x5a\x46\x92\xc7\xac\xbd\x90\x68\ +\xf6\xa0\x51\x70\x63\x59\xe9\x56\x7f\xd4\x20\x9e\xfa\x68\xc7\x0b\ +\x55\x61\x08\xb4\x45\x64\x24\x5f\x2b\xf1\x2f\xd1\xe1\x67\xdc\x66\ +\xcb\x51\xb8\x2c\xc6\xcc\x8f\xdb\xbc\x01\x7c\x02\x6d\x77\x23\xb3\ +\xd4\x95\x53\x76\x00\x00\x00\x00\x49\x45\x4e\x44\xae\x42\x60\x82\ +\ +\x00\x00\x01\x76\ +\x89\ +\x50\x4e\x47\x0d\x0a\x1a\x0a\x00\x00\x00\x0d\x49\x48\x44\x52\x00\ +\x00\x00\x30\x00\x00\x00\x30\x08\x06\x00\x00\x00\x57\x02\xf9\x87\ +\x00\x00\x00\x09\x70\x48\x59\x73\x00\x00\x0b\x13\x00\x00\x0b\x13\ +\x01\x00\x9a\x9c\x18\x00\x00\x01\x28\x49\x44\x41\x54\x68\x81\xed\ +\xda\xb1\x4a\xc3\x50\x14\x87\xf1\x2f\x37\xb7\xe0\xae\xf8\x00\x82\ +\x53\x75\xe8\xde\xc9\x6c\x79\x80\x40\x1f\x46\x87\xfa\x22\x6e\x42\ +\xdc\xb3\xc5\xa9\x2f\x20\xb4\x5d\x3a\x74\x0f\x7d\x82\x6a\xc1\xe1\ +\xa6\x50\xb3\x68\x10\xfa\xcf\x85\xf3\xdb\x52\x3a\x9c\xaf\xdc\x66\ +\x39\x37\xa1\x95\xe5\xc5\x15\xf0\x04\x4c\x81\x3b\x60\xc4\xb0\x7c\ +\x02\x4b\x60\x01\xcc\xeb\xaa\xdc\x01\x24\x00\x59\x5e\x3c\x00\xaf\ +\xc0\xb5\x6c\xbc\x7e\x1a\x60\x56\x57\xe5\x7b\xd2\xfe\xf2\x2b\xe2\ +\x19\xfe\xa8\x01\xc6\x8e\x70\x6c\x62\x1b\x1e\xc2\xcc\x8f\x9e\x70\ +\xe6\xbb\x0e\x67\x1e\xe6\xaf\xd2\xce\xf3\xd4\x13\xfe\xb0\xa7\x0e\ +\x75\x55\xfa\x33\x0d\xd4\x4b\x96\x17\x5f\xfc\x8c\xb8\x77\x0c\xef\ +\x6d\xd3\xc7\xc8\xa9\x27\xf8\x2f\x0b\x50\xb3\x00\x35\x0b\x50\xb3\ +\x00\x35\x0b\x50\xb3\x00\x35\x0b\x50\xb3\x00\x35\x0b\x50\xb3\x00\ +\x35\x0b\x50\xb3\x00\x35\x0b\x50\xb3\x00\x35\x0b\x50\xb3\x00\x35\ +\x0b\x50\x73\x84\x0d\x78\xac\xf6\x9e\xb0\xbe\x9f\x9c\x7c\x98\xb6\ +\xdb\xc0\x21\xea\xae\x59\x97\x9e\x70\xf7\x60\xf2\xcb\x17\x87\x6a\ +\xe1\x80\x39\x61\x6d\x1f\x9b\x06\x78\x76\xed\xad\x8f\x19\x71\x45\ +\x1c\x2f\x7b\xec\x52\x80\xed\x66\xb5\xbd\xb9\x1d\xbf\x00\x17\x84\ +\xc5\xf7\x25\xc3\x3b\x46\x7b\xe0\x03\x78\x03\x8a\xba\x2a\xd7\x00\ +\xdf\xa4\xb5\x36\xa2\xca\x99\x74\x47\x00\x00\x00\x00\x49\x45\x4e\ +\x44\xae\x42\x60\x82\ +\x00\x00\x00\xa0\ +\x89\ +\x50\x4e\x47\x0d\x0a\x1a\x0a\x00\x00\x00\x0d\x49\x48\x44\x52\x00\ +\x00\x00\x06\x00\x00\x00\x09\x08\x04\x00\x00\x00\xbb\x93\x95\x16\ +\x00\x00\x00\x01\x73\x52\x47\x42\x00\xae\xce\x1c\xe9\x00\x00\x00\ +\x02\x62\x4b\x47\x44\x00\xff\x87\x8f\xcc\xbf\x00\x00\x00\x09\x70\ +\x48\x59\x73\x00\x00\x0b\x13\x00\x00\x0b\x13\x01\x00\x9a\x9c\x18\ +\x00\x00\x00\x07\x74\x49\x4d\x45\x07\xdc\x08\x17\x14\x1f\x0d\xfc\ +\x52\x2b\x9c\x00\x00\x00\x24\x49\x44\x41\x54\x08\xd7\x63\x60\x40\ +\x05\x73\x3e\xc0\x58\x4c\xc8\x5c\x26\x64\x59\x26\x64\xc5\x70\x4e\ +\x8a\x00\x9c\x93\x22\x80\x61\x1a\x0a\x00\x00\x29\x95\x08\xaf\x88\ +\xac\xba\x34\x00\x00\x00\x00\x49\x45\x4e\x44\xae\x42\x60\x82\ +\x00\x00\x05\x7e\ +\x89\ +\x50\x4e\x47\x0d\x0a\x1a\x0a\x00\x00\x00\x0d\x49\x48\x44\x52\x00\ +\x00\x00\x01\x00\x00\x00\x01\x08\x06\x00\x00\x00\x1f\x15\xc4\x89\ +\x00\x00\x00\x09\x70\x48\x59\x73\x00\x00\x0b\x13\x00\x00\x0b\x13\ +\x01\x00\x9a\x9c\x18\x00\x00\x05\x17\x69\x54\x58\x74\x58\x4d\x4c\ +\x3a\x63\x6f\x6d\x2e\x61\x64\x6f\x62\x65\x2e\x78\x6d\x70\x00\x00\ +\x00\x00\x00\x3c\x3f\x78\x70\x61\x63\x6b\x65\x74\x20\x62\x65\x67\ +\x69\x6e\x3d\x22\xef\xbb\xbf\x22\x20\x69\x64\x3d\x22\x57\x35\x4d\ +\x30\x4d\x70\x43\x65\x68\x69\x48\x7a\x72\x65\x53\x7a\x4e\x54\x63\ +\x7a\x6b\x63\x39\x64\x22\x3f\x3e\x20\x3c\x78\x3a\x78\x6d\x70\x6d\ +\x65\x74\x61\x20\x78\x6d\x6c\x6e\x73\x3a\x78\x3d\x22\x61\x64\x6f\ +\x62\x65\x3a\x6e\x73\x3a\x6d\x65\x74\x61\x2f\x22\x20\x78\x3a\x78\ +\x6d\x70\x74\x6b\x3d\x22\x41\x64\x6f\x62\x65\x20\x58\x4d\x50\x20\ +\x43\x6f\x72\x65\x20\x37\x2e\x31\x2d\x63\x30\x30\x30\x20\x37\x39\ +\x2e\x37\x61\x37\x61\x32\x33\x36\x2c\x20\x32\x30\x32\x31\x2f\x30\ +\x38\x2f\x31\x32\x2d\x30\x30\x3a\x32\x35\x3a\x32\x30\x20\x20\x20\ +\x20\x20\x20\x20\x20\x22\x3e\x20\x3c\x72\x64\x66\x3a\x52\x44\x46\ +\x20\x78\x6d\x6c\x6e\x73\x3a\x72\x64\x66\x3d\x22\x68\x74\x74\x70\ +\x3a\x2f\x2f\x77\x77\x77\x2e\x77\x33\x2e\x6f\x72\x67\x2f\x31\x39\ +\x39\x39\x2f\x30\x32\x2f\x32\x32\x2d\x72\x64\x66\x2d\x73\x79\x6e\ +\x74\x61\x78\x2d\x6e\x73\x23\x22\x3e\x20\x3c\x72\x64\x66\x3a\x44\ +\x65\x73\x63\x72\x69\x70\x74\x69\x6f\x6e\x20\x72\x64\x66\x3a\x61\ +\x62\x6f\x75\x74\x3d\x22\x22\x20\x78\x6d\x6c\x6e\x73\x3a\x78\x6d\ +\x70\x3d\x22\x68\x74\x74\x70\x3a\x2f\x2f\x6e\x73\x2e\x61\x64\x6f\ +\x62\x65\x2e\x63\x6f\x6d\x2f\x78\x61\x70\x2f\x31\x2e\x30\x2f\x22\ +\x20\x78\x6d\x6c\x6e\x73\x3a\x64\x63\x3d\x22\x68\x74\x74\x70\x3a\ +\x2f\x2f\x70\x75\x72\x6c\x2e\x6f\x72\x67\x2f\x64\x63\x2f\x65\x6c\ +\x65\x6d\x65\x6e\x74\x73\x2f\x31\x2e\x31\x2f\x22\x20\x78\x6d\x6c\ +\x6e\x73\x3a\x78\x6d\x70\x4d\x4d\x3d\x22\x68\x74\x74\x70\x3a\x2f\ +\x2f\x6e\x73\x2e\x61\x64\x6f\x62\x65\x2e\x63\x6f\x6d\x2f\x78\x61\ +\x70\x2f\x31\x2e\x30\x2f\x6d\x6d\x2f\x22\x20\x78\x6d\x6c\x6e\x73\ +\x3a\x73\x74\x45\x76\x74\x3d\x22\x68\x74\x74\x70\x3a\x2f\x2f\x6e\ +\x73\x2e\x61\x64\x6f\x62\x65\x2e\x63\x6f\x6d\x2f\x78\x61\x70\x2f\ +\x31\x2e\x30\x2f\x73\x54\x79\x70\x65\x2f\x52\x65\x73\x6f\x75\x72\ +\x63\x65\x45\x76\x65\x6e\x74\x23\x22\x20\x78\x6d\x6c\x6e\x73\x3a\ +\x70\x68\x6f\x74\x6f\x73\x68\x6f\x70\x3d\x22\x68\x74\x74\x70\x3a\ +\x2f\x2f\x6e\x73\x2e\x61\x64\x6f\x62\x65\x2e\x63\x6f\x6d\x2f\x70\ +\x68\x6f\x74\x6f\x73\x68\x6f\x70\x2f\x31\x2e\x30\x2f\x22\x20\x78\ +\x6d\x70\x3a\x43\x72\x65\x61\x74\x6f\x72\x54\x6f\x6f\x6c\x3d\x22\ +\x41\x64\x6f\x62\x65\x20\x50\x68\x6f\x74\x6f\x73\x68\x6f\x70\x20\ +\x32\x32\x2e\x35\x20\x28\x57\x69\x6e\x64\x6f\x77\x73\x29\x22\x20\ +\x78\x6d\x70\x3a\x43\x72\x65\x61\x74\x65\x44\x61\x74\x65\x3d\x22\ +\x32\x30\x32\x31\x2d\x31\x31\x2d\x31\x30\x54\x31\x37\x3a\x33\x39\ +\x3a\x30\x32\x2b\x30\x31\x3a\x30\x30\x22\x20\x78\x6d\x70\x3a\x4d\ +\x65\x74\x61\x64\x61\x74\x61\x44\x61\x74\x65\x3d\x22\x32\x30\x32\ +\x31\x2d\x31\x31\x2d\x31\x30\x54\x31\x37\x3a\x33\x39\x3a\x30\x32\ +\x2b\x30\x31\x3a\x30\x30\x22\x20\x78\x6d\x70\x3a\x4d\x6f\x64\x69\ +\x66\x79\x44\x61\x74\x65\x3d\x22\x32\x30\x32\x31\x2d\x31\x31\x2d\ +\x31\x30\x54\x31\x37\x3a\x33\x39\x3a\x30\x32\x2b\x30\x31\x3a\x30\ +\x30\x22\x20\x64\x63\x3a\x66\x6f\x72\x6d\x61\x74\x3d\x22\x69\x6d\ +\x61\x67\x65\x2f\x70\x6e\x67\x22\x20\x78\x6d\x70\x4d\x4d\x3a\x49\ +\x6e\x73\x74\x61\x6e\x63\x65\x49\x44\x3d\x22\x78\x6d\x70\x2e\x69\ +\x69\x64\x3a\x66\x31\x37\x65\x62\x62\x32\x33\x2d\x65\x36\x32\x61\ +\x2d\x33\x39\x34\x36\x2d\x61\x39\x37\x35\x2d\x64\x34\x66\x36\x61\ +\x62\x34\x34\x64\x34\x30\x39\x22\x20\x78\x6d\x70\x4d\x4d\x3a\x44\ +\x6f\x63\x75\x6d\x65\x6e\x74\x49\x44\x3d\x22\x78\x6d\x70\x2e\x64\ +\x69\x64\x3a\x66\x31\x37\x65\x62\x62\x32\x33\x2d\x65\x36\x32\x61\ +\x2d\x33\x39\x34\x36\x2d\x61\x39\x37\x35\x2d\x64\x34\x66\x36\x61\ +\x62\x34\x34\x64\x34\x30\x39\x22\x20\x78\x6d\x70\x4d\x4d\x3a\x4f\ +\x72\x69\x67\x69\x6e\x61\x6c\x44\x6f\x63\x75\x6d\x65\x6e\x74\x49\ +\x44\x3d\x22\x78\x6d\x70\x2e\x64\x69\x64\x3a\x66\x31\x37\x65\x62\ +\x62\x32\x33\x2d\x65\x36\x32\x61\x2d\x33\x39\x34\x36\x2d\x61\x39\ +\x37\x35\x2d\x64\x34\x66\x36\x61\x62\x34\x34\x64\x34\x30\x39\x22\ +\x20\x70\x68\x6f\x74\x6f\x73\x68\x6f\x70\x3a\x43\x6f\x6c\x6f\x72\ +\x4d\x6f\x64\x65\x3d\x22\x33\x22\x20\x70\x68\x6f\x74\x6f\x73\x68\ +\x6f\x70\x3a\x49\x43\x43\x50\x72\x6f\x66\x69\x6c\x65\x3d\x22\x73\ +\x52\x47\x42\x20\x49\x45\x43\x36\x31\x39\x36\x36\x2d\x32\x2e\x31\ +\x22\x3e\x20\x3c\x78\x6d\x70\x4d\x4d\x3a\x48\x69\x73\x74\x6f\x72\ +\x79\x3e\x20\x3c\x72\x64\x66\x3a\x53\x65\x71\x3e\x20\x3c\x72\x64\ +\x66\x3a\x6c\x69\x20\x73\x74\x45\x76\x74\x3a\x61\x63\x74\x69\x6f\ +\x6e\x3d\x22\x63\x72\x65\x61\x74\x65\x64\x22\x20\x73\x74\x45\x76\ +\x74\x3a\x69\x6e\x73\x74\x61\x6e\x63\x65\x49\x44\x3d\x22\x78\x6d\ +\x70\x2e\x69\x69\x64\x3a\x66\x31\x37\x65\x62\x62\x32\x33\x2d\x65\ +\x36\x32\x61\x2d\x33\x39\x34\x36\x2d\x61\x39\x37\x35\x2d\x64\x34\ +\x66\x36\x61\x62\x34\x34\x64\x34\x30\x39\x22\x20\x73\x74\x45\x76\ +\x74\x3a\x77\x68\x65\x6e\x3d\x22\x32\x30\x32\x31\x2d\x31\x31\x2d\ +\x31\x30\x54\x31\x37\x3a\x33\x39\x3a\x30\x32\x2b\x30\x31\x3a\x30\ +\x30\x22\x20\x73\x74\x45\x76\x74\x3a\x73\x6f\x66\x74\x77\x61\x72\ +\x65\x41\x67\x65\x6e\x74\x3d\x22\x41\x64\x6f\x62\x65\x20\x50\x68\ +\x6f\x74\x6f\x73\x68\x6f\x70\x20\x32\x32\x2e\x35\x20\x28\x57\x69\ +\x6e\x64\x6f\x77\x73\x29\x22\x2f\x3e\x20\x3c\x2f\x72\x64\x66\x3a\ +\x53\x65\x71\x3e\x20\x3c\x2f\x78\x6d\x70\x4d\x4d\x3a\x48\x69\x73\ +\x74\x6f\x72\x79\x3e\x20\x3c\x2f\x72\x64\x66\x3a\x44\x65\x73\x63\ +\x72\x69\x70\x74\x69\x6f\x6e\x3e\x20\x3c\x2f\x72\x64\x66\x3a\x52\ +\x44\x46\x3e\x20\x3c\x2f\x78\x3a\x78\x6d\x70\x6d\x65\x74\x61\x3e\ +\x20\x3c\x3f\x78\x70\x61\x63\x6b\x65\x74\x20\x65\x6e\x64\x3d\x22\ +\x72\x22\x3f\x3e\x07\x62\x0c\x81\x00\x00\x00\x0d\x49\x44\x41\x54\ +\x08\x1d\x63\xf8\xff\xff\x3f\x03\x00\x08\xfc\x02\xfe\xe6\x0c\xff\ +\xab\x00\x00\x00\x00\x49\x45\x4e\x44\xae\x42\x60\x82\ +\x00\x00\x00\x9f\ +\x89\ +\x50\x4e\x47\x0d\x0a\x1a\x0a\x00\x00\x00\x0d\x49\x48\x44\x52\x00\ +\x00\x00\x09\x00\x00\x00\x06\x08\x04\x00\x00\x00\xbb\xce\x7c\x4e\ +\x00\x00\x00\x01\x73\x52\x47\x42\x00\xae\xce\x1c\xe9\x00\x00\x00\ +\x02\x62\x4b\x47\x44\x00\xff\x87\x8f\xcc\xbf\x00\x00\x00\x09\x70\ +\x48\x59\x73\x00\x00\x0b\x13\x00\x00\x0b\x13\x01\x00\x9a\x9c\x18\ +\x00\x00\x00\x07\x74\x49\x4d\x45\x07\xdc\x08\x17\x08\x14\x1f\xf9\ +\x23\xd9\x0b\x00\x00\x00\x23\x49\x44\x41\x54\x08\xd7\x63\x60\xc0\ +\x0d\xe6\x7c\x80\xb1\x18\x91\x05\x52\x04\xe0\x42\x08\x15\x29\x02\ +\x0c\x0c\x8c\xc8\x02\x08\x95\x68\x00\x00\xac\xac\x07\x90\x4e\x65\ +\x34\xac\x00\x00\x00\x00\x49\x45\x4e\x44\xae\x42\x60\x82\ +\x00\x00\x00\xa0\ +\x89\ +\x50\x4e\x47\x0d\x0a\x1a\x0a\x00\x00\x00\x0d\x49\x48\x44\x52\x00\ +\x00\x00\x06\x00\x00\x00\x09\x08\x04\x00\x00\x00\xbb\x93\x95\x16\ +\x00\x00\x00\x01\x73\x52\x47\x42\x00\xae\xce\x1c\xe9\x00\x00\x00\ +\x02\x62\x4b\x47\x44\x00\xff\x87\x8f\xcc\xbf\x00\x00\x00\x09\x70\ +\x48\x59\x73\x00\x00\x0b\x13\x00\x00\x0b\x13\x01\x00\x9a\x9c\x18\ +\x00\x00\x00\x07\x74\x49\x4d\x45\x07\xdc\x08\x17\x14\x1c\x1f\x24\ +\xc6\x09\x17\x00\x00\x00\x24\x49\x44\x41\x54\x08\xd7\x63\x60\x40\ +\x05\xff\xcf\xc3\x58\x4c\xc8\x5c\x26\x64\x59\x26\x64\xc5\x70\x0e\ +\xa3\x21\x9c\xc3\x68\x88\x61\x1a\x0a\x00\x00\x6d\x84\x09\x75\x37\ +\x9e\xd9\x23\x00\x00\x00\x00\x49\x45\x4e\x44\xae\x42\x60\x82\ +\x00\x00\x00\xa6\ +\x89\ +\x50\x4e\x47\x0d\x0a\x1a\x0a\x00\x00\x00\x0d\x49\x48\x44\x52\x00\ +\x00\x00\x06\x00\x00\x00\x09\x08\x04\x00\x00\x00\xbb\x93\x95\x16\ +\x00\x00\x00\x01\x73\x52\x47\x42\x00\xae\xce\x1c\xe9\x00\x00\x00\ +\x02\x62\x4b\x47\x44\x00\xff\x87\x8f\xcc\xbf\x00\x00\x00\x09\x70\ +\x48\x59\x73\x00\x00\x0b\x13\x00\x00\x0b\x13\x01\x00\x9a\x9c\x18\ +\x00\x00\x00\x07\x74\x49\x4d\x45\x07\xdc\x08\x17\x14\x1d\x00\xb0\ +\xd5\x35\xa3\x00\x00\x00\x2a\x49\x44\x41\x54\x08\xd7\x63\x60\xc0\ +\x06\xfe\x9f\x67\x60\x60\x42\x30\xa1\x1c\x08\x93\x81\x81\x09\xc1\ +\x64\x60\x60\x62\x60\x60\x34\x44\xe2\x20\x73\x19\x90\x8d\x40\x02\ +\x00\x64\x40\x09\x75\x86\xb3\xad\x9c\x00\x00\x00\x00\x49\x45\x4e\ +\x44\xae\x42\x60\x82\ +\x00\x00\x00\xa6\ +\x89\ +\x50\x4e\x47\x0d\x0a\x1a\x0a\x00\x00\x00\x0d\x49\x48\x44\x52\x00\ \x00\x00\x09\x00\x00\x00\x06\x08\x04\x00\x00\x00\xbb\xce\x7c\x4e\ \x00\x00\x00\x01\x73\x52\x47\x42\x00\xae\xce\x1c\xe9\x00\x00\x00\ \x02\x62\x4b\x47\x44\x00\xff\x87\x8f\xcc\xbf\x00\x00\x00\x09\x70\ @@ -543,147 +1209,72 @@ qt_resource_data = b"\ \x08\x30\x30\x30\x42\x98\x10\xc1\x14\x01\x14\x13\x50\xb5\xa3\x01\ \x00\xc6\xb9\x07\x90\x5d\x66\x1f\x83\x00\x00\x00\x00\x49\x45\x4e\ \x44\xae\x42\x60\x82\ -\x00\x00\x00\xa6\ +\x00\x00\x03\xff\ \x89\ \x50\x4e\x47\x0d\x0a\x1a\x0a\x00\x00\x00\x0d\x49\x48\x44\x52\x00\ -\x00\x00\x06\x00\x00\x00\x09\x08\x04\x00\x00\x00\xbb\x93\x95\x16\ -\x00\x00\x00\x01\x73\x52\x47\x42\x00\xae\xce\x1c\xe9\x00\x00\x00\ -\x02\x62\x4b\x47\x44\x00\xff\x87\x8f\xcc\xbf\x00\x00\x00\x09\x70\ -\x48\x59\x73\x00\x00\x0b\x13\x00\x00\x0b\x13\x01\x00\x9a\x9c\x18\ -\x00\x00\x00\x07\x74\x49\x4d\x45\x07\xdc\x08\x17\x14\x1d\x00\xb0\ -\xd5\x35\xa3\x00\x00\x00\x2a\x49\x44\x41\x54\x08\xd7\x63\x60\xc0\ -\x06\xfe\x9f\x67\x60\x60\x42\x30\xa1\x1c\x08\x93\x81\x81\x09\xc1\ -\x64\x60\x60\x62\x60\x60\x34\x44\xe2\x20\x73\x19\x90\x8d\x40\x02\ -\x00\x64\x40\x09\x75\x86\xb3\xad\x9c\x00\x00\x00\x00\x49\x45\x4e\ -\x44\xae\x42\x60\x82\ -\x00\x00\x00\xa6\ -\x89\ -\x50\x4e\x47\x0d\x0a\x1a\x0a\x00\x00\x00\x0d\x49\x48\x44\x52\x00\ -\x00\x00\x06\x00\x00\x00\x09\x08\x04\x00\x00\x00\xbb\x93\x95\x16\ -\x00\x00\x00\x01\x73\x52\x47\x42\x00\xae\xce\x1c\xe9\x00\x00\x00\ -\x02\x62\x4b\x47\x44\x00\xff\x87\x8f\xcc\xbf\x00\x00\x00\x09\x70\ -\x48\x59\x73\x00\x00\x0b\x13\x00\x00\x0b\x13\x01\x00\x9a\x9c\x18\ -\x00\x00\x00\x07\x74\x49\x4d\x45\x07\xdc\x08\x17\x14\x1d\x00\xb0\ -\xd5\x35\xa3\x00\x00\x00\x2a\x49\x44\x41\x54\x08\xd7\x63\x60\xc0\ -\x06\xfe\x9f\x67\x60\x60\x42\x30\xa1\x1c\x08\x93\x81\x81\x09\xc1\ -\x64\x60\x60\x62\x60\x60\x34\x44\xe2\x20\x73\x19\x90\x8d\x40\x02\ -\x00\x64\x40\x09\x75\x86\xb3\xad\x9c\x00\x00\x00\x00\x49\x45\x4e\ -\x44\xae\x42\x60\x82\ -\x00\x00\x07\x06\ -\x89\ -\x50\x4e\x47\x0d\x0a\x1a\x0a\x00\x00\x00\x0d\x49\x48\x44\x52\x00\ -\x00\x00\x0a\x00\x00\x00\x07\x08\x06\x00\x00\x00\x31\xac\xdc\x63\ -\x00\x00\x04\xb0\x69\x54\x58\x74\x58\x4d\x4c\x3a\x63\x6f\x6d\x2e\ -\x61\x64\x6f\x62\x65\x2e\x78\x6d\x70\x00\x00\x00\x00\x00\x3c\x3f\ -\x78\x70\x61\x63\x6b\x65\x74\x20\x62\x65\x67\x69\x6e\x3d\x22\xef\ -\xbb\xbf\x22\x20\x69\x64\x3d\x22\x57\x35\x4d\x30\x4d\x70\x43\x65\ -\x68\x69\x48\x7a\x72\x65\x53\x7a\x4e\x54\x63\x7a\x6b\x63\x39\x64\ -\x22\x3f\x3e\x0a\x3c\x78\x3a\x78\x6d\x70\x6d\x65\x74\x61\x20\x78\ -\x6d\x6c\x6e\x73\x3a\x78\x3d\x22\x61\x64\x6f\x62\x65\x3a\x6e\x73\ -\x3a\x6d\x65\x74\x61\x2f\x22\x20\x78\x3a\x78\x6d\x70\x74\x6b\x3d\ -\x22\x58\x4d\x50\x20\x43\x6f\x72\x65\x20\x35\x2e\x35\x2e\x30\x22\ -\x3e\x0a\x20\x3c\x72\x64\x66\x3a\x52\x44\x46\x20\x78\x6d\x6c\x6e\ -\x73\x3a\x72\x64\x66\x3d\x22\x68\x74\x74\x70\x3a\x2f\x2f\x77\x77\ -\x77\x2e\x77\x33\x2e\x6f\x72\x67\x2f\x31\x39\x39\x39\x2f\x30\x32\ -\x2f\x32\x32\x2d\x72\x64\x66\x2d\x73\x79\x6e\x74\x61\x78\x2d\x6e\ -\x73\x23\x22\x3e\x0a\x20\x20\x3c\x72\x64\x66\x3a\x44\x65\x73\x63\ -\x72\x69\x70\x74\x69\x6f\x6e\x20\x72\x64\x66\x3a\x61\x62\x6f\x75\ -\x74\x3d\x22\x22\x0a\x20\x20\x20\x20\x78\x6d\x6c\x6e\x73\x3a\x65\ -\x78\x69\x66\x3d\x22\x68\x74\x74\x70\x3a\x2f\x2f\x6e\x73\x2e\x61\ -\x64\x6f\x62\x65\x2e\x63\x6f\x6d\x2f\x65\x78\x69\x66\x2f\x31\x2e\ -\x30\x2f\x22\x0a\x20\x20\x20\x20\x78\x6d\x6c\x6e\x73\x3a\x74\x69\ -\x66\x66\x3d\x22\x68\x74\x74\x70\x3a\x2f\x2f\x6e\x73\x2e\x61\x64\ -\x6f\x62\x65\x2e\x63\x6f\x6d\x2f\x74\x69\x66\x66\x2f\x31\x2e\x30\ -\x2f\x22\x0a\x20\x20\x20\x20\x78\x6d\x6c\x6e\x73\x3a\x70\x68\x6f\ -\x74\x6f\x73\x68\x6f\x70\x3d\x22\x68\x74\x74\x70\x3a\x2f\x2f\x6e\ -\x73\x2e\x61\x64\x6f\x62\x65\x2e\x63\x6f\x6d\x2f\x70\x68\x6f\x74\ -\x6f\x73\x68\x6f\x70\x2f\x31\x2e\x30\x2f\x22\x0a\x20\x20\x20\x20\ -\x78\x6d\x6c\x6e\x73\x3a\x78\x6d\x70\x3d\x22\x68\x74\x74\x70\x3a\ -\x2f\x2f\x6e\x73\x2e\x61\x64\x6f\x62\x65\x2e\x63\x6f\x6d\x2f\x78\ -\x61\x70\x2f\x31\x2e\x30\x2f\x22\x0a\x20\x20\x20\x20\x78\x6d\x6c\ -\x6e\x73\x3a\x78\x6d\x70\x4d\x4d\x3d\x22\x68\x74\x74\x70\x3a\x2f\ -\x2f\x6e\x73\x2e\x61\x64\x6f\x62\x65\x2e\x63\x6f\x6d\x2f\x78\x61\ -\x70\x2f\x31\x2e\x30\x2f\x6d\x6d\x2f\x22\x0a\x20\x20\x20\x20\x78\ -\x6d\x6c\x6e\x73\x3a\x73\x74\x45\x76\x74\x3d\x22\x68\x74\x74\x70\ -\x3a\x2f\x2f\x6e\x73\x2e\x61\x64\x6f\x62\x65\x2e\x63\x6f\x6d\x2f\ -\x78\x61\x70\x2f\x31\x2e\x30\x2f\x73\x54\x79\x70\x65\x2f\x52\x65\ -\x73\x6f\x75\x72\x63\x65\x45\x76\x65\x6e\x74\x23\x22\x0a\x20\x20\ -\x20\x65\x78\x69\x66\x3a\x50\x69\x78\x65\x6c\x58\x44\x69\x6d\x65\ -\x6e\x73\x69\x6f\x6e\x3d\x22\x31\x30\x22\x0a\x20\x20\x20\x65\x78\ -\x69\x66\x3a\x50\x69\x78\x65\x6c\x59\x44\x69\x6d\x65\x6e\x73\x69\ -\x6f\x6e\x3d\x22\x37\x22\x0a\x20\x20\x20\x65\x78\x69\x66\x3a\x43\ -\x6f\x6c\x6f\x72\x53\x70\x61\x63\x65\x3d\x22\x31\x22\x0a\x20\x20\ -\x20\x74\x69\x66\x66\x3a\x49\x6d\x61\x67\x65\x57\x69\x64\x74\x68\ -\x3d\x22\x31\x30\x22\x0a\x20\x20\x20\x74\x69\x66\x66\x3a\x49\x6d\ -\x61\x67\x65\x4c\x65\x6e\x67\x74\x68\x3d\x22\x37\x22\x0a\x20\x20\ -\x20\x74\x69\x66\x66\x3a\x52\x65\x73\x6f\x6c\x75\x74\x69\x6f\x6e\ -\x55\x6e\x69\x74\x3d\x22\x32\x22\x0a\x20\x20\x20\x74\x69\x66\x66\ -\x3a\x58\x52\x65\x73\x6f\x6c\x75\x74\x69\x6f\x6e\x3d\x22\x37\x32\ -\x2e\x30\x22\x0a\x20\x20\x20\x74\x69\x66\x66\x3a\x59\x52\x65\x73\ -\x6f\x6c\x75\x74\x69\x6f\x6e\x3d\x22\x37\x32\x2e\x30\x22\x0a\x20\ -\x20\x20\x70\x68\x6f\x74\x6f\x73\x68\x6f\x70\x3a\x43\x6f\x6c\x6f\ -\x72\x4d\x6f\x64\x65\x3d\x22\x33\x22\x0a\x20\x20\x20\x70\x68\x6f\ -\x74\x6f\x73\x68\x6f\x70\x3a\x49\x43\x43\x50\x72\x6f\x66\x69\x6c\ -\x65\x3d\x22\x73\x52\x47\x42\x20\x49\x45\x43\x36\x31\x39\x36\x36\ -\x2d\x32\x2e\x31\x22\x0a\x20\x20\x20\x78\x6d\x70\x3a\x4d\x6f\x64\ -\x69\x66\x79\x44\x61\x74\x65\x3d\x22\x32\x30\x32\x31\x2d\x30\x35\ -\x2d\x33\x31\x54\x31\x32\x3a\x33\x30\x3a\x31\x31\x2b\x30\x32\x3a\ -\x30\x30\x22\x0a\x20\x20\x20\x78\x6d\x70\x3a\x4d\x65\x74\x61\x64\ -\x61\x74\x61\x44\x61\x74\x65\x3d\x22\x32\x30\x32\x31\x2d\x30\x35\ -\x2d\x33\x31\x54\x31\x32\x3a\x33\x30\x3a\x31\x31\x2b\x30\x32\x3a\ -\x30\x30\x22\x3e\x0a\x20\x20\x20\x3c\x78\x6d\x70\x4d\x4d\x3a\x48\ -\x69\x73\x74\x6f\x72\x79\x3e\x0a\x20\x20\x20\x20\x3c\x72\x64\x66\ -\x3a\x53\x65\x71\x3e\x0a\x20\x20\x20\x20\x20\x3c\x72\x64\x66\x3a\ -\x6c\x69\x0a\x20\x20\x20\x20\x20\x20\x73\x74\x45\x76\x74\x3a\x61\ -\x63\x74\x69\x6f\x6e\x3d\x22\x70\x72\x6f\x64\x75\x63\x65\x64\x22\ -\x0a\x20\x20\x20\x20\x20\x20\x73\x74\x45\x76\x74\x3a\x73\x6f\x66\ -\x74\x77\x61\x72\x65\x41\x67\x65\x6e\x74\x3d\x22\x41\x66\x66\x69\ -\x6e\x69\x74\x79\x20\x44\x65\x73\x69\x67\x6e\x65\x72\x20\x31\x2e\ -\x39\x2e\x32\x22\x0a\x20\x20\x20\x20\x20\x20\x73\x74\x45\x76\x74\ -\x3a\x77\x68\x65\x6e\x3d\x22\x32\x30\x32\x31\x2d\x30\x35\x2d\x33\ -\x31\x54\x31\x32\x3a\x33\x30\x3a\x31\x31\x2b\x30\x32\x3a\x30\x30\ -\x22\x2f\x3e\x0a\x20\x20\x20\x20\x3c\x2f\x72\x64\x66\x3a\x53\x65\ -\x71\x3e\x0a\x20\x20\x20\x3c\x2f\x78\x6d\x70\x4d\x4d\x3a\x48\x69\ -\x73\x74\x6f\x72\x79\x3e\x0a\x20\x20\x3c\x2f\x72\x64\x66\x3a\x44\ -\x65\x73\x63\x72\x69\x70\x74\x69\x6f\x6e\x3e\x0a\x20\x3c\x2f\x72\ -\x64\x66\x3a\x52\x44\x46\x3e\x0a\x3c\x2f\x78\x3a\x78\x6d\x70\x6d\ -\x65\x74\x61\x3e\x0a\x3c\x3f\x78\x70\x61\x63\x6b\x65\x74\x20\x65\ -\x6e\x64\x3d\x22\x72\x22\x3f\x3e\x85\x9d\x9f\x08\x00\x00\x01\x83\ -\x69\x43\x43\x50\x73\x52\x47\x42\x20\x49\x45\x43\x36\x31\x39\x36\ -\x36\x2d\x32\x2e\x31\x00\x00\x28\x91\x75\x91\xcf\x2b\x44\x51\x14\ -\xc7\x3f\x66\x68\xfc\x18\x8d\x62\x61\x31\x65\x12\x16\x42\x83\x12\ -\x1b\x8b\x99\x18\x0a\x8b\x99\x51\x7e\x6d\x66\x9e\x79\x33\x6a\xde\ -\x78\xbd\x37\xd2\x64\xab\x6c\xa7\x28\xb1\xf1\x6b\xc1\x5f\xc0\x56\ -\x59\x2b\x45\xa4\x64\xa7\xac\x89\x0d\x7a\xce\x9b\x51\x23\x99\x73\ -\x3b\xf7\x7c\xee\xf7\xde\x73\xba\xf7\x5c\x70\x44\xd3\x8a\x66\x56\ -\xfa\x41\xcb\x64\x8d\x70\x28\xe0\x9b\x99\x9d\xf3\xb9\x9e\xa8\xa2\ -\x85\x1a\x3a\xf1\xc6\x14\x53\x9f\x8c\x8c\x46\x29\x6b\xef\xb7\x54\ -\xd8\xf1\xba\xdb\xae\x55\xfe\xdc\xbf\x56\xb7\x98\x30\x15\xa8\xa8\ -\x16\x1e\x56\x74\x23\x2b\x3c\x26\x3c\xb1\x9a\xd5\x6d\xde\x12\x6e\ -\x52\x52\xb1\x45\xe1\x13\xe1\x2e\x43\x2e\x28\x7c\x63\xeb\xf1\x22\ -\x3f\xdb\x9c\x2c\xf2\xa7\xcd\x46\x34\x1c\x04\x47\x83\xb0\x2f\xf9\ -\x8b\xe3\xbf\x58\x49\x19\x9a\xb0\xbc\x9c\x36\x2d\xbd\xa2\xfc\xdc\ -\xc7\x7e\x89\x3b\x91\x99\x8e\x48\x6c\x15\xf7\x62\x12\x26\x44\x00\ -\x1f\xe3\x8c\x10\x64\x80\x5e\x86\x64\x1e\xa0\x9b\x3e\x7a\x64\x45\ -\x99\x7c\x7f\x21\x7f\x8a\x65\xc9\x55\x64\xd6\xc9\x61\xb0\x44\x92\ -\x14\x59\xba\x44\x5d\x91\xea\x09\x89\xaa\xe8\x09\x19\x69\x72\x76\ -\xff\xff\xf6\xd5\x54\xfb\xfb\x8a\xd5\xdd\x01\xa8\x7a\xb4\xac\xd7\ -\x76\x70\x6d\xc2\x57\xde\xb2\x3e\x0e\x2c\xeb\xeb\x10\x9c\x0f\x70\ -\x9e\x29\xe5\x2f\xef\xc3\xe0\x9b\xe8\xf9\x92\xd6\xb6\x07\x9e\x75\ -\x38\xbd\x28\x69\xf1\x6d\x38\xdb\x80\xe6\x7b\x3d\x66\xc4\x0a\x92\ -\x53\xdc\xa1\xaa\xf0\x72\x0c\xf5\xb3\xd0\x78\x05\xb5\xf3\xc5\x9e\ -\xfd\xec\x73\x74\x07\xd1\x35\xf9\xaa\x4b\xd8\xd9\x85\x0e\x39\xef\ -\x59\xf8\x06\x8e\xfd\x67\xf8\xfd\x8a\x18\x97\x00\x00\x00\x09\x70\ -\x48\x59\x73\x00\x00\x0b\x13\x00\x00\x0b\x13\x01\x00\x9a\x9c\x18\ -\x00\x00\x00\x6d\x49\x44\x41\x54\x18\x95\x75\xcf\xc1\x09\xc2\x50\ -\x10\x84\xe1\xd7\x85\x07\x9b\xd0\x43\x40\xd2\x82\x78\x14\x7b\x30\ -\x57\x21\x8d\x84\x60\x3f\x62\x4b\x7a\x48\xcc\x97\x83\xfb\x30\x04\ -\xdf\x9c\x86\x7f\x67\x99\xdd\x84\x0d\xaa\x54\x10\x6a\x6c\x13\x1e\ -\xbe\xba\xfe\x09\x35\x31\x7b\xe6\x8d\x0f\x26\x1c\x17\xa1\x53\xb0\ -\x11\x87\x0c\x2f\x01\x07\xec\xb0\x0f\x3f\xe1\xbc\xae\x69\xa3\xe6\ -\x85\x77\xf8\x5b\xe9\xf0\xbb\x9f\xfa\xd2\x83\x39\xdc\xa3\x5b\xf3\ -\x19\x2e\xa8\x89\xb5\x30\xf7\x43\xa0\x00\x00\x00\x00\x49\x45\x4e\ -\x44\xae\x42\x60\x82\ +\x00\x00\x30\x00\x00\x00\x30\x08\x06\x00\x00\x00\x57\x02\xf9\x87\ +\x00\x00\x00\x09\x70\x48\x59\x73\x00\x00\x0b\x13\x00\x00\x0b\x13\ +\x01\x00\x9a\x9c\x18\x00\x00\x03\xb1\x49\x44\x41\x54\x68\x81\xed\ +\x9a\x4f\x68\x1e\x45\x18\xc6\x7f\x9b\x26\x85\x82\x15\x15\xab\x42\ +\xcb\x03\x06\x05\xa9\x0a\x8a\xb7\x52\x3c\xd4\x96\xaa\xb5\xe0\x41\ +\xad\xc5\x43\x25\xa0\x07\x51\xcc\x4d\x28\xb4\x07\x45\x3c\x78\xb0\ +\xa0\x52\x50\x0f\x8a\x50\xf5\x50\xa5\x28\xc6\xbf\xa8\x34\x20\x08\ +\x9e\x4c\x41\xc5\x83\x3c\x20\xd2\x90\x2a\x0a\xfe\x69\x92\x3a\x1e\ +\x66\x8d\x5f\xd6\xdd\x9d\xfd\xf6\x8b\xd9\x2f\xd0\xdf\xed\x9b\x79\ +\xe7\x9d\xe7\x9d\xd9\x99\x79\x67\xbf\xcd\xc8\x09\x21\x5c\x0a\x1c\ +\x06\xb6\x03\xd7\x01\x63\x0c\x17\x0b\xc0\x0c\x30\x0d\x3c\x9e\x65\ +\xd9\xdc\x52\x4d\x08\x61\x47\x08\xe1\x74\x58\x3b\x9c\x0e\x21\xec\ +\x00\xc8\x42\x1c\xf9\x53\xc0\x65\x5d\x0c\xeb\x00\xcc\x02\xd7\x8e\ +\x10\x1f\x9b\xb5\x26\x1e\xa2\xe6\x43\xa3\xc4\x67\xbe\xc8\xb9\x55\ +\x16\xd3\x94\x75\x85\xdf\xdb\xb3\x10\xc2\x3c\xcb\x17\xec\xb9\x2c\ +\xcb\x46\x57\x51\x54\x63\x42\x08\x8b\x2c\x0f\x62\x61\x84\xe1\xdb\ +\x6d\xfa\x61\x6c\xa4\x6b\x05\x83\x72\x3e\x80\xae\x39\x1f\xc0\xff\ +\x81\xed\xbb\x6d\xbf\x66\x7b\x57\xca\x36\x0b\x21\x84\x42\x59\xa7\ +\xdb\xa8\xed\x47\x81\x23\xf9\xcf\x00\x1c\x05\x26\x25\x2d\x94\x6c\ +\xa3\xc3\x35\x03\xb6\xef\x05\x9e\xe9\x29\xca\x80\x87\x80\x17\xab\ +\xda\x0c\xcd\x81\x65\x7b\x27\xf0\x0a\x51\x74\x91\x03\xb6\xbf\x2a\ +\x6b\x37\x14\x33\x60\xfb\x26\xe0\x4d\x60\x7d\x8d\xd9\x6d\x65\x85\ +\x9d\x07\x60\xfb\x2a\xe0\x5d\x60\x63\xc2\xf4\xa5\xb2\xc2\x4e\x03\ +\xb0\x7d\x39\xf0\x3e\xe9\x6c\xf8\x69\x49\xaf\x97\x55\x74\x16\x80\ +\xed\x8d\xc0\x14\x30\x9e\x30\x7d\x15\x78\xac\xaa\xb2\x93\x00\x6c\ +\xaf\x07\xde\x02\x6e\x4c\x98\xbe\x07\x4c\x48\x2a\x6e\xf5\x4b\xac\ +\x7a\x00\xb6\x47\x88\xa3\x7a\x4b\xc2\xf4\x0b\xe0\x2e\x49\x8b\x75\ +\x46\x5d\xcc\xc0\x11\xe0\x9e\x84\xcd\xb7\xc0\x1e\x49\xbf\xa5\x9c\ +\x35\x0a\xc0\x76\x96\x8f\xdc\x40\xd8\x3e\x08\x3c\x92\x30\xfb\x11\ +\xd8\x2d\x69\x2e\x61\x07\x24\x02\xc8\x85\x4f\x02\x7f\x00\x33\xb6\ +\xaf\x6f\xa4\xb4\xdc\xd7\x04\xf0\x64\xc2\xec\x17\xe0\x56\x49\xdf\ +\x37\xf5\x5b\x99\x0b\xd9\x1e\x03\x8e\x03\x7b\x7b\xea\x66\x81\x9b\ +\x25\x7d\xd3\xb4\x03\x00\xdb\x7b\x89\x8b\xb6\x78\xa7\xed\xe5\x2c\ +\x71\xe4\x3f\xab\x32\xe8\x37\x17\x7a\x8e\xe5\xe2\x21\xee\xd7\x1f\ +\xdb\xbe\xb2\x56\x71\x0f\xb6\xb7\x01\x6f\x14\x3b\x2e\xf0\x17\x70\ +\x5f\x9d\xf8\x2a\x4a\x03\xc8\x47\xec\xc1\x8a\x36\x9b\x81\x8f\x6c\ +\x6f\x4e\x39\xb7\xbd\x15\x78\x1b\xd8\x90\x30\x7d\x58\xd2\xf1\x94\ +\xbf\x32\xaa\x66\xe0\x92\x44\xbb\x71\x62\x10\x9b\xaa\x0c\x6c\x6f\ +\x21\x9e\xb2\x29\x5f\x4f\x48\x3a\x9a\xb0\xa9\xa4\x2a\x80\x63\xc4\ +\x7d\xb8\x8e\x6b\x80\x0f\x6c\x5f\x54\xac\xb0\x7d\x31\x51\xfc\x96\ +\x84\x8f\x17\x24\x1d\x4e\xaa\xac\xa1\x34\x00\x49\x0b\xc4\xec\xaf\ +\x34\x85\xed\xe1\x06\x60\xca\xf6\x05\xff\x14\xd8\xde\x40\x7c\x6c\ +\xb6\x26\xda\x9e\x20\xe6\xfa\x03\x51\x7b\x23\xcb\x93\xad\x93\xc0\ +\xd5\x09\x3f\x9f\x02\xb7\x03\xf3\xc4\xdd\xa6\xb8\xf8\x8b\x4c\x03\ +\xbb\x24\xfd\xd9\x8f\xd8\xb2\x5d\x28\x79\xa5\xb4\x2d\x62\x10\x4a\ +\xf8\x9f\x22\x1e\x42\x13\x09\xbb\x19\xe2\x56\xfc\x73\x23\xd5\x3d\ +\xb4\x0a\x00\x96\x72\xf6\x93\xc0\x15\xfd\x76\x5a\xc0\xc0\x36\x49\ +\x3f\xb4\x69\xdc\xfa\x4e\x2c\xe9\x3b\x60\x27\x70\xa6\x4d\xc7\x39\ +\x3f\x11\x0f\xaa\x56\xe2\xab\x68\x9c\xdf\x48\x3a\x05\xec\x06\x7e\ +\x6d\xd1\xcf\xef\xc0\x1d\x92\xbe\x6e\xd1\xb6\x96\xbe\x12\x34\x49\ +\x5f\x02\x7b\x72\x41\x4d\x59\x04\xf6\x49\xfa\xbc\x9f\xbe\x9a\xd2\ +\x77\x86\x29\x69\x1a\xb8\x93\x98\xbb\x34\xe1\x01\x49\xef\xf4\xdb\ +\x4f\x53\x5a\xa5\xc8\x92\x3e\x04\xf6\x11\x47\xb7\x8e\x83\x92\x5e\ +\x6e\xd3\x47\x53\x5a\xe7\xf8\x92\x4e\x00\x07\x88\x89\x58\x19\xcf\ +\x4a\x7a\xaa\xad\xff\xa6\x0c\x74\x49\x91\x74\x0c\xb8\x9f\xff\xae\ +\x89\xe7\x81\xc9\x41\x7c\x37\x65\x45\xde\x8d\xda\x1e\x27\x9e\xbe\ +\x17\x02\x9f\xe4\xeb\x64\xc5\x69\x7d\x90\x0d\x0b\x55\x07\xd9\x42\ +\x37\x72\x56\x84\xf9\x51\x62\x6e\xd2\xfb\x7e\x66\x5d\x1e\xe9\x30\ +\x52\xbc\xd5\xcd\x8c\x12\x33\xc3\xe2\x0b\xa6\xba\xeb\xdf\x30\x31\ +\xbd\xf6\x3f\x35\xc8\xbf\xfa\xd8\x9f\x17\xac\x15\x66\x81\xfd\x59\ +\x96\xcd\x2d\xfd\x99\x90\xcf\xc4\x21\xfe\xfd\xdc\xa6\xee\x5d\x7d\ +\x17\xcc\xb3\xfc\x73\x9b\x33\x00\x7f\x03\xd9\x1a\xfb\xdb\xbb\xa7\ +\x8f\x07\x00\x00\x00\x00\x49\x45\x4e\x44\xae\x42\x60\x82\ " qt_resource_name = b"\ @@ -695,120 +1286,198 @@ qt_resource_name = b"\ \x07\x03\x7d\xc3\ \x00\x69\ \x00\x6d\x00\x61\x00\x67\x00\x65\x00\x73\ -\x00\x0f\ -\x02\x9f\x05\x87\ -\x00\x72\ -\x00\x69\x00\x67\x00\x68\x00\x74\x00\x5f\x00\x61\x00\x72\x00\x72\x00\x6f\x00\x77\x00\x2e\x00\x70\x00\x6e\x00\x67\ -\x00\x12\ -\x05\x8f\x9d\x07\ -\x00\x62\ -\x00\x72\x00\x61\x00\x6e\x00\x63\x00\x68\x00\x5f\x00\x6f\x00\x70\x00\x65\x00\x6e\x00\x5f\x00\x6f\x00\x6e\x00\x2e\x00\x70\x00\x6e\ -\x00\x67\ -\x00\x1b\ -\x03\x5a\x32\x27\ -\x00\x63\ -\x00\x6f\x00\x6d\x00\x62\x00\x6f\x00\x62\x00\x6f\x00\x78\x00\x5f\x00\x61\x00\x72\x00\x72\x00\x6f\x00\x77\x00\x5f\x00\x64\x00\x69\ -\x00\x73\x00\x61\x00\x62\x00\x6c\x00\x65\x00\x64\x00\x2e\x00\x70\x00\x6e\x00\x67\ -\x00\x18\ -\x03\x8e\xde\x67\ -\x00\x72\ -\x00\x69\x00\x67\x00\x68\x00\x74\x00\x5f\x00\x61\x00\x72\x00\x72\x00\x6f\x00\x77\x00\x5f\x00\x64\x00\x69\x00\x73\x00\x61\x00\x62\ -\x00\x6c\x00\x65\x00\x64\x00\x2e\x00\x70\x00\x6e\x00\x67\ \x00\x11\ \x0b\xda\x30\xa7\ \x00\x62\ \x00\x72\x00\x61\x00\x6e\x00\x63\x00\x68\x00\x5f\x00\x63\x00\x6c\x00\x6f\x00\x73\x00\x65\x00\x64\x00\x2e\x00\x70\x00\x6e\x00\x67\ \ -\x00\x12\ -\x03\x8d\x04\x47\ -\x00\x72\ -\x00\x69\x00\x67\x00\x68\x00\x74\x00\x5f\x00\x61\x00\x72\x00\x72\x00\x6f\x00\x77\x00\x5f\x00\x6f\x00\x6e\x00\x2e\x00\x70\x00\x6e\ -\x00\x67\ -\x00\x15\ -\x0f\xf3\xc0\x07\ -\x00\x75\ -\x00\x70\x00\x5f\x00\x61\x00\x72\x00\x72\x00\x6f\x00\x77\x00\x5f\x00\x64\x00\x69\x00\x73\x00\x61\x00\x62\x00\x6c\x00\x65\x00\x64\ -\x00\x2e\x00\x70\x00\x6e\x00\x67\ \x00\x0f\ -\x01\x73\x8b\x07\ -\x00\x75\ -\x00\x70\x00\x5f\x00\x61\x00\x72\x00\x72\x00\x6f\x00\x77\x00\x5f\x00\x6f\x00\x6e\x00\x2e\x00\x70\x00\x6e\x00\x67\ +\x06\x53\x25\xa7\ +\x00\x62\ +\x00\x72\x00\x61\x00\x6e\x00\x63\x00\x68\x00\x5f\x00\x6f\x00\x70\x00\x65\x00\x6e\x00\x2e\x00\x70\x00\x6e\x00\x67\ +\x00\x1a\ +\x01\x87\xae\x67\ +\x00\x63\ +\x00\x68\x00\x65\x00\x63\x00\x6b\x00\x62\x00\x6f\x00\x78\x00\x5f\x00\x69\x00\x6e\x00\x64\x00\x65\x00\x74\x00\x65\x00\x72\x00\x6d\ +\x00\x69\x00\x6e\x00\x61\x00\x74\x00\x65\x00\x2e\x00\x70\x00\x6e\x00\x67\ +\x00\x20\ +\x0f\xd4\x1b\xc7\ +\x00\x63\ +\x00\x68\x00\x65\x00\x63\x00\x6b\x00\x62\x00\x6f\x00\x78\x00\x5f\x00\x69\x00\x6e\x00\x64\x00\x65\x00\x74\x00\x65\x00\x72\x00\x6d\ +\x00\x69\x00\x6e\x00\x61\x00\x74\x00\x65\x00\x5f\x00\x68\x00\x6f\x00\x76\x00\x65\x00\x72\x00\x2e\x00\x70\x00\x6e\x00\x67\ +\x00\x1b\ +\x03\x5a\x32\x27\ +\x00\x63\ +\x00\x6f\x00\x6d\x00\x62\x00\x6f\x00\x62\x00\x6f\x00\x78\x00\x5f\x00\x61\x00\x72\x00\x72\x00\x6f\x00\x77\x00\x5f\x00\x64\x00\x69\ +\x00\x73\x00\x61\x00\x62\x00\x6c\x00\x65\x00\x64\x00\x2e\x00\x70\x00\x6e\x00\x67\ +\x00\x0f\ +\x02\x9f\x05\x87\ +\x00\x72\ +\x00\x69\x00\x67\x00\x68\x00\x74\x00\x5f\x00\x61\x00\x72\x00\x72\x00\x6f\x00\x77\x00\x2e\x00\x70\x00\x6e\x00\x67\ \x00\x0e\ \x04\xa2\xfc\xa7\ \x00\x64\ \x00\x6f\x00\x77\x00\x6e\x00\x5f\x00\x61\x00\x72\x00\x72\x00\x6f\x00\x77\x00\x2e\x00\x70\x00\x6e\x00\x67\ +\x00\x1c\ +\x0e\x3c\xde\x07\ +\x00\x63\ +\x00\x68\x00\x65\x00\x63\x00\x6b\x00\x62\x00\x6f\x00\x78\x00\x5f\x00\x75\x00\x6e\x00\x63\x00\x68\x00\x65\x00\x63\x00\x6b\x00\x65\ +\x00\x64\x00\x5f\x00\x68\x00\x6f\x00\x76\x00\x65\x00\x72\x00\x2e\x00\x70\x00\x6e\x00\x67\ \x00\x12\ \x01\x2e\x03\x27\ \x00\x63\ \x00\x6f\x00\x6d\x00\x62\x00\x6f\x00\x62\x00\x6f\x00\x78\x00\x5f\x00\x61\x00\x72\x00\x72\x00\x6f\x00\x77\x00\x2e\x00\x70\x00\x6e\ \x00\x67\ -\x00\x14\ -\x04\x5e\x2d\xa7\ -\x00\x62\ -\x00\x72\x00\x61\x00\x6e\x00\x63\x00\x68\x00\x5f\x00\x63\x00\x6c\x00\x6f\x00\x73\x00\x65\x00\x64\x00\x5f\x00\x6f\x00\x6e\x00\x2e\ -\x00\x70\x00\x6e\x00\x67\ -\x00\x17\ -\x0c\xab\x51\x07\ -\x00\x64\ -\x00\x6f\x00\x77\x00\x6e\x00\x5f\x00\x61\x00\x72\x00\x72\x00\x6f\x00\x77\x00\x5f\x00\x64\x00\x69\x00\x73\x00\x61\x00\x62\x00\x6c\ -\x00\x65\x00\x64\x00\x2e\x00\x70\x00\x6e\x00\x67\ -\x00\x11\ -\x01\x1f\xc3\x87\ -\x00\x64\ -\x00\x6f\x00\x77\x00\x6e\x00\x5f\x00\x61\x00\x72\x00\x72\x00\x6f\x00\x77\x00\x5f\x00\x6f\x00\x6e\x00\x2e\x00\x70\x00\x6e\x00\x67\ -\ -\x00\x17\ -\x0c\x65\xce\x07\ -\x00\x6c\ -\x00\x65\x00\x66\x00\x74\x00\x5f\x00\x61\x00\x72\x00\x72\x00\x6f\x00\x77\x00\x5f\x00\x64\x00\x69\x00\x73\x00\x61\x00\x62\x00\x6c\ -\x00\x65\x00\x64\x00\x2e\x00\x70\x00\x6e\x00\x67\ -\x00\x0c\ -\x06\xe6\xe6\x67\ -\x00\x75\ -\x00\x70\x00\x5f\x00\x61\x00\x72\x00\x72\x00\x6f\x00\x77\x00\x2e\x00\x70\x00\x6e\x00\x67\ \x00\x15\ \x03\x27\x72\x67\ \x00\x63\ \x00\x6f\x00\x6d\x00\x62\x00\x6f\x00\x62\x00\x6f\x00\x78\x00\x5f\x00\x61\x00\x72\x00\x72\x00\x6f\x00\x77\x00\x5f\x00\x6f\x00\x6e\ \x00\x2e\x00\x70\x00\x6e\x00\x67\ +\x00\x1d\ +\x09\x07\x81\x07\ +\x00\x63\ +\x00\x68\x00\x65\x00\x63\x00\x6b\x00\x62\x00\x6f\x00\x78\x00\x5f\x00\x63\x00\x68\x00\x65\x00\x63\x00\x6b\x00\x65\x00\x64\x00\x5f\ +\x00\x64\x00\x69\x00\x73\x00\x61\x00\x62\x00\x6c\x00\x65\x00\x64\x00\x2e\x00\x70\x00\x6e\x00\x67\ +\x00\x23\ +\x06\xf2\x1a\x47\ +\x00\x63\ +\x00\x68\x00\x65\x00\x63\x00\x6b\x00\x62\x00\x6f\x00\x78\x00\x5f\x00\x69\x00\x6e\x00\x64\x00\x65\x00\x74\x00\x65\x00\x72\x00\x6d\ +\x00\x69\x00\x6e\x00\x61\x00\x74\x00\x65\x00\x5f\x00\x64\x00\x69\x00\x73\x00\x61\x00\x62\x00\x6c\x00\x65\x00\x64\x00\x2e\x00\x70\ +\x00\x6e\x00\x67\ +\x00\x17\ +\x0c\x65\xce\x07\ +\x00\x6c\ +\x00\x65\x00\x66\x00\x74\x00\x5f\x00\x61\x00\x72\x00\x72\x00\x6f\x00\x77\x00\x5f\x00\x64\x00\x69\x00\x73\x00\x61\x00\x62\x00\x6c\ +\x00\x65\x00\x64\x00\x2e\x00\x70\x00\x6e\x00\x67\ +\x00\x12\ +\x05\x8f\x9d\x07\ +\x00\x62\ +\x00\x72\x00\x61\x00\x6e\x00\x63\x00\x68\x00\x5f\x00\x6f\x00\x70\x00\x65\x00\x6e\x00\x5f\x00\x6f\x00\x6e\x00\x2e\x00\x70\x00\x6e\ +\x00\x67\ +\x00\x14\ +\x07\xec\xd1\xc7\ +\x00\x63\ +\x00\x68\x00\x65\x00\x63\x00\x6b\x00\x62\x00\x6f\x00\x78\x00\x5f\x00\x63\x00\x68\x00\x65\x00\x63\x00\x6b\x00\x65\x00\x64\x00\x2e\ +\x00\x70\x00\x6e\x00\x67\ +\x00\x16\ +\x01\x75\xcc\x87\ +\x00\x63\ +\x00\x68\x00\x65\x00\x63\x00\x6b\x00\x62\x00\x6f\x00\x78\x00\x5f\x00\x75\x00\x6e\x00\x63\x00\x68\x00\x65\x00\x63\x00\x6b\x00\x65\ +\x00\x64\x00\x2e\x00\x70\x00\x6e\x00\x67\ +\x00\x14\ +\x04\x5e\x2d\xa7\ +\x00\x62\ +\x00\x72\x00\x61\x00\x6e\x00\x63\x00\x68\x00\x5f\x00\x63\x00\x6c\x00\x6f\x00\x73\x00\x65\x00\x64\x00\x5f\x00\x6f\x00\x6e\x00\x2e\ +\x00\x70\x00\x6e\x00\x67\ +\x00\x0f\ +\x01\x73\x8b\x07\ +\x00\x75\ +\x00\x70\x00\x5f\x00\x61\x00\x72\x00\x72\x00\x6f\x00\x77\x00\x5f\x00\x6f\x00\x6e\x00\x2e\x00\x70\x00\x6e\x00\x67\ \x00\x11\ \x00\xb8\x8c\x07\ \x00\x6c\ \x00\x65\x00\x66\x00\x74\x00\x5f\x00\x61\x00\x72\x00\x72\x00\x6f\x00\x77\x00\x5f\x00\x6f\x00\x6e\x00\x2e\x00\x70\x00\x6e\x00\x67\ \ +\x00\x11\ +\x01\x1f\xc3\x87\ +\x00\x64\ +\x00\x6f\x00\x77\x00\x6e\x00\x5f\x00\x61\x00\x72\x00\x72\x00\x6f\x00\x77\x00\x5f\x00\x6f\x00\x6e\x00\x2e\x00\x70\x00\x6e\x00\x67\ +\ +\x00\x0c\ +\x06\xe6\xe6\x67\ +\x00\x75\ +\x00\x70\x00\x5f\x00\x61\x00\x72\x00\x72\x00\x6f\x00\x77\x00\x2e\x00\x70\x00\x6e\x00\x67\ +\x00\x1c\ +\x08\x3f\xda\x67\ +\x00\x63\ +\x00\x68\x00\x65\x00\x63\x00\x6b\x00\x62\x00\x6f\x00\x78\x00\x5f\x00\x75\x00\x6e\x00\x63\x00\x68\x00\x65\x00\x63\x00\x6b\x00\x65\ +\x00\x64\x00\x5f\x00\x66\x00\x6f\x00\x63\x00\x75\x00\x73\x00\x2e\x00\x70\x00\x6e\x00\x67\ +\x00\x1a\ +\x03\x0e\xe4\x87\ +\x00\x63\ +\x00\x68\x00\x65\x00\x63\x00\x6b\x00\x62\x00\x6f\x00\x78\x00\x5f\x00\x63\x00\x68\x00\x65\x00\x63\x00\x6b\x00\x65\x00\x64\x00\x5f\ +\x00\x68\x00\x6f\x00\x76\x00\x65\x00\x72\x00\x2e\x00\x70\x00\x6e\x00\x67\ +\x00\x20\ +\x09\xd7\x1f\xa7\ +\x00\x63\ +\x00\x68\x00\x65\x00\x63\x00\x6b\x00\x62\x00\x6f\x00\x78\x00\x5f\x00\x69\x00\x6e\x00\x64\x00\x65\x00\x74\x00\x65\x00\x72\x00\x6d\ +\x00\x69\x00\x6e\x00\x61\x00\x74\x00\x65\x00\x5f\x00\x66\x00\x6f\x00\x63\x00\x75\x00\x73\x00\x2e\x00\x70\x00\x6e\x00\x67\ +\x00\x1f\ +\x0a\xae\x27\x47\ +\x00\x63\ +\x00\x68\x00\x65\x00\x63\x00\x6b\x00\x62\x00\x6f\x00\x78\x00\x5f\x00\x75\x00\x6e\x00\x63\x00\x68\x00\x65\x00\x63\x00\x6b\x00\x65\ +\x00\x64\x00\x5f\x00\x64\x00\x69\x00\x73\x00\x61\x00\x62\x00\x6c\x00\x65\x00\x64\x00\x2e\x00\x70\x00\x6e\x00\x67\ +\x00\x18\ +\x03\x8e\xde\x67\ +\x00\x72\ +\x00\x69\x00\x67\x00\x68\x00\x74\x00\x5f\x00\x61\x00\x72\x00\x72\x00\x6f\x00\x77\x00\x5f\x00\x64\x00\x69\x00\x73\x00\x61\x00\x62\ +\x00\x6c\x00\x65\x00\x64\x00\x2e\x00\x70\x00\x6e\x00\x67\ +\x00\x0f\ +\x0c\xe2\x68\x67\ +\x00\x74\ +\x00\x72\x00\x61\x00\x6e\x00\x73\x00\x70\x00\x61\x00\x72\x00\x65\x00\x6e\x00\x74\x00\x2e\x00\x70\x00\x6e\x00\x67\ +\x00\x15\ +\x0f\xf3\xc0\x07\ +\x00\x75\ +\x00\x70\x00\x5f\x00\x61\x00\x72\x00\x72\x00\x6f\x00\x77\x00\x5f\x00\x64\x00\x69\x00\x73\x00\x61\x00\x62\x00\x6c\x00\x65\x00\x64\ +\x00\x2e\x00\x70\x00\x6e\x00\x67\ +\x00\x12\ +\x03\x8d\x04\x47\ +\x00\x72\ +\x00\x69\x00\x67\x00\x68\x00\x74\x00\x5f\x00\x61\x00\x72\x00\x72\x00\x6f\x00\x77\x00\x5f\x00\x6f\x00\x6e\x00\x2e\x00\x70\x00\x6e\ +\x00\x67\ \x00\x0e\ \x0e\xde\xfa\xc7\ \x00\x6c\ \x00\x65\x00\x66\x00\x74\x00\x5f\x00\x61\x00\x72\x00\x72\x00\x6f\x00\x77\x00\x2e\x00\x70\x00\x6e\x00\x67\ -\x00\x0f\ -\x06\x53\x25\xa7\ -\x00\x62\ -\x00\x72\x00\x61\x00\x6e\x00\x63\x00\x68\x00\x5f\x00\x6f\x00\x70\x00\x65\x00\x6e\x00\x2e\x00\x70\x00\x6e\x00\x67\ +\x00\x17\ +\x0c\xab\x51\x07\ +\x00\x64\ +\x00\x6f\x00\x77\x00\x6e\x00\x5f\x00\x61\x00\x72\x00\x72\x00\x6f\x00\x77\x00\x5f\x00\x64\x00\x69\x00\x73\x00\x61\x00\x62\x00\x6c\ +\x00\x65\x00\x64\x00\x2e\x00\x70\x00\x6e\x00\x67\ +\x00\x1a\ +\x05\x11\xe0\xe7\ +\x00\x63\ +\x00\x68\x00\x65\x00\x63\x00\x6b\x00\x62\x00\x6f\x00\x78\x00\x5f\x00\x63\x00\x68\x00\x65\x00\x63\x00\x6b\x00\x65\x00\x64\x00\x5f\ +\x00\x66\x00\x6f\x00\x63\x00\x75\x00\x73\x00\x2e\x00\x70\x00\x6e\x00\x67\ " qt_resource_struct_v1 = b"\ \x00\x00\x00\x00\x00\x02\x00\x00\x00\x01\x00\x00\x00\x01\ \x00\x00\x00\x00\x00\x02\x00\x00\x00\x01\x00\x00\x00\x02\ -\x00\x00\x00\x16\x00\x02\x00\x00\x00\x13\x00\x00\x00\x03\ -\x00\x00\x02\xe6\x00\x00\x00\x00\x00\x01\x00\x00\x1f\x3c\ -\x00\x00\x02\x3c\x00\x00\x00\x00\x00\x01\x00\x00\x1c\x9d\ -\x00\x00\x01\xb0\x00\x00\x00\x00\x00\x01\x00\x00\x13\x68\ -\x00\x00\x01\x6a\x00\x00\x00\x00\x00\x01\x00\x00\x12\x1d\ +\x00\x00\x00\x16\x00\x02\x00\x00\x00\x20\x00\x00\x00\x03\ +\x00\x00\x03\xaa\x00\x00\x00\x00\x00\x01\x00\x00\x33\x3b\ +\x00\x00\x03\xd2\x00\x00\x00\x00\x00\x01\x00\x00\x33\xe5\ +\x00\x00\x01\xb4\x00\x00\x00\x00\x00\x01\x00\x00\x15\xf1\ +\x00\x00\x03\x86\x00\x00\x00\x00\x00\x01\x00\x00\x32\x99\ +\x00\x00\x03\x26\x00\x00\x00\x00\x00\x01\x00\x00\x29\x59\ +\x00\x00\x00\x74\x00\x00\x00\x00\x00\x01\x00\x00\x0e\xbb\ +\x00\x00\x01\x30\x00\x00\x00\x00\x00\x01\x00\x00\x13\x37\ +\x00\x00\x04\x56\x00\x00\x00\x00\x00\x01\x00\x00\x36\x8b\ +\x00\x00\x01\xde\x00\x00\x00\x00\x00\x01\x00\x00\x16\x9b\ +\x00\x00\x00\xf4\x00\x00\x00\x00\x00\x01\x00\x00\x12\x8e\ +\x00\x00\x05\xa4\x00\x00\x00\x00\x00\x01\x00\x00\x44\xc9\ +\x00\x00\x05\x1a\x00\x00\x00\x00\x00\x01\x00\x00\x3e\x00\ +\x00\x00\x03\x58\x00\x00\x00\x00\x00\x01\x00\x00\x2a\xb8\ +\x00\x00\x01\x54\x00\x00\x00\x00\x00\x01\x00\x00\x13\xdb\ +\x00\x00\x06\x24\x00\x00\x00\x00\x00\x01\x00\x00\x46\xc1\ +\x00\x00\x02\xce\x00\x00\x00\x00\x00\x01\x00\x00\x1e\x26\ +\x00\x00\x00\x50\x00\x00\x00\x00\x00\x01\x00\x00\x07\xb1\ +\x00\x00\x03\xfa\x00\x00\x00\x00\x00\x01\x00\x00\x34\x8e\ +\x00\x00\x02\x4e\x00\x00\x00\x00\x00\x01\x00\x00\x1b\x7c\ +\x00\x00\x02\xf8\x00\x00\x00\x00\x00\x01\x00\x00\x25\x5a\ +\x00\x00\x04\x18\x00\x00\x00\x00\x00\x01\x00\x00\x35\x30\ +\x00\x00\x02\x0e\x00\x00\x00\x00\x00\x01\x00\x00\x17\x45\ +\x00\x00\x04\x90\x00\x00\x00\x00\x00\x01\x00\x00\x3a\xa1\ +\x00\x00\x04\xd6\x00\x00\x00\x00\x00\x01\x00\x00\x3c\x86\ \x00\x00\x00\x28\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\ -\x00\x00\x02\xb6\x00\x00\x00\x00\x00\x01\x00\x00\x1e\x92\ -\x00\x00\x00\x76\x00\x00\x00\x00\x00\x01\x00\x00\x07\xd8\ -\x00\x00\x01\x10\x00\x00\x00\x00\x00\x01\x00\x00\x10\xd6\ -\x00\x00\x00\xb2\x00\x00\x00\x00\x00\x01\x00\x00\x08\x81\ -\x00\x00\x01\xda\x00\x00\x00\x00\x00\x01\x00\x00\x14\x12\ -\x00\x00\x01\x8e\x00\x00\x00\x00\x00\x01\x00\x00\x12\xbf\ -\x00\x00\x00\x4c\x00\x00\x00\x00\x00\x01\x00\x00\x00\xa4\ -\x00\x00\x03\x30\x00\x00\x00\x00\x00\x01\x00\x00\x20\x90\ -\x00\x00\x02\x98\x00\x00\x00\x00\x00\x01\x00\x00\x1d\xf0\ -\x00\x00\x00\xe8\x00\x00\x00\x00\x00\x01\x00\x00\x09\x25\ -\x00\x00\x02\x64\x00\x00\x00\x00\x00\x01\x00\x00\x1d\x46\ -\x00\x00\x02\x08\x00\x00\x00\x00\x00\x01\x00\x00\x1b\xf3\ -\x00\x00\x03\x0e\x00\x00\x00\x00\x00\x01\x00\x00\x1f\xe6\ -\x00\x00\x01\x3a\x00\x00\x00\x00\x00\x01\x00\x00\x11\x7a\ +\x00\x00\x02\x9a\x00\x00\x00\x00\x00\x01\x00\x00\x1d\x7c\ +\x00\x00\x05\xf0\x00\x00\x00\x00\x00\x01\x00\x00\x46\x17\ +\x00\x00\x05\x50\x00\x00\x00\x00\x00\x01\x00\x00\x3e\xa4\ +\x00\x00\x01\x76\x00\x00\x00\x00\x00\x01\x00\x00\x14\x84\ +\x00\x00\x05\xce\x00\x00\x00\x00\x00\x01\x00\x00\x45\x6d\ +\x00\x00\x00\xae\x00\x00\x00\x00\x00\x01\x00\x00\x10\x9b\ +\x00\x00\x05\x74\x00\x00\x00\x00\x00\x01\x00\x00\x44\x26\ " qt_resource_struct_v2 = b"\ @@ -816,46 +1485,72 @@ qt_resource_struct_v2 = b"\ \x00\x00\x00\x00\x00\x00\x00\x00\ \x00\x00\x00\x00\x00\x02\x00\x00\x00\x01\x00\x00\x00\x02\ \x00\x00\x00\x00\x00\x00\x00\x00\ -\x00\x00\x00\x16\x00\x02\x00\x00\x00\x13\x00\x00\x00\x03\ +\x00\x00\x00\x16\x00\x02\x00\x00\x00\x20\x00\x00\x00\x03\ \x00\x00\x00\x00\x00\x00\x00\x00\ -\x00\x00\x02\xe6\x00\x00\x00\x00\x00\x01\x00\x00\x1f\x3c\ -\x00\x00\x01\x76\x41\x9d\xa2\x35\ -\x00\x00\x02\x3c\x00\x00\x00\x00\x00\x01\x00\x00\x1c\x9d\ -\x00\x00\x01\x76\x41\x9d\xa2\x35\ -\x00\x00\x01\xb0\x00\x00\x00\x00\x00\x01\x00\x00\x13\x68\ -\x00\x00\x01\x79\xb4\x72\xcc\x9c\ -\x00\x00\x01\x6a\x00\x00\x00\x00\x00\x01\x00\x00\x12\x1d\ -\x00\x00\x01\x76\x41\x9d\xa2\x39\ +\x00\x00\x03\xaa\x00\x00\x00\x00\x00\x01\x00\x00\x33\x3b\ +\x00\x00\x01\x7b\xe9\x78\x46\xdd\ +\x00\x00\x03\xd2\x00\x00\x00\x00\x00\x01\x00\x00\x33\xe5\ +\x00\x00\x01\x7b\xe9\x78\x46\xdb\ +\x00\x00\x01\xb4\x00\x00\x00\x00\x00\x01\x00\x00\x15\xf1\ +\x00\x00\x01\x7b\xe9\x78\x46\xd9\ +\x00\x00\x03\x86\x00\x00\x00\x00\x00\x01\x00\x00\x32\x99\ +\x00\x00\x01\x7b\xe9\x78\x46\xe0\ +\x00\x00\x03\x26\x00\x00\x00\x00\x00\x01\x00\x00\x29\x59\ +\x00\x00\x01\x7d\x0a\x8c\xfa\xc7\ +\x00\x00\x00\x74\x00\x00\x00\x00\x00\x01\x00\x00\x0e\xbb\ +\x00\x00\x01\x7d\x0a\x8c\xfa\xc5\ +\x00\x00\x01\x30\x00\x00\x00\x00\x00\x01\x00\x00\x13\x37\ +\x00\x00\x01\x7b\xe9\x78\x46\xdd\ +\x00\x00\x04\x56\x00\x00\x00\x00\x00\x01\x00\x00\x36\x8b\ +\x00\x00\x01\x7d\x0a\x8c\xfa\xc4\ +\x00\x00\x01\xde\x00\x00\x00\x00\x00\x01\x00\x00\x16\x9b\ +\x00\x00\x01\x7b\xe9\x78\x46\xda\ +\x00\x00\x00\xf4\x00\x00\x00\x00\x00\x01\x00\x00\x12\x8e\ +\x00\x00\x01\x7b\xe9\x78\x46\xd9\ +\x00\x00\x05\xa4\x00\x00\x00\x00\x00\x01\x00\x00\x44\xc9\ +\x00\x00\x01\x7b\xe9\x78\x46\xde\ +\x00\x00\x05\x1a\x00\x00\x00\x00\x00\x01\x00\x00\x3e\x00\ +\x00\x00\x01\x7b\xe9\x78\x46\xde\ +\x00\x00\x03\x58\x00\x00\x00\x00\x00\x01\x00\x00\x2a\xb8\ +\x00\x00\x01\x7b\xe9\x78\x46\xd7\ +\x00\x00\x01\x54\x00\x00\x00\x00\x00\x01\x00\x00\x13\xdb\ +\x00\x00\x01\x7b\xe9\x78\x46\xda\ +\x00\x00\x06\x24\x00\x00\x00\x00\x00\x01\x00\x00\x46\xc1\ +\x00\x00\x01\x7d\x0a\x8c\xfa\xc4\ +\x00\x00\x02\xce\x00\x00\x00\x00\x00\x01\x00\x00\x1e\x26\ +\x00\x00\x01\x7b\xe9\x78\x46\xd8\ +\x00\x00\x00\x50\x00\x00\x00\x00\x00\x01\x00\x00\x07\xb1\ +\x00\x00\x01\x7b\xe9\x78\x46\xd8\ +\x00\x00\x03\xfa\x00\x00\x00\x00\x00\x01\x00\x00\x34\x8e\ +\x00\x00\x01\x7b\xe9\x78\x46\xdf\ +\x00\x00\x02\x4e\x00\x00\x00\x00\x00\x01\x00\x00\x1b\x7c\ +\x00\x00\x01\x7d\x0a\x8c\xfa\xc5\ +\x00\x00\x02\xf8\x00\x00\x00\x00\x00\x01\x00\x00\x25\x5a\ +\x00\x00\x01\x7d\x0a\x8c\xfa\xc2\ +\x00\x00\x04\x18\x00\x00\x00\x00\x00\x01\x00\x00\x35\x30\ +\x00\x00\x01\x7d\x0a\x8c\xfa\xc8\ +\x00\x00\x02\x0e\x00\x00\x00\x00\x00\x01\x00\x00\x17\x45\ +\x00\x00\x01\x7d\x0a\x8c\xfa\xc3\ +\x00\x00\x04\x90\x00\x00\x00\x00\x00\x01\x00\x00\x3a\xa1\ +\x00\x00\x01\x7d\x0a\x8c\xfa\xc6\ +\x00\x00\x04\xd6\x00\x00\x00\x00\x00\x01\x00\x00\x3c\x86\ +\x00\x00\x01\x7d\x0a\x8c\xfa\xc7\ \x00\x00\x00\x28\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\ -\x00\x00\x01\x76\x41\x9d\xa2\x37\ -\x00\x00\x02\xb6\x00\x00\x00\x00\x00\x01\x00\x00\x1e\x92\ -\x00\x00\x01\x79\xb4\x72\xcc\x9c\ -\x00\x00\x00\x76\x00\x00\x00\x00\x00\x01\x00\x00\x07\xd8\ -\x00\x00\x01\x79\xb4\x72\xcc\x9c\ -\x00\x00\x01\x10\x00\x00\x00\x00\x00\x01\x00\x00\x10\xd6\ -\x00\x00\x01\x76\x41\x9d\xa2\x37\ -\x00\x00\x00\xb2\x00\x00\x00\x00\x00\x01\x00\x00\x08\x81\ -\x00\x00\x01\x76\x41\x9d\xa2\x37\ -\x00\x00\x01\xda\x00\x00\x00\x00\x00\x01\x00\x00\x14\x12\ -\x00\x00\x01\x79\xc2\x05\x2b\x60\ -\x00\x00\x01\x8e\x00\x00\x00\x00\x00\x01\x00\x00\x12\xbf\ -\x00\x00\x01\x76\x41\x9d\xa2\x35\ -\x00\x00\x00\x4c\x00\x00\x00\x00\x00\x01\x00\x00\x00\xa4\ -\x00\x00\x01\x79\xc1\xfc\x16\x91\ -\x00\x00\x03\x30\x00\x00\x00\x00\x00\x01\x00\x00\x20\x90\ -\x00\x00\x01\x79\xc1\xf9\x4b\x78\ -\x00\x00\x02\x98\x00\x00\x00\x00\x00\x01\x00\x00\x1d\xf0\ -\x00\x00\x01\x76\x41\x9d\xa2\x39\ -\x00\x00\x00\xe8\x00\x00\x00\x00\x00\x01\x00\x00\x09\x25\ -\x00\x00\x01\x79\xc2\x05\x91\x2a\ -\x00\x00\x02\x64\x00\x00\x00\x00\x00\x01\x00\x00\x1d\x46\ -\x00\x00\x01\x76\x41\x9d\xa2\x35\ -\x00\x00\x02\x08\x00\x00\x00\x00\x00\x01\x00\x00\x1b\xf3\ -\x00\x00\x01\x76\x41\x9d\xa2\x35\ -\x00\x00\x03\x0e\x00\x00\x00\x00\x00\x01\x00\x00\x1f\xe6\ -\x00\x00\x01\x76\x41\x9d\xa2\x35\ -\x00\x00\x01\x3a\x00\x00\x00\x00\x00\x01\x00\x00\x11\x7a\ -\x00\x00\x01\x76\x41\x9d\xa2\x39\ +\x00\x00\x01\x7b\xe9\x78\x46\xd7\ +\x00\x00\x02\x9a\x00\x00\x00\x00\x00\x01\x00\x00\x1d\x7c\ +\x00\x00\x01\x7b\xe9\x78\x46\xdc\ +\x00\x00\x05\xf0\x00\x00\x00\x00\x00\x01\x00\x00\x46\x17\ +\x00\x00\x01\x7b\xe9\x78\x46\xdb\ +\x00\x00\x05\x50\x00\x00\x00\x00\x00\x01\x00\x00\x3e\xa4\ +\x00\x00\x01\x7d\x0a\xb7\x38\x27\ +\x00\x00\x01\x76\x00\x00\x00\x00\x00\x01\x00\x00\x14\x84\ +\x00\x00\x01\x7d\x0a\x8c\xfa\xc9\ +\x00\x00\x05\xce\x00\x00\x00\x00\x00\x01\x00\x00\x45\x6d\ +\x00\x00\x01\x7b\xe9\x78\x46\xdc\ +\x00\x00\x00\xae\x00\x00\x00\x00\x00\x01\x00\x00\x10\x9b\ +\x00\x00\x01\x7d\x0a\x8c\xfa\xc6\ +\x00\x00\x05\x74\x00\x00\x00\x00\x00\x01\x00\x00\x44\x26\ +\x00\x00\x01\x7b\xe9\x78\x46\xdf\ " diff --git a/openpype/style/pyside2_resources.py b/openpype/style/pyside2_resources.py index ee68a74b8e..2aa84d04f1 100644 --- a/openpype/style/pyside2_resources.py +++ b/openpype/style/pyside2_resources.py @@ -1,37 +1,16 @@ -# Resource object code (Python 3) -# Created by: object code -# Created by: The Resource Compiler for Qt version 5.15.2 +# -*- coding: utf-8 -*- + +# Resource object code +# +# Created: Wed Nov 10 17:40:15 2021 +# by: The Resource Compiler for PySide2 (Qt v5.12.5) +# # WARNING! All changes made in this file will be lost! from PySide2 import QtCore qt_resource_data = b"\ -\x00\x00\x00\x9f\ -\x89\ -PNG\x0d\x0a\x1a\x0a\x00\x00\x00\x0dIHDR\x00\ -\x00\x00\x09\x00\x00\x00\x06\x08\x04\x00\x00\x00\xbb\xce|N\ -\x00\x00\x00\x01sRGB\x00\xae\xce\x1c\xe9\x00\x00\x00\ -\x02bKGD\x00\xff\x87\x8f\xcc\xbf\x00\x00\x00\x09p\ -HYs\x00\x00\x0b\x13\x00\x00\x0b\x13\x01\x00\x9a\x9c\x18\ -\x00\x00\x00\x07tIME\x07\xdc\x08\x17\x08\x14\x1f\xf9\ -#\xd9\x0b\x00\x00\x00#IDAT\x08\xd7c`\xc0\ -\x0d\xe6|\x80\xb1\x18\x91\x05R\x04\xe0B\x08\x15)\x02\ -\x0c\x0c\x8c\xc8\x02\x08\x95h\x00\x00\xac\xac\x07\x90Ne\ -4\xac\x00\x00\x00\x00IEND\xaeB`\x82\ -\x00\x00\x00\xa6\ -\x89\ -PNG\x0d\x0a\x1a\x0a\x00\x00\x00\x0dIHDR\x00\ -\x00\x00\x09\x00\x00\x00\x06\x08\x04\x00\x00\x00\xbb\xce|N\ -\x00\x00\x00\x01sRGB\x00\xae\xce\x1c\xe9\x00\x00\x00\ -\x02bKGD\x00\xff\x87\x8f\xcc\xbf\x00\x00\x00\x09p\ -HYs\x00\x00\x0b\x13\x00\x00\x0b\x13\x01\x00\x9a\x9c\x18\ -\x00\x00\x00\x07tIME\x07\xdc\x08\x17\x08\x15;\xdc\ -;\x0c\x9b\x00\x00\x00*IDAT\x08\xd7c`\xc0\ -\x00\x8c\x0c\x0cs> \x0b\xa4\x08020 \x0b\xa6\ -\x08000B\x98\x10\xc1\x14\x01\x14\x13P\xb5\xa3\x01\ -\x00\xc6\xb9\x07\x90]f\x1f\x83\x00\x00\x00\x00IEN\ -D\xaeB`\x82\ \x00\x00\x00\xa5\ \x89\ PNG\x0d\x0a\x1a\x0a\x00\x00\x00\x0dIHDR\x00\ @@ -45,73 +24,11 @@ HYs\x00\x00\x0b\x13\x00\x00\x0b\x13\x01\x00\x9a\x9c\x18\ 200B\x98\x10AFC\x14\x13P\xb5\xa3\x01\x00\ \xd6\x10\x07\xd2/H\xdfJ\x00\x00\x00\x00IEND\ \xaeB`\x82\ -\x00\x00\x00\xa5\ +\x00\x00\x07\xad\ \x89\ PNG\x0d\x0a\x1a\x0a\x00\x00\x00\x0dIHDR\x00\ -\x00\x00\x09\x00\x00\x00\x06\x08\x04\x00\x00\x00\xbb\xce|N\ -\x00\x00\x00\x01sRGB\x00\xae\xce\x1c\xe9\x00\x00\x00\ -\x02bKGD\x00\x9cS4\xfc]\x00\x00\x00\x09p\ -HYs\x00\x00\x0b\x13\x00\x00\x0b\x13\x01\x00\x9a\x9c\x18\ -\x00\x00\x00\x07tIME\x07\xdc\x08\x17\x0b\x02\x04m\ -\x98\x1bi\x00\x00\x00)IDAT\x08\xd7c`\xc0\ -\x00\x8c\x0c\x0c\xff\xcf\xa3\x08\x18220 \x0b2\x1a\ -200B\x98\x10AFC\x14\x13P\xb5\xa3\x01\x00\ -\xd6\x10\x07\xd2/H\xdfJ\x00\x00\x00\x00IEND\ -\xaeB`\x82\ -\x00\x00\x00\xa0\ -\x89\ -PNG\x0d\x0a\x1a\x0a\x00\x00\x00\x0dIHDR\x00\ -\x00\x00\x06\x00\x00\x00\x09\x08\x04\x00\x00\x00\xbb\x93\x95\x16\ -\x00\x00\x00\x01sRGB\x00\xae\xce\x1c\xe9\x00\x00\x00\ -\x02bKGD\x00\xff\x87\x8f\xcc\xbf\x00\x00\x00\x09p\ -HYs\x00\x00\x0b\x13\x00\x00\x0b\x13\x01\x00\x9a\x9c\x18\ -\x00\x00\x00\x07tIME\x07\xdc\x08\x17\x14\x1f\x0d\xfc\ -R+\x9c\x00\x00\x00$IDAT\x08\xd7c`@\ -\x05s>\xc0XL\xc8\x5c&dY&d\xc5pN\ -\x8a\x00\x9c\x93\x22\x80a\x1a\x0a\x00\x00)\x95\x08\xaf\x88\ -\xac\xba4\x00\x00\x00\x00IEND\xaeB`\x82\ -\x00\x00\x00\xa6\ -\x89\ -PNG\x0d\x0a\x1a\x0a\x00\x00\x00\x0dIHDR\x00\ -\x00\x00\x06\x00\x00\x00\x09\x08\x04\x00\x00\x00\xbb\x93\x95\x16\ -\x00\x00\x00\x01sRGB\x00\xae\xce\x1c\xe9\x00\x00\x00\ -\x02bKGD\x00\xff\x87\x8f\xcc\xbf\x00\x00\x00\x09p\ -HYs\x00\x00\x0b\x13\x00\x00\x0b\x13\x01\x00\x9a\x9c\x18\ -\x00\x00\x00\x07tIME\x07\xdc\x08\x17\x14\x1d\x00\xb0\ -\xd55\xa3\x00\x00\x00*IDAT\x08\xd7c`\xc0\ -\x06\xfe\x9fg``B0\xa1\x1c\x08\x93\x81\x81\x09\xc1\ -d``b``4D\xe2 s\x19\x90\x8d@\x02\ -\x00d@\x09u\x86\xb3\xad\x9c\x00\x00\x00\x00IEN\ -D\xaeB`\x82\ -\x00\x00\x00\x9e\ -\x89\ -PNG\x0d\x0a\x1a\x0a\x00\x00\x00\x0dIHDR\x00\ -\x00\x00\x09\x00\x00\x00\x06\x08\x04\x00\x00\x00\xbb\xce|N\ -\x00\x00\x00\x01sRGB\x00\xae\xce\x1c\xe9\x00\x00\x00\ -\x02bKGD\x00\xff\x87\x8f\xcc\xbf\x00\x00\x00\x09p\ -HYs\x00\x00\x0b\x13\x00\x00\x0b\x13\x01\x00\x9a\x9c\x18\ -\x00\x00\x00\x07tIME\x07\xdc\x08\x17\x08\x15\x0f\xfd\ -\x8f\xf8.\x00\x00\x00\x22IDAT\x08\xd7c`\xc0\ -\x0d\xfe\x9f\x87\xb1\x18\x91\x05\x18\x0d\xe1BH*\x0c\x19\ -\x18\x18\x91\x05\x10*\xd1\x00\x00\xca\xb5\x07\xd2v\xbb\xb2\ -\xc5\x00\x00\x00\x00IEND\xaeB`\x82\ -\x00\x00\x00\x9e\ -\x89\ -PNG\x0d\x0a\x1a\x0a\x00\x00\x00\x0dIHDR\x00\ -\x00\x00\x09\x00\x00\x00\x06\x08\x04\x00\x00\x00\xbb\xce|N\ -\x00\x00\x00\x01sRGB\x00\xae\xce\x1c\xe9\x00\x00\x00\ -\x02bKGD\x00\xff\x87\x8f\xcc\xbf\x00\x00\x00\x09p\ -HYs\x00\x00\x0b\x13\x00\x00\x0b\x13\x01\x00\x9a\x9c\x18\ -\x00\x00\x00\x07tIME\x07\xdc\x08\x17\x08\x15\x0f\xfd\ -\x8f\xf8.\x00\x00\x00\x22IDAT\x08\xd7c`\xc0\ -\x0d\xfe\x9f\x87\xb1\x18\x91\x05\x18\x0d\xe1BH*\x0c\x19\ -\x18\x18\x91\x05\x10*\xd1\x00\x00\xca\xb5\x07\xd2v\xbb\xb2\ -\xc5\x00\x00\x00\x00IEND\xaeB`\x82\ -\x00\x00\x07\x06\ -\x89\ -PNG\x0d\x0a\x1a\x0a\x00\x00\x00\x0dIHDR\x00\ -\x00\x00\x0a\x00\x00\x00\x07\x08\x06\x00\x00\x001\xac\xdcc\ -\x00\x00\x04\xb0iTXtXML:com.\ +\x00\x00\x07\x00\x00\x00\x0a\x08\x06\x00\x00\x00x\xccD\x0d\ +\x00\x00\x05RiTXtXML:com.\ adobe.xmp\x00\x00\x00\x00\x00\x0a \x0a \x0a \x0a \x0a \x0a \x0a \x0a \x0a\x0a\x85\x9d\x9f\x08\x00\x00\x01\x83\ -iCCPsRGB IEC6196\ -6-2.1\x00\x00(\x91u\x91\xcf+DQ\x14\ -\xc7?fh\xfc\x18\x8dba1e\x12\x16B\x83\x12\ -\x1b\x8b\x99\x18\x0a\x8b\x99Q~mf\x9ey3j\xde\ -x\xbd7\xd2d\xabl\xa7(\xb1\xf1k\xc1_\xc0V\ -Y+E\xa4d\xa7\xac\x89\x0dz\xce\x9bQ#\x99s\ -;\xf7|\xee\xf7\xdes\xba\xf7\x5cpD\xd3\x8afV\ -\xfaA\xcbd\x8dp(\xe0\x9b\x99\x9d\xf3\xb9\x9e\xa8\xa2\ -\x85\x1a:\xf1\xc6\x14S\x9f\x8c\x8cF)k\xef\xb7T\ -\xd8\xf1\xba\xdb\xaeU\xfe\xdc\xbfV\xb7\x980\x15\xa8\xa8\ -\x16\x1eVt#+<&<\xb1\x9a\xd5m\xde\x12n\ -RR\xb1E\xe1\x13\xe1.C.(|c\xeb\xf1\x22\ -?\xdb\x9c,\xf2\xa7\xcdF4\x1c\x04G\x83\xb0/\xf9\ -\x8b\xe3\xbfXI\x19\x9a\xb0\xbc\x9c6-\xbd\xa2\xfc\xdc\ -\xc7~\x89;\x91\x99\x8eHl\x15\xf7b\x12&D\x00\ -\x1f\xe3\x8c\x10d\x80^\x86d\x1e\xa0\x9b>zdE\ -\x99|\x7f!\x7f\x8ae\xc9Ud\xd6\xc9a\xb0D\x92\ -\x14Y\xbaD]\x91\xea\x09\x89\xaa\xe8\x09\x19irv\ -\xff\xff\xf6\xd5T\xfb\xfb\x8a\xd5\xdd\x01\xa8z\xb4\xac\xd7\ -vpm\xc2W\xde\xb2>\x0e,\xeb\xeb\x10\x9c\x0fp\ -\x9e)\xe5/\xef\xc3\xe0\x9b\xe8\xf9\x92\xd6\xb6\x07\x9eu\ -8\xbd(i\xf1m8\xdb\x80\xe6{=f\xc4\x0a\x92\ -S\xdc\xa1\xaa\xf0r\x0c\xf5\xb3\xd0x\x05\xb5\xf3\xc5\x9e\ -\xfd\xecst\x07\xd15\xf9\xaaK\xd8\xd9\x85\x0e9\xef\ -Y\xf8\x06\x8e\xfdg\xf8\xfd\x8a\x18\x97\x00\x00\x00\x09p\ -HYs\x00\x00\x0b\x13\x00\x00\x0b\x13\x01\x00\x9a\x9c\x18\ -\x00\x00\x00mIDAT\x18\x95u\xcf\xc1\x09\xc2P\ -\x10\x84\xe1\xd7\x85\x07\x9b\xd0C@\xd2\x82x\x14{0\ -W!\x8d\x84`?bKzH\xcc\x97\x83\xfb0\x04\ -\xdf\x9c\x86\x7fg\x99\xdd\x84\x0d\xaaT\x10jl\x13\x1e\ -\xbe\xba\xfe\x0951{\xe6\x8d\x0f&\x1c\x17\xa1S\xb0\ -\x11\x87\x0c/\x01\x07\xec\xb0\x0f?\xe1\xbc\xaei\xa3\xe6\ -\x85w\xf8[\xe9\xf0\xbb\x9f\xfa\xd2\x839\xdc\xa3[\xf3\ -\x19.\xa8\x89\xb50\xf7C\xa0\x00\x00\x00\x00IEN\ -D\xaeB`\x82\ +-31T12:43:35+02:\ +00\x22>\x0a \x0a \ +\x0a branch_close<\ +/rdf:li>\x0a \x0a \x0a \x0a \x0a \x0a \x0a \x0a \x0a <\ +/rdf:RDF>\x0a\x0a$\xe15\x97\x00\x00\ +\x01\x83iCCPsRGB IEC61\ +966-2.1\x00\x00(\x91u\x91\xcf+D\ +Q\x14\xc7?fh\xfc\x18\x8dba1e\x12\x16B\ +\x83\x12\x1b\x8b\x99\x18\x0a\x8b\x99Q~mf\x9ey3\ +j\xdex\xbd7\xd2d\xabl\xa7(\xb1\xf1k\xc1_\ +\xc0VY+E\xa4d\xa7\xac\x89\x0dz\xce\x9bQ#\ +\x99s;\xf7|\xee\xf7\xdes\xba\xf7\x5cpD\xd3\x8a\ +fV\xfaA\xcbd\x8dp(\xe0\x9b\x99\x9d\xf3\xb9\x9e\ +\xa8\xa2\x85\x1a:\xf1\xc6\x14S\x9f\x8c\x8cF)k\xef\ +\xb7T\xd8\xf1\xba\xdb\xaeU\xfe\xdc\xbfV\xb7\x980\x15\ +\xa8\xa8\x16\x1eVt#+<&<\xb1\x9a\xd5m\xde\ +\x12nRR\xb1E\xe1\x13\xe1.C.(|c\xeb\ +\xf1\x22?\xdb\x9c,\xf2\xa7\xcdF4\x1c\x04G\x83\xb0\ +/\xf9\x8b\xe3\xbfXI\x19\x9a\xb0\xbc\x9c6-\xbd\xa2\ +\xfc\xdc\xc7~\x89;\x91\x99\x8eHl\x15\xf7b\x12&\ +D\x00\x1f\xe3\x8c\x10d\x80^\x86d\x1e\xa0\x9b>z\ +dE\x99|\x7f!\x7f\x8ae\xc9Ud\xd6\xc9a\xb0\ +D\x92\x14Y\xbaD]\x91\xea\x09\x89\xaa\xe8\x09\x19i\ +rv\xff\xff\xf6\xd5T\xfb\xfb\x8a\xd5\xdd\x01\xa8z\xb4\ +\xac\xd7vpm\xc2W\xde\xb2>\x0e,\xeb\xeb\x10\x9c\ +\x0fp\x9e)\xe5/\xef\xc3\xe0\x9b\xe8\xf9\x92\xd6\xb6\x07\ +\x9eu8\xbd(i\xf1m8\xdb\x80\xe6{=f\xc4\ +\x0a\x92S\xdc\xa1\xaa\xf0r\x0c\xf5\xb3\xd0x\x05\xb5\xf3\ +\xc5\x9e\xfd\xecst\x07\xd15\xf9\xaaK\xd8\xd9\x85\x0e\ +9\xefY\xf8\x06\x8e\xfdg\xf8\xfd\x8a\x18\x97\x00\x00\x00\ +\x09pHYs\x00\x00\x0b\x13\x00\x00\x0b\x13\x01\x00\x9a\ +\x9c\x18\x00\x00\x00rIDAT\x18\x95m\xcf1\x0a\ +\xc2P\x14D\xd1\xe8\x02\xb4W\x08\xd6Ia\x99JC\ +t\x15\x82\xabI6(\xee@\x04\xdb\xa8\x95Xx,\ +\xf2\x09\xe1\xf3\x07\xa6\x9a\xfb\xe0\xbe\x0c\x1b\xb4Xdq\ +p0\xe4\x82U\x0a8\xe3\x8b\x1b\x8a\x14p\xc4\x1b=\ +v)`\x8b\x07>\xa8\xe6\xd1\xfe\x0b\x9d\x85\x8eW\x0d\ +^x\xa2\x9e\x0e\xa7 tG9\x1d\xf6\xe1\x95+\xd6\ +\xb1D\x8e\x0e\xcbX\xf0\x0fR\x8ay\x18\xdc\xe2\x02p\ +\x00\x00\x00\x00IEND\xaeB`\x82\ +\x00\x00\x043\ +\x89\ +PNG\x0d\x0a\x1a\x0a\x00\x00\x00\x0dIHDR\x00\ +\x00\x000\x00\x00\x000\x08\x06\x00\x00\x00W\x02\xf9\x87\ +\x00\x00\x00\x09pHYs\x00\x00\x0b\x13\x00\x00\x0b\x13\ +\x01\x00\x9a\x9c\x18\x00\x00\x03\xe5IDATh\x81\xed\ +\x9aM\x88\x1cE\x14\xc7\x7f3;\x89.\x18\xf13\x1e\ +\x92\x8b\x8b\xa2&\x06\x14/\xa29\x88\x15\x89\xa9\x18\x15\ +\xd1\xca\x06\x0f\x91\x05=\x88bnB\xc0\x1c\x12\xc4\x83\ +\x07\x15\x0dB\x14\x89\x04\xa2\x96DY\x22/F}\x8a\ +\xb0\x22\x08\x1e4\xbb\x22F\x8c,\x88\xba\xc4\x88\x82_\ +\xc9F=T\x0f\x8c\xbd\xdd]\xdd\xd3a\xa7\x07\xfc\xdd\ +\xa6\xfa\xd5\xab\xf7\xea\xf3\xdf\xd5\xd3\x22\xc1Xw\x11\xb0\ +\x03X\x0b\x5c\x0d,\xa1Y\x9c\x02\xa6\x81)`\xa7\x8a\ +?\x0e\xd0\x020\xd6\xdd\x0c\xbc\x02,\x1fXx\xd5\x98\ +\x03\xb6\xa8\xf8\xf7[I\xcf\xcf0<\xc1w\x99\x03V\ +\xb7\x09\xd3f\xd8\x82\x87\x10\xf3c\x1d\xc2\x9cOsz\ +\x91\x83)\xcbH\xea\xf7\xda\x0ea\xc1\xf6rZ\xc5w\ +\x16)\xa0J\x18\xeb\xe6\xf9o\x12k\xda4o\xb7\xa9\ +\xc2\x92\xf6\xa0#\xa8\xcb\xff\x09\x0c\x9a\xa1O\xa0\xa9\xbb\ +\xcd=\xc0]\xc0K*\xfe\xdd\x22\xdb\xc6\x8d\x80\xb1\xee\ +\x11\xc0\x03\xe3\xc0ac\xddnc]\xeeN\xd9\xa8\x04\ +\x8cu\xe3\xc0S=E-\xe0A\xe0\x85\xbc:\x8d\x99\ +B\xc6\xbau\xc0\xcb$\x023\xc5Vc\xdd\x91\xacz\ +\x8d\x18\x01c\xddu\xc0\x1b\xc0\xd2\x02\xb3\x0dY\x85\x03\ +O\xc0Xw\x19 \xc0\xb2\x88\xe9\x8bY\x85\x03M\xc0\ +Xw\x09p\x98\xb8\x1a~R\xc5\xbf\x9a\xf5``\x09\ +\x18\xeb\x96\x01\x87\x80\xb1\x88\xe9>\xe0\xd1\xbc\x87\x03I\ +\xc0X\xb7\x14x\x13\xb86b\xfa60\xa1\xe2\xff\xc9\ +3X\xf4\x04\x8cumB\xaf\x9a\x88\xe9'\xc0\xdd*\ +~\xbe\xc8h\x10#\xf04\xe0\x226_\x01\x1bU\xfc\ +o1g\xa5\x120\xd6\xb5\x92\x9e\xab\x85\xb1n;\xf0\ +p\xc4\xec{`}\xf7\xd6!FaPI\xe0\xdb\x80\ +?\x80ic\xdd\x9aR\x91f\xfb\x9a\x00\x1e\x8f\x98\xfd\ +\x02\xdc\xaa\xe2\xbf-\xeb77\x81D\x7fL\x12\x8e\xf6\ +\xb3\x80\xab\x80\xf7\x8cuW\x94u\xde\xe3k\x13\xb0'\ +b\xf6\x17p\x87\x8a\xff\xbc\x8a\xef\xa2\x11x\x0e\xd8\x94\ +*[\x0e\xa8\xb1\xee\xd2\xb2\x0d\x18\xebn\x00^c\xe1\ +\x0by/\x7f\x03\xf7\xaa\xf8\x0f\xcb\xfa\xed\x92\x99@\xd2\ +c\x0f\xe4\xd4YA\x18\x89\x151\xe7\xc6\xbaU\xc0A\ +`4b\xfa\x90\x8a?\x10\xf3\x97E\xde\x08\x5c\x10\xa9\ +7FH\xe2\xe2<\x03c\xddJ\xc2)\x1b\xf3\xb5K\ +\xc5?\x1f\xb1\xc9%/\x81\xfd\x84}\xb8\x88+\x81w\ +\x8cu\xe7\xa5\x1f\x18\xeb\xce'\x04\xbf2\xe2c\x8f\x8a\ +\xdf\x11\x8d\xb2\x80\xcc\x04T\xfc)\x82\xfa\xcb\x94\xb0=\ +\x5c\x03\x1c2\xd6\x9d\xd3-0\xd6\x8d\x12\xa6\xcd\xaaH\ +\xddI\x82\xd6\xafE\xee\x22V\xf1'\x80[\x80\xa3\x11\ +\x1f\xd7\x03\x07\x8du\xa3\xc6\xba\x11\xc2\x82\xbd1Rg\ +\x0a\x18W\xf1\xb5o\x00\x0b\xcf\x01\x15\xff#\xb0\x0e\x98\ +\x8d\xf8\xb9\x098@\xd8*\xd3;W\x9ai\xe0v\x15\ +\xffg\xc9\x18\x0b\x89\x9e\xae*~\x96\xa0[~\x88\x98\ +n\x00&\x226\xb3\x84\x83\xea\xe7r\xe1\xc5)%\x0f\ +T\xfc\xd7\x84\x91\xf8\xa9F['\x08\x12\xe1\xbb\x1a>\ +\x16PZ\xdf\xa8\xf8\x19`=\xf0k\x1f\xed\xfc\x0e\xdc\ +\xa6\xe2\xbf\xec\xa3n!\x95\x04\x9a\x8a\xff\x14\xd8\x98\x04\ +T\x96y`\xb3\x8a\xff\xb8J[e\xa9\xac0U\xfc\ +\x14p'A\xbb\x94\xe1~\x15\xffV\xd5v\xca\xd2\x97\ +DNn\xcb6\x13z\xb7\x88\xed*~o?m\x94\ +\xa5o\x8d\xaf\xe2'\x81\xad\x04!\x96\xc5\xb3*\xfe\x89\ +~\xfd\x97\xa5\xd6K\x8a\x8a\xdf\x0f\xdc\xc7\xc25\xb1\x1b\ +\xd8V\xc7wYj\xdf\xcc\xa9\xf8}\xc6\xba\x8f\x08\x07\ +\xd8\xb9\xc0\x07\xc9:Y\x14\xce\xc8\xd5\xa2\x8a\xff\x06x\ +\xe6L\xf8\xaaJ\x9b\xf0\x05|X9\xd9!h\x93\xde\ +\xfb\x99\x91\xe4k`\x13I\xbf\xd5Mw\x08\xca0}\ +\xc1T\xf4\xfa\xd7$\xa6\xda\xc0N\xc2g\xfbac\x0e\ +\xd8\xd5N\xee_\xb60\x5cIt\xff\xecq|\x04\xe0\ +\xd8\xd1\x99cc\x97\xaf\xde\x0b\x9cM\xf8\xf0}!\xcd\ +\x9bF'\x81\xcf\x80\xd7\x01\xa7\xe2\xbf\x00\xf8\x17]\x81\ +\x0b8\xb3\xfa \x9c\x00\x00\x00\x00IEND\xaeB\ +`\x82\ +\x00\x00\x01W\ +\x89\ +PNG\x0d\x0a\x1a\x0a\x00\x00\x00\x0dIHDR\x00\ +\x00\x000\x00\x00\x000\x08\x06\x00\x00\x00W\x02\xf9\x87\ +\x00\x00\x00\x09pHYs\x00\x00\x0b\x13\x00\x00\x0b\x13\ +\x01\x00\x9a\x9c\x18\x00\x00\x01\x09IDATh\x81\xed\ +\xda\xcdm\xc2@\x14E\xe1\xf3\x8cI\x05Q\x9aH6\ +\xecY\xd1\x05\xc5\x90Ej\xa3\x04R\x04\x884`\x82\ +n\x163\xf9\xb1\xa5(DH\x5c[z\xdf\x8e\xc1\x8b\ +w\x8c\xcdf&\xa8$\xdd\x03\xcf\xc0\x12x\x02\xe6\x8c\ +\xcb\x09\xd8\x01[\xe0%\x22\x8e_\xdfHZI\xdak\ +:\xf6\x92V\x00\xa1r\xe7_\x81\x07\xc7m\xbd\xc2\x01\ +xl(\x8f\xcd\xd4\x86\x872\xf3\xa6\xa5<\xf3C\xe7\ +\x1b\x0fs\xa9\xd9\xe0\xf32$u\xf4_\xd8sD\xb4\ +7\x1c\xeab\x92\xde\xe9G\x9c\x1a\xc6\xf7o\xf3\x1f\xf3\ +\xc6=\xc1\xb52\xc0-\x03\xdc2\xc0-\x03\xdc2\xc0\ +-\x03\xdc2\xc0-\x03\xdc2\xc0-\x03\xdc2\xc0-\ +\x03\xdc2\xc0-\x03\xdc2\xc0-\x03\xdc2\xc0\xad\xa1\ +\xec\x80OU\xd7R\xb6\xef\x17?\x16gu7p\x8c\ +\x86\xdb\xac\xbb\x96r\xf6`\xf1\xc7\x85c\xb5\x9d\xfeQ\ +\x83z\xeac]\x17\xa6\xe2\x00\xac#\xe2\x18\x9f+\xf5\ +\x97\xd8\xf0}\xdc\xe6\xce4\xdco:\xfa\xc7m\xde\x00\ +>\x00G\xd7\xea\xb1\xadi\xe1\xd6\x00\x00\x00\x00IE\ +ND\xaeB`\x82\ +\x00\x00\x01\xfc\ +\x89\ +PNG\x0d\x0a\x1a\x0a\x00\x00\x00\x0dIHDR\x00\ +\x00\x000\x00\x00\x000\x08\x06\x00\x00\x00W\x02\xf9\x87\ +\x00\x00\x00\x09pHYs\x00\x00\x0b\x13\x00\x00\x0b\x13\ +\x01\x00\x9a\x9c\x18\x00\x00\x01\xaeIDATh\x81\xed\ +\x9a\xbdJ\x03A\x14FO6\x1b\xb0\xd0J\xf1\x01\x14\ +\xabh\x91\xc6*X\xb8\x16\xb2\x88v\x0b\xe9}\x01\x1f\ +@\x8b\xf8\x00\xbe\x80\x85\x9d0b\xa32U\xc6B\xf2\ +\x02B\x92F\x83}H'6\xf9\x01\x8b\xdd@\x12\xb2\ +\x89k~f7\xcc\xe9v\xef\x14\xdfY\xee\x0c\x0bw\ +R\x048\xae\xb7\x01\x5c\x01y`\x17\xc8\x10/\xda@\ +\x05(\x03E%E\x13 \x05\xe0\xb8\xde!p\x0fl\ +j\x8b\x17\x8d\x06PPR\xbc\xa6\x82/_%9\xe1\ +{4\x80\xac\x85\xdf6I\x0b\x0f~\xe6K\x1b\xbf\xe7\ +\x87\xe9.8\xcc_I\x0f=\xe7m\xfc\x0d\xdbOW\ +Ia/(P$\x1c\xd7\xeb0(\xb1g\x11\xbf\xd3\ +&\x0a\x19Kw\x82i1\x02\xba1\x02\xba1\x02\xba\ +1\x02\xba1\x02\xba1\x02\xba\x99\xf8\xdb\xec\xb8\xde\x11\ +\xb0\x0f\xac\xce?\xce\x00\x1d\xa0\x06<+)~\xc2\x16\ +\x85\x0a8\xaeg\x03\x8f\xc0\xe9\xec\xb3E\xa2\xee\xb8\xde\ +\xb1\x92\xe2sTq\x5c\x0b]\xa0?<\xc06p\x1b\ +V\x1c'p2\xfb,\xff\xe6\xc0q\xbd\xb5Q\x85\xc4\ +o\xe2q\x02/\x0bK1\x997%\xc5\xf7\xa8\xc28\ +\x81\x1b\xe0i>y\x22Q\x07\xce\xc3\x8a\xa1\xa7\x90\x92\ +\xa2\x03\x9c%\xf6\x18\xed\xa1\xa4(\x01\xa5\x19\x06\x9b)\ +K\xbd\x89\x13\x81\x11\xd0\x8d\x11\xd0\x8d\x11\xd0\x8d\x11\xd0\ +\x8d\x11\xd0\xcdR\x08\xb4u\x87\x98\x82\x96\x8d?\xbe\xcf\ +\xf5\xbdL\x07\xd3\xc082\xaa_[\ +;\xd9;`\x05\x7f\xf0\xbdN\xfc\xda\xa8\x05\xbc\x03\x0f\ +\x80\xa7\xa4\xa8\x01\xfc\x02Q\xab\x5c\x8a?\xde\xe3Y\x00\ +\x00\x00\x00IEND\xaeB`\x82\ \x00\x00\x00\xa6\ \x89\ PNG\x0d\x0a\x1a\x0a\x00\x00\x00\x0dIHDR\x00\ +\x00\x00\x09\x00\x00\x00\x06\x08\x04\x00\x00\x00\xbb\xce|N\ +\x00\x00\x00\x01sRGB\x00\xae\xce\x1c\xe9\x00\x00\x00\ +\x02bKGD\x00\xff\x87\x8f\xcc\xbf\x00\x00\x00\x09p\ +HYs\x00\x00\x0b\x13\x00\x00\x0b\x13\x01\x00\x9a\x9c\x18\ +\x00\x00\x00\x07tIME\x07\xdc\x08\x17\x08\x15;\xdc\ +;\x0c\x9b\x00\x00\x00*IDAT\x08\xd7c`\xc0\ +\x00\x8c\x0c\x0cs> \x0b\xa4\x08020 \x0b\xa6\ +\x08000B\x98\x10\xc1\x14\x01\x14\x13P\xb5\xa3\x01\ +\x00\xc6\xb9\x07\x90]f\x1f\x83\x00\x00\x00\x00IEN\ +D\xaeB`\x82\ +\x00\x00\x01i\ +\x89\ +PNG\x0d\x0a\x1a\x0a\x00\x00\x00\x0dIHDR\x00\ +\x00\x000\x00\x00\x000\x08\x06\x00\x00\x00W\x02\xf9\x87\ +\x00\x00\x00\x09pHYs\x00\x00\x0b\x13\x00\x00\x0b\x13\ +\x01\x00\x9a\x9c\x18\x00\x00\x01\x1bIDATh\x81\xed\ +\xda\xb1m\xc2@\x14\x87\xf1\xcf\xc7\x91\x09P\x86pB\ +AO\xc5\x0a\xae\x90\xbc\x0a)\xc8*\x96Ry\x05*\ +F \x1e\xc2\x82\x05H\x90R\xdcY\x01KQbE\ +\xe2\xef\x93\xde\xaf\xb3E\xf1>\xcb\xa6\xb9\x97\x11\x95u\ +3\x03^\x80%\xf0\x0cL\x19\x97\x0f\xe0\x00\xec\x81m\ +U\xe4G\x80\x0c\xa0\xac\x9b\x15\xf0\x06<\xca\xc6\x1b\xa6\ +\x05\xd6U\x91\xef\xb2\xf8\xe4\xdfIg\xf8N\x0b<9\ +\xc2k\x93\xda\xf0\x10f\xdex\xc2;\xdfw\xb9\xf30\ +\x7f5\xe9]/=\xe1\x83\xbdv\xa9\x8a\xdc\xdfi\xa0\ +A\xca\xba\xf9\xe46b\xee\x18\xdf\xbf\xcd\x10S\xa7\x9e\ +\xe0\xbf,@\xcd\x02\xd4,@\xcd\x02\xd4,@\xcd\x02\ +\xd4,@\xcd\x02\xd4,@\xcd\x02\xd4,@\xcd\x02\xd4\ +,@\xcd\x02\xd4,@\xcd\x02\xd4,@\xcd\x11N\xc0\ +Su\xf6\x84\xe3\xfb\xc5\xd5\xcdI<\x0d\x1c\xa3\xfe1\ +\xeb\xc1\x13v\x0f\x16\xbf\xfcp\xac\xf6\x0e\xd8\x12\x8e\xed\ +S\xd3\x02\xaf.n}\xacI+\xa2[\xf68f\xdd\ +\x9d\xb8\xf4\xb1\xe1{\xdd\xe6A4\xdcO\xce\xdc\xae\xdb\ +\x9c\x00\xbe\x00\x9f\xf64>6O7\x81\x00\x00\x00\x00\ +IEND\xaeB`\x82\ +\x00\x00\x03\xfb\ +\x89\ +PNG\x0d\x0a\x1a\x0a\x00\x00\x00\x0dIHDR\x00\ +\x00\x000\x00\x00\x000\x08\x06\x00\x00\x00W\x02\xf9\x87\ +\x00\x00\x00\x09pHYs\x00\x00\x0b\x13\x00\x00\x0b\x13\ +\x01\x00\x9a\x9c\x18\x00\x00\x03\xadIDATh\x81\xed\ +\x9aO\xa8\x15U\x1c\xc7?\xf7\xbe\xab\xf2 \xa3\x22m\ +\xa1|!\x09*K(\xdaD\xb9\x88RL\xccjQ\ +\xf9\xa4\x85\xf1\xa0\x16Q\xe4.\x10tQD\x8b\x16\x15\ +%\x81\xb5(\x04\xad\xc0\xe2ad\xf6\x97\xe0E\x10\xb4\ +\xa9'DE\xc4\x17\xa2\x125\x0a*\xff<\xab\xc5\x99\ +[\xd7y3s\xce\xdcko\xee\x05?\xbb9\xf3;\ +\xbf\xf3\xfb\x9d3\xe7\x9c\xef\x9c\x99\x16\x19\xb6/\x06v\ +\x00\xab\x81\xab\x81\x05\x0c\x17\xa7\x80\x19`\x1axL\xd2\ +\x11\x80\x16\x80\xed\x9b\x81\xbd\xc0\xd2\xc6\xc2\xab\xc7a`\ +\xb3\xa4\x0f[Y\xcf\x1fbt\x82\xefr\x18\xb8\xaaM\ +xlF-x\x081o\xef\x10\x9e\xf9<\xa7\xe79\ +\x98T\xc6r\xd7\xab;\x84\x09\xdb\xcbiI\x9dy\x0a\ +\xa8\x16\xb6g93\x89Um\x86o\xb5\xa9\xc3\x82v\ +\xd3\x11\x0c\xca\xb9\x04\x9a\xe6\x5c\x02\xff\x07\xb6\xef\xb6\xbd\ +\xd7\xf6\xda\x98\xed\xd0%`\xfb\x11\xe0u`\x028h\ +{\xa7\xed\xd2\x95r\xa8\x12\xb0=\x01<\xddS\xd4\x02\ +\x1e\x04^,\xab34\x1b\x96\xed5\xc0+d\x023\ +\xc7\x16\xdb_\x16\xd5\x1b\x8a\x11\xb0}\x1d\xf0\x06\xb0\xb0\ +\xc2l}Qa\xe3\x09\xd8\xbe\x0cx\x1bX\x1c1}\ +\xa9\xa8\xb0\xd1\x04l_\x02\x1c$\xae\x86\x9f\x92\xf4j\ +\xd1\x8d\xc6\x12\xb0\xbd\x188\x00\xac\x88\x98\xee\x06\x1e-\ +\xbb\xd9H\x02\xb6\x17\x02o\x02\xd7FL\xdf\x01&%\ +\xfd]f0\xef\x09\xd8n\x13z\xf5\x96\x88\xe9g\xc0\ +]\x92f\xab\x8c\x9a\x18\x81g\x80{\x226_\x03\x1b\ +$\xfd\x1es\x96\x94\x80\xedV\xd6s\x03a{\x1b\xf0\ +p\xc4\xecG`]\xf7\xd4!FePY\xe0[\x81\ +?\x81\x19\xdb\xab\x92\x22-\xf65\x09<\x111\xfb\x15\ +\xb8U\xd2\xf7\xa9~K\x13\xc8\xf4\xc7\x14ak_\x04\ +\x5c\x09\xbco\xfb\xf2T\xe7=\xbe6\x02\xbb\x22f'\ +\x80;$}Q\xc7w\xd5\x08<\x0fl\xcc\x95-\x05\ +>\xb0}ij\x03\xb6o\x00^c\xee\x0by/\x7f\ +\x01\xf7J\xfa8\xd5o\x97\xc2\x04\xb2\x1e{\xa0\xa4\xce\ +2\xc2H,\x8b9\xb7\xbd\x12\xd8\x0f\x8cGL\x1f\x92\ +\xb4/\xe6\xaf\x88\xb2\x11\xb8(Ro\x05!\x89%e\ +\x06\xb6\x97\x13v\xd9\x98\xaf\xc7%\xbd\x10\xb1)\xa5,\ +\x81=\x84u\xb8\x8a+\x80wm_\x90\xbfa\xfbB\ +B\xf0\xcb#>vI\xda\x11\x8d\xb2\x82\xc2\x04$\x9d\ +\x22\xa8\xbfB\x09\xdb\xc35\xc0\x01\xdb\xe7u\x0bl\x8f\ +\x13\x1e\x9b\x95\x91\xbaS\x04\xad?\x10\xa5\x93X\xd21\ +`-\xf0M\xc4\xc7\xf5\xc0~\xdb\xe3\xb6\xc7\x08\x13\xf6\ +\xc6H\x9di`B\xd2\xc0'\x80\x95\xfb\x80\xa4\x9f\x81\ +5\x80#~n\x02\xf6\x11\x96\xca\xfc\xca\x95g\x06\xb8\ +]\xd2\xf1\xc4\x18+\x89\xee\xae\x92L\xd0-?EL\ +\xd7\x03\x93\x11\x1b\x136\xaa_\xd2\xc2\x8b\x93$\x0f$\ +}K\x18\x89\xa3\x03\xb4u\x8c \x11~\x18\xc0\xc7\x1c\ +\x92\xf5\x8d\xa4C\xc0:\xe0\xb7>\xda\xf9\x03\xb8M\xd2\ +W}\xd4\xad\xa4\x96@\x93\xf49\xb0!\x0b(\x95Y\ +`\x93\xa4O\xeb\xb4\x95Jm\x85)i\x1a\xb8\x93\xa0\ +]R\xb8_\xd2[u\xdbI\xa5/\x89,\xe9=`\ +\x13\xa1w\xab\xd8&\xe9\xe5~\xdaH\xa5o\x8d/i\ +\x0a\xd8B\x10bE<'\xe9\xc9~\xfd\xa72\xd0K\ +\x8a\xa4=\xc0}\xcc\x9d\x13;\x81\xad\x83\xf8Ne\xe0\ +\x939I\xbbm\x7fB\xd8\xc0\xce\x07>\xca\xe6\xc9\xbc\ +pV\x8e\x16%}\x07<{6|\xd5\xa5M\xf8\x02\ +>\xaa\x9c\xec\x10\xb4I\xef\xf9\xccX\xf65p\x18\xc9\ +\xbf\xd5\xcdt\x08\xca0\x7f\xc0T\xf5\xfa7LL\x8f\ +\xfe\xaf\x06\xd9\xf9\xcb\xe6\xac`T\xe8\xfe\xecq\xe4\xdf\ +\x8f\x09\xd9Hl\xe7\xbf\xdfm\xaa\xce\xea\x9b\xe0$g\ +\xfens\x14\xe0\x1f\x0aC\x12kO\xfd?\x13\x00\x00\ +\x00\x00IEND\xaeB`\x82\ +\x00\x00\x03\xff\ +\x89\ +PNG\x0d\x0a\x1a\x0a\x00\x00\x00\x0dIHDR\x00\ +\x00\x000\x00\x00\x000\x08\x06\x00\x00\x00W\x02\xf9\x87\ +\x00\x00\x00\x09pHYs\x00\x00\x0b\x13\x00\x00\x0b\x13\ +\x01\x00\x9a\x9c\x18\x00\x00\x03\xb1IDATh\x81\xed\ +\x9aOh\x1eE\x18\xc6\x7f\x9b&\x85\x82\x15\x15\xabB\ +\xcb\x03\x06\x05\xa9\x0a\x8a\xb7R<\xd4\x96\xaa\xb5\xe0A\ +\xad\xc5C%\xa0\x07Q\xccM(\xb4\x07E\x80\xae9\x1f\xc0\xff\ +\x81\xed\xbbm\xbff{W\xca6\x0b!\x84BY\xa7\ +\xdb\xa8\xedG\x81#\xf9\xcf\x00\x1c\x05&%-\x94l\ +\xa3\xc35\x03\xb6\xef\x05\x9e\xe9)\xca\x80\x87\x80\x17\xab\ +\xda\x0c\xcd\x81e{'\xf0\x0aQt\x91\x03\xb6\xbf*\ +k7\x143`\xfb&\xe0M`}\x8d\xd9me\x85\ +\x9d\x07`\xfb*\xe0]`c\xc2\xf4\xa5\xb2\xc2N\x03\ +\xb0}9\xf0>\xe9l\xf8iI\xaf\x97Ut\x16\x80\ +\xed\x8d\xc0\x140\x9e0}\x15x\xac\xaa\xb2\x93\x00l\ +\xaf\x07\xde\x02nL\x98\xbe\x07LH*n\xf5K\xac\ +z\x00\xb6G\x88\xa3zK\xc2\xf4\x0b\xe0.I\x8bu\ +F]\xcc\xc0\x11\xe0\x9e\x84\xcd\xb7\xc0\x1eI\xbf\xa5\x9c\ +5\x0a\xc0v\x96\x8f\xdc@\xd8>\x08<\x920\xfb\x11\ +\xd8-i.a\x07$\x02\xc8\x85O\x02\x7f\x003\xb6\ +\xafo\xa4\xb4\xdc\xd7\x04\xf0d\xc2\xec\x17\xe0VI\xdf\ +7\xf5[\x99\x0b\xd9\x1e\x03\x8e\x03{{\xeaf\x81\x9b\ +%}\xd3\xb4\x03\x00\xdb{\x89\x8b\xb6x\xa7\xed\xe5,\ +q\xe4?\xab2\xe87\x17z\x8e\xe5\xe2!\xee\xd7\x1f\ +\xdb\xbe\xb2Vq\x0f\xb6\xb7\x01o\x14;.\xf0\x17p\ +_\x9d\xf8*J\x03\xc8G\xec\xc1\x8a6\x9b\x81\x8fl\ +oN9\xb7\xbd\x15x\x1b\xd8\x900}X\xd2\xf1\x94\ +\xbf2\xaaf\xe0\x92D\xbbqb\x10\x9b\xaa\x0clo\ +!\x9e\xb2)_OH:\x9a\xb0\xa9\xa4*\x80c\xc4\ +}\xb8\x8ek\x80\x0fl_T\xac\xb0}1Q\xfc\x96\ +\x84\x8f\x17$\x1dN\xaa\xac\xa14\x00I\x0b\xc4\xec\xaf\ +4\x85\xed\xe1\x06`\xca\xf6\x05\xff\x14\xd8\xde@|l\ +\xb6&\xda\x9e \xe6\xfa\x03Q{#\xcb\x93\xad\x93\xc0\ +\xd5\x09?\x9f\x02\xb7\x03\xf3\xc4\xdd\xa6\xb8\xf8\x8bL\x03\ +\xbb$\xfd\xd9\x8f\xd8\xb2](y\xa5\xb4-b\x10J\ +\xf8\x9f\x22\x1eB\x13\x09\xbb\x19\xe2V\xfcs#\xd5=\ +\xb4\x0a\x00\x96r\xf6\x93\xc0\x15\xfdvZ\xc0\xc06I\ +?\xb4i\xdc\xfaN,\xe9;`'p\xa6M\xc79\ +?\x11\x0f\xaaV\xe2\xabh\x9c\xdfH:\x05\xec\x06~\ +m\xd1\xcf\xef\xc0\x1d\x92\xben\xd1\xb6\x96\xbe\x124I\ +_\x02{rAMY\x04\xf6I\xfa\xbc\x9f\xbe\x9a\xd2\ +w\x86)i\x1a\xb8\x93\x98\xbb4\xe1\x01I\xef\xf4\xdb\ +OSZ\xa5\xc8\x92>\x04\xf6\x11G\xb7\x8e\x83\x92^\ +n\xd3GSZ\xe7\xf8\x92N\x00\x07\x88\x89X\x19\xcf\ +Jz\xaa\xad\xff\xa6\x0ctI\x91t\x0c\xb8\x9f\xff\xae\ +\x89\xe7\x81\xc9A|7eE\xde\x8d\xda\x1e'\x9e\xbe\ +\x17\x02\x9f\xe4\xebd\xc5i}\x90\x0d\x0bU\x07\xd9B\ +7rV\x84\xf9Qbn\xd2\xfb~f]\x1e\xe90\ +R\xbc\xd5\xcd\x8c\x123\xc3\xe2\x0b\xa6\xba\xeb\xdf01\ +\xbd\xf6?5\xc8\xbf\xfa\xd8\x9f\x17\xac\x15f\x81\xfdY\ +\x96\xcd-\xfd\x99\x90\xcf\xc4!\xfe\xfd\xdc\xa6\xee]}\ +\x17\xcc\xb3\xfcs\x9b3\x00\x7f\x03\xd9\x1a\xfb\xdb\xbb\xa7\ +\x8f\x07\x00\x00\x00\x00IEND\xaeB`\x82\ +\x00\x00\x00\xa0\ +\x89\ +PNG\x0d\x0a\x1a\x0a\x00\x00\x00\x0dIHDR\x00\ \x00\x00\x06\x00\x00\x00\x09\x08\x04\x00\x00\x00\xbb\x93\x95\x16\ \x00\x00\x00\x01sRGB\x00\xae\xce\x1c\xe9\x00\x00\x00\ \x02bKGD\x00\xff\x87\x8f\xcc\xbf\x00\x00\x00\x09p\ HYs\x00\x00\x0b\x13\x00\x00\x0b\x13\x01\x00\x9a\x9c\x18\ -\x00\x00\x00\x07tIME\x07\xdc\x08\x17\x14\x1f \xb9\ -\x8dw\xe9\x00\x00\x00*IDAT\x08\xd7c`\xc0\ -\x06\xe6|```B0\xa1\x1c\x08\x93\x81\x81\x09\xc1\ -d``b`H\x11@\xe2 s\x19\x90\x8d@\x02\ -\x00#\xed\x08\xafd\x9f\x0f\x15\x00\x00\x00\x00IEN\ +\x00\x00\x00\x07tIME\x07\xdc\x08\x17\x14\x1f\x0d\xfc\ +R+\x9c\x00\x00\x00$IDAT\x08\xd7c`@\ +\x05s>\xc0XL\xc8\x5c&dY&d\xc5pN\ +\x8a\x00\x9c\x93\x22\x80a\x1a\x0a\x00\x00)\x95\x08\xaf\x88\ +\xac\xba4\x00\x00\x00\x00IEND\xaeB`\x82\ +\x00\x00\x00\xa6\ +\x89\ +PNG\x0d\x0a\x1a\x0a\x00\x00\x00\x0dIHDR\x00\ +\x00\x00\x09\x00\x00\x00\x06\x08\x04\x00\x00\x00\xbb\xce|N\ +\x00\x00\x00\x01sRGB\x00\xae\xce\x1c\xe9\x00\x00\x00\ +\x02bKGD\x00\xff\x87\x8f\xcc\xbf\x00\x00\x00\x09p\ +HYs\x00\x00\x0b\x13\x00\x00\x0b\x13\x01\x00\x9a\x9c\x18\ +\x00\x00\x00\x07tIME\x07\xdc\x08\x17\x08\x15;\xdc\ +;\x0c\x9b\x00\x00\x00*IDAT\x08\xd7c`\xc0\ +\x00\x8c\x0c\x0cs> \x0b\xa4\x08020 \x0b\xa6\ +\x08000B\x98\x10\xc1\x14\x01\x14\x13P\xb5\xa3\x01\ +\x00\xc6\xb9\x07\x90]f\x1f\x83\x00\x00\x00\x00IEN\ D\xaeB`\x82\ +\x00\x00\x00\xa0\ +\x89\ +PNG\x0d\x0a\x1a\x0a\x00\x00\x00\x0dIHDR\x00\ +\x00\x00\x06\x00\x00\x00\x09\x08\x04\x00\x00\x00\xbb\x93\x95\x16\ +\x00\x00\x00\x01sRGB\x00\xae\xce\x1c\xe9\x00\x00\x00\ +\x02bKGD\x00\xff\x87\x8f\xcc\xbf\x00\x00\x00\x09p\ +HYs\x00\x00\x0b\x13\x00\x00\x0b\x13\x01\x00\x9a\x9c\x18\ +\x00\x00\x00\x07tIME\x07\xdc\x08\x17\x14\x1c\x1f$\ +\xc6\x09\x17\x00\x00\x00$IDAT\x08\xd7c`@\ +\x05\xff\xcf\xc3XL\xc8\x5c&dY&d\xc5p\x0e\ +\xa3!\x9c\xc3h\x88a\x1a\x0a\x00\x00m\x84\x09u7\ +\x9e\xd9#\x00\x00\x00\x00IEND\xaeB`\x82\ +\x00\x00\x01[\ +\x89\ +PNG\x0d\x0a\x1a\x0a\x00\x00\x00\x0dIHDR\x00\ +\x00\x000\x00\x00\x000\x08\x06\x00\x00\x00W\x02\xf9\x87\ +\x00\x00\x00\x09pHYs\x00\x00\x0b\x13\x00\x00\x0b\x13\ +\x01\x00\x9a\x9c\x18\x00\x00\x01\x0dIDATh\x81\xed\ +\xda\xb1m\x02A\x10F\xe1w\xc7\xe2\x0a,\x87\xd3\x00\ +8 'r\x17\x14\x83\x037\xe3.\x1cQ\x0240\ +!\xc2\x0d`\x90\x1c\xec\x9e\x0c'Y\xf6\x09\x89\xffV\ +\x9a/cE0\x0f\x0e\x92\x9d\x86\xc2\xdd\x1f\x81W`\ +\x09\xcc\x81)\xe3\xf2\x05l\x81\x0d\xf0ff\x07\x80\x06\ +\xc0\xdd_\x80w\xe0I6\xde0{`ef\x1fM\ +\xf9\xe4w\xd43|g\x0f\xccZ\xf2cS\xdb\xf0\x90\ +g^'\xf23\xdfw\xbe\xf30\xff5\xe9\xbd^&\ +\xf2\x0f\xf6\xd2\xd9\xcc\xd2\x9d\x06\x1a\xc4\xddO\x5cG<\ +\xb7\x8c\xef\xdff\x88i\xab\x9e\xe0V\x11\xa0\x16\x01j\ +\x11\xa0\x16\x01j\x11\xa0\x16\x01j\x11\xa0\x16\x01j\x11\ +\xa0\x16\x01j\x11\xa0\x16\x01j\x11\xa0\x16\x01j\x11\xa0\ +\x16\x01j\x11\xa0\xd6\x92o\xc0kuL\xe4\xeb\xfb\xc5\ +\xc5\xe1\xa4\xdc\x06\x8eQ\xff\x9au\x9b\xc8\xbb\x07\x8b?\ +\xde8V\x9b\xfaW\x0d\xca\xd6\xc7\xaa\x1c\xd4\xa2[\xf6\ +84\xddI\xf9&\xd6\xfc\xac\xdb<\x88\x86\xfb\xcd\x91\ +\xebu\x9bO\x80oV\x016\x1ew\x0d\xa5B\x00\x00\ +\x00\x00IEND\xaeB`\x82\ +\x00\x00\x00\xa6\ +\x89\ +PNG\x0d\x0a\x1a\x0a\x00\x00\x00\x0dIHDR\x00\ +\x00\x00\x09\x00\x00\x00\x06\x08\x04\x00\x00\x00\xbb\xce|N\ +\x00\x00\x00\x01sRGB\x00\xae\xce\x1c\xe9\x00\x00\x00\ +\x02bKGD\x00\xff\x87\x8f\xcc\xbf\x00\x00\x00\x09p\ +HYs\x00\x00\x0b\x13\x00\x00\x0b\x13\x01\x00\x9a\x9c\x18\ +\x00\x00\x00\x07tIME\x07\xdc\x08\x17\x08\x15;\xdc\ +;\x0c\x9b\x00\x00\x00*IDAT\x08\xd7c`\xc0\ +\x00\x8c\x0c\x0cs> \x0b\xa4\x08020 \x0b\xa6\ +\x08000B\x98\x10\xc1\x14\x01\x14\x13P\xb5\xa3\x01\ +\x00\xc6\xb9\x07\x90]f\x1f\x83\x00\x00\x00\x00IEN\ +D\xaeB`\x82\ +\x00\x00\x00\x9f\ +\x89\ +PNG\x0d\x0a\x1a\x0a\x00\x00\x00\x0dIHDR\x00\ +\x00\x00\x09\x00\x00\x00\x06\x08\x04\x00\x00\x00\xbb\xce|N\ +\x00\x00\x00\x01sRGB\x00\xae\xce\x1c\xe9\x00\x00\x00\ +\x02bKGD\x00\xff\x87\x8f\xcc\xbf\x00\x00\x00\x09p\ +HYs\x00\x00\x0b\x13\x00\x00\x0b\x13\x01\x00\x9a\x9c\x18\ +\x00\x00\x00\x07tIME\x07\xdc\x08\x17\x08\x14\x1f\xf9\ +#\xd9\x0b\x00\x00\x00#IDAT\x08\xd7c`\xc0\ +\x0d\xe6|\x80\xb1\x18\x91\x05R\x04\xe0B\x08\x15)\x02\ +\x0c\x0c\x8c\xc8\x02\x08\x95h\x00\x00\xac\xac\x07\x90Ne\ +4\xac\x00\x00\x00\x00IEND\xaeB`\x82\ \x00\x00\x07\xdd\ \x89\ PNG\x0d\x0a\x1a\x0a\x00\x00\x00\x0dIHDR\x00\ @@ -363,11 +661,11 @@ zpp\xf0\xe3\x0e.\xa4\xd2\xae\xf0\x8a\xf7\x9a\xe3V\ q[s\x5c@H\xa5\xdda\x81\x0d\x9ek\x8e\xff\xfd\ \xcf?\xcc1\xe9\x01\x1c\x00sR-q\xe4J\x1bi\ \x00\x00\x00\x00IEND\xaeB`\x82\ -\x00\x00\x07\xad\ +\x00\x00\x07\x06\ \x89\ PNG\x0d\x0a\x1a\x0a\x00\x00\x00\x0dIHDR\x00\ -\x00\x00\x07\x00\x00\x00\x0a\x08\x06\x00\x00\x00x\xccD\x0d\ -\x00\x00\x05RiTXtXML:com.\ +\x00\x00\x0a\x00\x00\x00\x07\x08\x06\x00\x00\x001\xac\xdcc\ +\x00\x00\x04\xb0iTXtXML:com.\ adobe.xmp\x00\x00\x00\x00\x00\x0a \x0a \x0a \ -\x0a branch_close<\ -/rdf:li>\x0a \x0a \x0a \x0a \x0a \x0a \x0a \x0a \x0a <\ -/rdf:RDF>\x0a\x0a$\xe15\x97\x00\x00\ -\x01\x83iCCPsRGB IEC61\ -966-2.1\x00\x00(\x91u\x91\xcf+D\ -Q\x14\xc7?fh\xfc\x18\x8dba1e\x12\x16B\ -\x83\x12\x1b\x8b\x99\x18\x0a\x8b\x99Q~mf\x9ey3\ -j\xdex\xbd7\xd2d\xabl\xa7(\xb1\xf1k\xc1_\ -\xc0VY+E\xa4d\xa7\xac\x89\x0dz\xce\x9bQ#\ -\x99s;\xf7|\xee\xf7\xdes\xba\xf7\x5cpD\xd3\x8a\ -fV\xfaA\xcbd\x8dp(\xe0\x9b\x99\x9d\xf3\xb9\x9e\ -\xa8\xa2\x85\x1a:\xf1\xc6\x14S\x9f\x8c\x8cF)k\xef\ -\xb7T\xd8\xf1\xba\xdb\xaeU\xfe\xdc\xbfV\xb7\x980\x15\ -\xa8\xa8\x16\x1eVt#+<&<\xb1\x9a\xd5m\xde\ -\x12nRR\xb1E\xe1\x13\xe1.C.(|c\xeb\ -\xf1\x22?\xdb\x9c,\xf2\xa7\xcdF4\x1c\x04G\x83\xb0\ -/\xf9\x8b\xe3\xbfXI\x19\x9a\xb0\xbc\x9c6-\xbd\xa2\ -\xfc\xdc\xc7~\x89;\x91\x99\x8eHl\x15\xf7b\x12&\ -D\x00\x1f\xe3\x8c\x10d\x80^\x86d\x1e\xa0\x9b>z\ -dE\x99|\x7f!\x7f\x8ae\xc9Ud\xd6\xc9a\xb0\ -D\x92\x14Y\xbaD]\x91\xea\x09\x89\xaa\xe8\x09\x19i\ -rv\xff\xff\xf6\xd5T\xfb\xfb\x8a\xd5\xdd\x01\xa8z\xb4\ -\xac\xd7vpm\xc2W\xde\xb2>\x0e,\xeb\xeb\x10\x9c\ -\x0fp\x9e)\xe5/\xef\xc3\xe0\x9b\xe8\xf9\x92\xd6\xb6\x07\ -\x9eu8\xbd(i\xf1m8\xdb\x80\xe6{=f\xc4\ -\x0a\x92S\xdc\xa1\xaa\xf0r\x0c\xf5\xb3\xd0x\x05\xb5\xf3\ -\xc5\x9e\xfd\xecst\x07\xd15\xf9\xaaK\xd8\xd9\x85\x0e\ -9\xefY\xf8\x06\x8e\xfdg\xf8\xfd\x8a\x18\x97\x00\x00\x00\ -\x09pHYs\x00\x00\x0b\x13\x00\x00\x0b\x13\x01\x00\x9a\ -\x9c\x18\x00\x00\x00rIDAT\x18\x95m\xcf1\x0a\ -\xc2P\x14D\xd1\xe8\x02\xb4W\x08\xd6Ia\x99JC\ -t\x15\x82\xabI6(\xee@\x04\xdb\xa8\x95Xx,\ -\xf2\x09\xe1\xf3\x07\xa6\x9a\xfb\xe0\xbe\x0c\x1b\xb4Xdq\ -p0\xe4\x82U\x0a8\xe3\x8b\x1b\x8a\x14p\xc4\x1b=\ -v)`\x8b\x07>\xa8\xe6\xd1\xfe\x0b\x9d\x85\x8eW\x0d\ -^x\xa2\x9e\x0e\xa7 tG9\x1d\xf6\xe1\x95+\xd6\ -\xb1D\x8e\x0e\xcbX\xf0\x0fR\x8ay\x18\xdc\xe2\x02p\ -\x00\x00\x00\x00IEND\xaeB`\x82\ +-31T12:30:11+02:\ +00\x22>\x0a \x0a \x0a \x0a \x0a \x0a \x0a \x0a\x0a\x85\x9d\x9f\x08\x00\x00\x01\x83\ +iCCPsRGB IEC6196\ +6-2.1\x00\x00(\x91u\x91\xcf+DQ\x14\ +\xc7?fh\xfc\x18\x8dba1e\x12\x16B\x83\x12\ +\x1b\x8b\x99\x18\x0a\x8b\x99Q~mf\x9ey3j\xde\ +x\xbd7\xd2d\xabl\xa7(\xb1\xf1k\xc1_\xc0V\ +Y+E\xa4d\xa7\xac\x89\x0dz\xce\x9bQ#\x99s\ +;\xf7|\xee\xf7\xdes\xba\xf7\x5cpD\xd3\x8afV\ +\xfaA\xcbd\x8dp(\xe0\x9b\x99\x9d\xf3\xb9\x9e\xa8\xa2\ +\x85\x1a:\xf1\xc6\x14S\x9f\x8c\x8cF)k\xef\xb7T\ +\xd8\xf1\xba\xdb\xaeU\xfe\xdc\xbfV\xb7\x980\x15\xa8\xa8\ +\x16\x1eVt#+<&<\xb1\x9a\xd5m\xde\x12n\ +RR\xb1E\xe1\x13\xe1.C.(|c\xeb\xf1\x22\ +?\xdb\x9c,\xf2\xa7\xcdF4\x1c\x04G\x83\xb0/\xf9\ +\x8b\xe3\xbfXI\x19\x9a\xb0\xbc\x9c6-\xbd\xa2\xfc\xdc\ +\xc7~\x89;\x91\x99\x8eHl\x15\xf7b\x12&D\x00\ +\x1f\xe3\x8c\x10d\x80^\x86d\x1e\xa0\x9b>zdE\ +\x99|\x7f!\x7f\x8ae\xc9Ud\xd6\xc9a\xb0D\x92\ +\x14Y\xbaD]\x91\xea\x09\x89\xaa\xe8\x09\x19irv\ +\xff\xff\xf6\xd5T\xfb\xfb\x8a\xd5\xdd\x01\xa8z\xb4\xac\xd7\ +vpm\xc2W\xde\xb2>\x0e,\xeb\xeb\x10\x9c\x0fp\ +\x9e)\xe5/\xef\xc3\xe0\x9b\xe8\xf9\x92\xd6\xb6\x07\x9eu\ +8\xbd(i\xf1m8\xdb\x80\xe6{=f\xc4\x0a\x92\ +S\xdc\xa1\xaa\xf0r\x0c\xf5\xb3\xd0x\x05\xb5\xf3\xc5\x9e\ +\xfd\xecst\x07\xd15\xf9\xaaK\xd8\xd9\x85\x0e9\xef\ +Y\xf8\x06\x8e\xfdg\xf8\xfd\x8a\x18\x97\x00\x00\x00\x09p\ +HYs\x00\x00\x0b\x13\x00\x00\x0b\x13\x01\x00\x9a\x9c\x18\ +\x00\x00\x00mIDAT\x18\x95u\xcf\xc1\x09\xc2P\ +\x10\x84\xe1\xd7\x85\x07\x9b\xd0C@\xd2\x82x\x14{0\ +W!\x8d\x84`?bKzH\xcc\x97\x83\xfb0\x04\ +\xdf\x9c\x86\x7fg\x99\xdd\x84\x0d\xaaT\x10jl\x13\x1e\ +\xbe\xba\xfe\x0951{\xe6\x8d\x0f&\x1c\x17\xa1S\xb0\ +\x11\x87\x0c/\x01\x07\xec\xb0\x0f?\xe1\xbc\xaei\xa3\xe6\ +\x85w\xf8[\xe9\xf0\xbb\x9f\xfa\xd2\x839\xdc\xa3[\xf3\ +\x19.\xa8\x89\xb50\xf7C\xa0\x00\x00\x00\x00IEN\ +D\xaeB`\x82\ +\x00\x00\x00\xa6\ +\x89\ +PNG\x0d\x0a\x1a\x0a\x00\x00\x00\x0dIHDR\x00\ +\x00\x00\x06\x00\x00\x00\x09\x08\x04\x00\x00\x00\xbb\x93\x95\x16\ +\x00\x00\x00\x01sRGB\x00\xae\xce\x1c\xe9\x00\x00\x00\ +\x02bKGD\x00\xff\x87\x8f\xcc\xbf\x00\x00\x00\x09p\ +HYs\x00\x00\x0b\x13\x00\x00\x0b\x13\x01\x00\x9a\x9c\x18\ +\x00\x00\x00\x07tIME\x07\xdc\x08\x17\x14\x1f \xb9\ +\x8dw\xe9\x00\x00\x00*IDAT\x08\xd7c`\xc0\ +\x06\xe6|```B0\xa1\x1c\x08\x93\x81\x81\x09\xc1\ +d``b`H\x11@\xe2 s\x19\x90\x8d@\x02\ +\x00#\xed\x08\xafd\x9f\x0f\x15\x00\x00\x00\x00IEN\ +D\xaeB`\x82\ \x00\x00\x00\xa6\ \x89\ PNG\x0d\x0a\x1a\x0a\x00\x00\x00\x0dIHDR\x00\ @@ -501,6 +802,57 @@ HYs\x00\x00\x0b\x13\x00\x00\x0b\x13\x01\x00\x9a\x9c\x18\ d``b``4D\xe2 s\x19\x90\x8d@\x02\ \x00d@\x09u\x86\xb3\xad\x9c\x00\x00\x00\x00IEN\ D\xaeB`\x82\ +\x00\x00\x01v\ +\x89\ +PNG\x0d\x0a\x1a\x0a\x00\x00\x00\x0dIHDR\x00\ +\x00\x000\x00\x00\x000\x08\x06\x00\x00\x00W\x02\xf9\x87\ +\x00\x00\x00\x09pHYs\x00\x00\x0b\x13\x00\x00\x0b\x13\ +\x01\x00\x9a\x9c\x18\x00\x00\x01(IDATh\x81\xed\ +\xda\xb1J\xc3P\x14\x87\xf1/7\xb7\xe0\xae\xf8\x00\x82\ +Su\xe8\xde\xc9ly\x80@\x1fF\x87\xfa\x22nB\ +\xdc\xb3\xc5\xa9/ \xb4]:t\x0f}\x82j\xc1\xe1\ +\xa6P\xb3h\x10\xfa\xcf\x85\xf3\xdbR:\x9c\xaf\xdcf\ +97\xa1\x95\xe5\xc5\x15\xf0\x04L\x81;`\xc4\xb0|\ +\x02K`\x01\xcc\xeb\xaa\xdc\x01$\x00Y^<\x00\xaf\ +\xc0\xb5l\xbc~\x1a`VW\xe5{\xd2\xfe\xf2+\xe2\ +\x19\xfe\xa8\x01\xc6\x8eplb\x1b\x1e\xc2\xcc\x8f\x9ep\ +\xe6\xbb\x0eg\x1e\xe6\xaf\xd2\xce\xf3\xd4\x13\xfe\xb0\xa7\x0e\ +uU\xfa3\x0d\xd4K\x96\x17_\xfc\x8c\xb8w\x0c\xef\ +m\xd3\xc7\xc8\xa9'\xf8/\x0bP\xb3\x005\x0bP\xb3\ +\x005\x0bP\xb3\x005\x0bP\xb3\x005\x0bP\xb3\x00\ +5\x0bP\xb3\x005\x0bP\xb3\x005\x0bP\xb3\x005\ +\x0bPs\x84\x0dx\xac\xf6\x9e\xb0\xbe\x9f\x9c|\x98\xb6\ +\xdb\xc0!\xea\xaeY\x97\x9ep\xf7`\xf2\xcb\x17\x87j\ +\xe1\x809am\x1f\x9b\x06xv\xed\xad\x8f\x19qE\ +\x1c/{\xecR\x80\xedf\xb5\xbd\xb9\x1d\xbf\x00\x17\x84\ +\xc5\xf7%\xc3;F{\xe0\x03x\x03\x8a\xba*\xd7\x00\ +\xdf\xa4\xb56\xa2\xca\x99tG\x00\x00\x00\x00IEN\ +D\xaeB`\x82\ +\x00\x00\x00\xa6\ +\x89\ +PNG\x0d\x0a\x1a\x0a\x00\x00\x00\x0dIHDR\x00\ +\x00\x00\x06\x00\x00\x00\x09\x08\x04\x00\x00\x00\xbb\x93\x95\x16\ +\x00\x00\x00\x01sRGB\x00\xae\xce\x1c\xe9\x00\x00\x00\ +\x02bKGD\x00\xff\x87\x8f\xcc\xbf\x00\x00\x00\x09p\ +HYs\x00\x00\x0b\x13\x00\x00\x0b\x13\x01\x00\x9a\x9c\x18\ +\x00\x00\x00\x07tIME\x07\xdc\x08\x17\x14\x1d\x00\xb0\ +\xd55\xa3\x00\x00\x00*IDAT\x08\xd7c`\xc0\ +\x06\xfe\x9fg``B0\xa1\x1c\x08\x93\x81\x81\x09\xc1\ +d``b``4D\xe2 s\x19\x90\x8d@\x02\ +\x00d@\x09u\x86\xb3\xad\x9c\x00\x00\x00\x00IEN\ +D\xaeB`\x82\ +\x00\x00\x00\xa0\ +\x89\ +PNG\x0d\x0a\x1a\x0a\x00\x00\x00\x0dIHDR\x00\ +\x00\x00\x06\x00\x00\x00\x09\x08\x04\x00\x00\x00\xbb\x93\x95\x16\ +\x00\x00\x00\x01sRGB\x00\xae\xce\x1c\xe9\x00\x00\x00\ +\x02bKGD\x00\xff\x87\x8f\xcc\xbf\x00\x00\x00\x09p\ +HYs\x00\x00\x0b\x13\x00\x00\x0b\x13\x01\x00\x9a\x9c\x18\ +\x00\x00\x00\x07tIME\x07\xdc\x08\x17\x14\x1c\x1f$\ +\xc6\x09\x17\x00\x00\x00$IDAT\x08\xd7c`@\ +\x05\xff\xcf\xc3XL\xc8\x5c&dY&d\xc5p\x0e\ +\xa3!\x9c\xc3h\x88a\x1a\x0a\x00\x00m\x84\x09u7\ +\x9e\xd9#\x00\x00\x00\x00IEND\xaeB`\x82\ \x00\x00\x00\xa5\ \x89\ PNG\x0d\x0a\x1a\x0a\x00\x00\x00\x0dIHDR\x00\ @@ -514,18 +866,51 @@ HYs\x00\x00\x0b\x13\x00\x00\x0b\x13\x01\x00\x9a\x9c\x18\ 200B\x98\x10AFC\x14\x13P\xb5\xa3\x01\x00\ \xd6\x10\x07\xd2/H\xdfJ\x00\x00\x00\x00IEND\ \xaeB`\x82\ -\x00\x00\x00\xa0\ +\x00\x00\x00\x9e\ \x89\ PNG\x0d\x0a\x1a\x0a\x00\x00\x00\x0dIHDR\x00\ -\x00\x00\x06\x00\x00\x00\x09\x08\x04\x00\x00\x00\xbb\x93\x95\x16\ +\x00\x00\x09\x00\x00\x00\x06\x08\x04\x00\x00\x00\xbb\xce|N\ \x00\x00\x00\x01sRGB\x00\xae\xce\x1c\xe9\x00\x00\x00\ \x02bKGD\x00\xff\x87\x8f\xcc\xbf\x00\x00\x00\x09p\ HYs\x00\x00\x0b\x13\x00\x00\x0b\x13\x01\x00\x9a\x9c\x18\ -\x00\x00\x00\x07tIME\x07\xdc\x08\x17\x14\x1c\x1f$\ -\xc6\x09\x17\x00\x00\x00$IDAT\x08\xd7c`@\ -\x05\xff\xcf\xc3XL\xc8\x5c&dY&d\xc5p\x0e\ -\xa3!\x9c\xc3h\x88a\x1a\x0a\x00\x00m\x84\x09u7\ -\x9e\xd9#\x00\x00\x00\x00IEND\xaeB`\x82\ +\x00\x00\x00\x07tIME\x07\xdc\x08\x17\x08\x15\x0f\xfd\ +\x8f\xf8.\x00\x00\x00\x22IDAT\x08\xd7c`\xc0\ +\x0d\xfe\x9f\x87\xb1\x18\x91\x05\x18\x0d\xe1BH*\x0c\x19\ +\x18\x18\x91\x05\x10*\xd1\x00\x00\xca\xb5\x07\xd2v\xbb\xb2\ +\xc5\x00\x00\x00\x00IEND\xaeB`\x82\ +\x00\x00\x01\xef\ +\x89\ +PNG\x0d\x0a\x1a\x0a\x00\x00\x00\x0dIHDR\x00\ +\x00\x000\x00\x00\x000\x08\x06\x00\x00\x00W\x02\xf9\x87\ +\x00\x00\x00\x09pHYs\x00\x00\x0b\x13\x00\x00\x0b\x13\ +\x01\x00\x9a\x9c\x18\x00\x00\x01\xa1IDATh\x81\xed\ +\x9a\xbfN\xc2P\x14\x87\xbf\x96\xe2\xa4\x9bq\xbc\x8b\x1b\ +\xea\xc0\xe2D\x1c\x8c\x83\x83\xd1\x81\x89\x84\xd1\x17\xf0\x01\ +p\xc0\x07\xf0\x05\x1cI\x9c\xba\xa8#q0<\x02v\ +\x92\xe5\x8e\x04'\xe3\xc2\x9f\xc4\xa1m\x04B\x8b\x95\xc2\ +\xa1\xe4~[{\xee\xf0\xfb\x9a{o\x9a\x9cc\x11P\ +u\xbd]\xe0\x16(\x01\x87@\x9e\xf5b\x00\xb4\x81\x16\ +Po\x94\x0b=\x00\x0b\xa0\xeaz\xa7\xc0#\xb0'\x16\ +/\x19]\xa0\xd2(\x17^\xad\xe0\xcb\xbf\x93\x9d\xf0!\ +]\xe0\xc0\xc6\xdf6Y\x0b\x0f~\xe6\x9a\x83\xbf\xe7\xa7\ +\x19\xad8\xcc_\xc9M=\x97\x1c\xfc\x03;\xce\xa8Q\ +.8+\x0a\x94\x88\xaa\xeb\x0d\x99\x948\xb2Y\xbf\xdb\ +&\x09y[:\xc1\xa2\x18\x01i\x8c\x804F@\x1a\ +# \x8d\x11\x90\xc6\x08H3\xf7\xb7Yk}\x06\x1c\ +\x03\xdb\xcb\x8f3\xc1\x10\xf0\x80g\xa5\xd4w\xd4\xa2H\ +\x01\xad\xb5\x03\xb8\xc0e\xfa\xd9\x12\xd1\xd1Z\x9f+\xa5\ +>f\x15\xe3\xb6\xd0\x0d\xf2\xe1\x01\xf6\x81\x87\xa8b\x9c\ +\xc0E\xfaY\xfe\xcd\x89\xd6zgV!\xf3\x878N\ +\xe0ee)\xe6\xf3\xa6\x94\xfa\x9aU\x88\x13\xb8\x07\x9e\ +\x96\x93'\x11\x1d\xe0:\xaa\x18y\x0b)\xa5\x86\xc0U\ +f\xaf\xd1\x10\xa5T\x13h\xa6\x18,U6\xfa\x10g\ +\x02# \x8d\x11\x90\xc6\x08Hc\x04\xa41\x02\xd2l\ +\x84\xc0@:\xc4\x02\xf4\x1d\xfc\xf6}q\xece.\xe8\ +\x06\xae#\xd3m\xd6\xb6\x83?{P\x9c\xb3p]i\ +\xd9@\x1d\xbfm\x9f5\xba\xc0\x9d\x1dL}T\xc8\x96\ +D8\xec\xd1\xb3\xc27\xc1\xd0G\x8d\xdfq\x9b-\xa1\ +pQ\xf4\x99\x1c\xb7\xf9\x04\xf8\x01o\xedXc-\xfd\ +\xb2Y\x00\x00\x00\x00IEND\xaeB`\x82\ \x00\x00\x070\ \x89\ PNG\x0d\x0a\x1a\x0a\x00\x00\x00\x0dIHDR\x00\ @@ -643,44 +1028,254 @@ HYs\x00\x00\x0b\x13\x00\x00\x0b\x13\x01\x00\x9a\x9c\x18\ #\xe0#7\xe1\xa8O\x0e\x7f\xda`\xd7\xaf\x9f\xb9\x09\ \xdfc\x05\xff\xe5uLe\xf5\xcc\x1f\x0d3,\x83\xb6\ \x06D\x83\x00\x00\x00\x00IEND\xaeB`\x82\ -\x00\x00\x00\xa6\ +\x00\x00\x01\xdc\ +\x89\ +PNG\x0d\x0a\x1a\x0a\x00\x00\x00\x0dIHDR\x00\ +\x00\x000\x00\x00\x000\x08\x06\x00\x00\x00W\x02\xf9\x87\ +\x00\x00\x00\x09pHYs\x00\x00\x0b\x13\x00\x00\x0b\x13\ +\x01\x00\x9a\x9c\x18\x00\x00\x01\x8eIDATh\x81\xed\ +\x9a\xafN\xc3P\x14\x87\xbfn\x1d\x0a\x1cA\x1e\x83\x04\ +\xc4\x0cjA\x10\x04\x82\x80\x9e\xe7\x05x\x80!x\x01\ +^\x00\x8f\xc2\x00rA\x90=\xc2@1s\xe42\x14\ +\xc1\xecO\x82h\x1b\xb6e\xed(\xebv\xda\xe5~\xae\ +\xf7\x5c\xf1\xfb\xda{o\x9a\xdc\xe3\x11\xa2\xaa\xdb\xc05\ +P\x03\xf6\x81\x0a\xf9b\x00\xb4\x81\x16p#\x22=\x00\ +\x0f@U\x8f\x81{`\xc7,^:\xba@]D^\ +\xbc\xf0\xcd\xbfQ\x9c\xf0\x11]`\xafD\xb0l\x8a\x16\ +\x1e\x82\xcc\x0d\x9f`\xcdO3Zq\x98\xbfR\x9ez\ +\xae\xf9\x04\x1bv\x9c\x91\x88\xf8+\x0a\x94\x0aU\x1d2\ +)qP\x22\x7f\xa7M\x1a*%\xeb\x04\x8b\xe2\x04\xac\ +q\x02\xd68\x01k\x9c\x805N\xc0\x1a'`\xcd\xdc\ +\xdffU=\x01\x0e\x81\xcd\xe5\xc7\x99`\x08\xbc\x03O\ +\x22\xf2\x1d7)V@U}\xe0\x018\xcf>[*\ +:\xaaz*\x22\x1f\xb3\x8aIK\xe8\x0a\xfb\xf0\x00\xbb\ +\xc0]\x5c1I\xe0,\xfb,\xff\xe6HU\xb7f\x15\ +\x0a\xbf\x89\x93\x04\x9eW\x96b>\xaf\x22\xf25\xab\x90\ +$p\x0b<.'O*:\xc0e\x5c1\xf6\x14\x12\ +\x91!pQ\xd8c4BD\x9a@3\xc3`\x99\xb2\ +\xd6\x9b\xb8\x108\x01k\x9c\x805N\xc0\x1a'`\x8d\ +\x13\xb0f-\x04\x06\xd6!\x16\xa0\xef\x13\x5c\xdfW\xc7\ +\x06\xcb\xe1m`\x1e\x99\xbefm\xfb\x04\xbd\x07\xd59\ +\x13\xf3J\xab\xf8\xad\x06a\xd7G=\x1c(\x0aQ\xb3\ +G\xcf\x8bF\xc2/\xd1\xe0\xb7\xddf\xc3(\x5c\x1c}\ +&\xdbm>\x01~\x00%\xf8ZCUN:\x7f\x00\ +\x00\x00\x00IEND\xaeB`\x82\ +\x00\x00\x05~\ +\x89\ +PNG\x0d\x0a\x1a\x0a\x00\x00\x00\x0dIHDR\x00\ +\x00\x00\x01\x00\x00\x00\x01\x08\x06\x00\x00\x00\x1f\x15\xc4\x89\ +\x00\x00\x00\x09pHYs\x00\x00\x0b\x13\x00\x00\x0b\x13\ +\x01\x00\x9a\x9c\x18\x00\x00\x05\x17iTXtXML\ +:com.adobe.xmp\x00\x00\ +\x00\x00\x00 \ + \x07b\x0c\x81\x00\x00\x00\x0dIDAT\ +\x08\x1dc\xf8\xff\xff?\x03\x00\x08\xfc\x02\xfe\xe6\x0c\xff\ +\xab\x00\x00\x00\x00IEND\xaeB`\x82\ +\x00\x00\x00\x9e\ \x89\ PNG\x0d\x0a\x1a\x0a\x00\x00\x00\x0dIHDR\x00\ \x00\x00\x09\x00\x00\x00\x06\x08\x04\x00\x00\x00\xbb\xce|N\ \x00\x00\x00\x01sRGB\x00\xae\xce\x1c\xe9\x00\x00\x00\ \x02bKGD\x00\xff\x87\x8f\xcc\xbf\x00\x00\x00\x09p\ HYs\x00\x00\x0b\x13\x00\x00\x0b\x13\x01\x00\x9a\x9c\x18\ -\x00\x00\x00\x07tIME\x07\xdc\x08\x17\x08\x15;\xdc\ -;\x0c\x9b\x00\x00\x00*IDAT\x08\xd7c`\xc0\ -\x00\x8c\x0c\x0cs> \x0b\xa4\x08020 \x0b\xa6\ -\x08000B\x98\x10\xc1\x14\x01\x14\x13P\xb5\xa3\x01\ -\x00\xc6\xb9\x07\x90]f\x1f\x83\x00\x00\x00\x00IEN\ -D\xaeB`\x82\ -\x00\x00\x00\xa0\ +\x00\x00\x00\x07tIME\x07\xdc\x08\x17\x08\x15\x0f\xfd\ +\x8f\xf8.\x00\x00\x00\x22IDAT\x08\xd7c`\xc0\ +\x0d\xfe\x9f\x87\xb1\x18\x91\x05\x18\x0d\xe1BH*\x0c\x19\ +\x18\x18\x91\x05\x10*\xd1\x00\x00\xca\xb5\x07\xd2v\xbb\xb2\ +\xc5\x00\x00\x00\x00IEND\xaeB`\x82\ +\x00\x00\x01\xe1\ \x89\ PNG\x0d\x0a\x1a\x0a\x00\x00\x00\x0dIHDR\x00\ -\x00\x00\x06\x00\x00\x00\x09\x08\x04\x00\x00\x00\xbb\x93\x95\x16\ -\x00\x00\x00\x01sRGB\x00\xae\xce\x1c\xe9\x00\x00\x00\ -\x02bKGD\x00\xff\x87\x8f\xcc\xbf\x00\x00\x00\x09p\ -HYs\x00\x00\x0b\x13\x00\x00\x0b\x13\x01\x00\x9a\x9c\x18\ -\x00\x00\x00\x07tIME\x07\xdc\x08\x17\x14\x1c\x1f$\ -\xc6\x09\x17\x00\x00\x00$IDAT\x08\xd7c`@\ -\x05\xff\xcf\xc3XL\xc8\x5c&dY&d\xc5p\x0e\ -\xa3!\x9c\xc3h\x88a\x1a\x0a\x00\x00m\x84\x09u7\ -\x9e\xd9#\x00\x00\x00\x00IEND\xaeB`\x82\ -\x00\x00\x00\xa6\ +\x00\x000\x00\x00\x000\x08\x06\x00\x00\x00W\x02\xf9\x87\ +\x00\x00\x00\x09pHYs\x00\x00\x0b\x13\x00\x00\x0b\x13\ +\x01\x00\x9a\x9c\x18\x00\x00\x01\x93IDATh\x81\xed\ +\x9a;N\xc3@\x10\x86\xbfq\x1c*\xe8\x10\xe56\x94\ +\xd0\xa4\xa1\x8a(\x22\x0a\x0aD\x9f\x9e\x0bp\x80Pp\ +\x01.\xc0\x15h\x80\x13\xa0\x1c!P\x91f\xbbD\xa1\ +B4yh(l\x1e\xb1\xfcH\x08\xc9\xda\xd2~\x9d\ +w\x5c\xfc\x9f\xb3\x1e9\xda\x11bTu\x17\xb8\x02\x9a\ +\xc0!P\xa7\x5cL\x80\x1e\xd0\x05\xaeEd\xf4]Q\ +\xd5\x96\xaa\x0e\xb4:\x0cT\xb5\x05 \x1a=\xf9g`\ +\xcf\xc5c]\x81!p\x10\x10m\x9b\xaa\x85\x87(s\ +'$\xda\xf3If\x1b\x0e\xb3(\xb5\xc4uSTu\ +\xcc\xfc\x0b;\x13\x91p\x83\xa1\x16FU\xa7\xccKL\ +\x02\xca\xd7m\x96\xa1\x1e\xb8N\xb0*^\xc05^\xc0\ +5^\xc05^\xc05^\xc05^\xc05\x85\x9f\xcd\ +\xd6\xda\x13\xe0\x08\xd8^\x7f\x9c9\xa6\xc0\x0b\xf0`\x8c\ +\xf9\xc8\xbaITU\x13k3\x11\x09\xad\xb5!p\x07\ +\x9c\xaf1\xe4\x22\xf4\x81Sc\xcck\xca\xff\x81\xdc-\ +t\x89\xfb\xf0\x00\xfb\xc0mV1O\xe0\xec\xff\xb3\xfc\ +\x99ck\xedNZ\xa1\xf2/q\x9e\xc0\xe3\xc6R\x14\ +\xf3d\x8cyO+\xe4\x09\xdc\x00\xf7\xeb\xc9\xb3\x14}\ +\xe0\x22\xab\x98\xd9\x85\xbe.\xca\xd4F\xd3\xbaP\xa1@\ +\x99X\xb6\x8dV\x02/\xe0\x1a/\xe0\x1a/\xe0\x1a/\ +\xe0\x1a/\xe0\x1a/\xe0\x9a\x80\xe8\x04\xbc\xaa\x8cC\xa2\ +\xe3\xfb\xc6\xaf\xc5Z\xfc\xd9ZF\x92\xc7\xac\xbd\x90h\ +\xf6\xa0QpcY\xe9V\x7f\xd4 \x9e\xfah\xc7\x0b\ +Ua\x08\xb4Ed$_+\xf1/\xd1\xe1g\xdcf\ +\xcbQ\xb8,\xc6\xcc\x8f\xdb\xbc\x01|\x02mw#\xb3\ +\xd4\x95Sv\x00\x00\x00\x00IEND\xaeB`\x82\ +\ +\x00\x00\x00\xa5\ \x89\ PNG\x0d\x0a\x1a\x0a\x00\x00\x00\x0dIHDR\x00\ \x00\x00\x09\x00\x00\x00\x06\x08\x04\x00\x00\x00\xbb\xce|N\ \x00\x00\x00\x01sRGB\x00\xae\xce\x1c\xe9\x00\x00\x00\ -\x02bKGD\x00\xff\x87\x8f\xcc\xbf\x00\x00\x00\x09p\ +\x02bKGD\x00\x9cS4\xfc]\x00\x00\x00\x09p\ HYs\x00\x00\x0b\x13\x00\x00\x0b\x13\x01\x00\x9a\x9c\x18\ -\x00\x00\x00\x07tIME\x07\xdc\x08\x17\x08\x15;\xdc\ -;\x0c\x9b\x00\x00\x00*IDAT\x08\xd7c`\xc0\ -\x00\x8c\x0c\x0cs> \x0b\xa4\x08020 \x0b\xa6\ -\x08000B\x98\x10\xc1\x14\x01\x14\x13P\xb5\xa3\x01\ -\x00\xc6\xb9\x07\x90]f\x1f\x83\x00\x00\x00\x00IEN\ -D\xaeB`\x82\ +\x00\x00\x00\x07tIME\x07\xdc\x08\x17\x0b\x02\x04m\ +\x98\x1bi\x00\x00\x00)IDAT\x08\xd7c`\xc0\ +\x00\x8c\x0c\x0c\xff\xcf\xa3\x08\x18220 \x0b2\x1a\ +200B\x98\x10AFC\x14\x13P\xb5\xa3\x01\x00\ +\xd6\x10\x07\xd2/H\xdfJ\x00\x00\x00\x00IEND\ +\xaeB`\x82\ +\x00\x00\x04\x12\ +\x89\ +PNG\x0d\x0a\x1a\x0a\x00\x00\x00\x0dIHDR\x00\ +\x00\x000\x00\x00\x000\x08\x06\x00\x00\x00W\x02\xf9\x87\ +\x00\x00\x00\x09pHYs\x00\x00\x0b\x13\x00\x00\x0b\x13\ +\x01\x00\x9a\x9c\x18\x00\x00\x03\xc4IDATh\x81\xed\ +\x9a_\x88\x94U\x18\xc6\x7f3;\x1a\x0b\x19\x15f\x17\ +\xca\x03IPM\x09J7Q^D)&f\x05[\ +\xb9\xd2\x82\xb1P\x17\x91$t\x11\x08z\xa1D\x17]\ +T\x94\x04\xd6E\xe1\xa2\x15L\xb1\x18\x99\xfd%\xd8\x08\ +\x82nj]\xa4\x22\xe2\x81\xa8\x96\xd5(\xe8\x9f\xae\xd5\ +\xc5\xf9\xb6\xc6\xd9\xf9\xbesf\xc6vf\xc0\xdf\xdd\x9c\ +\xef=\xefy\x9fs\xe6\x9c\xf3~\xdf9%2Fj\ +SK\x81\xdd\xc0Z\xe0:`\x11\xbd\xc5i`\x12\x98\ +\x00\xf6\x8c\x0dUg\x00J\x00#\xb5\xa9[\x80C\xc0\ +\xb2\xae\x85\xd7\x1a\xd3\xc0\xd6\xb1\xa1\xea\x07\xa5\xac\xe7\x8f\ +\xd1?\xc1\xcf1\x0d\x5c[&\xfcm\xfa-x\x081\ +\xef\xaa\x10\xfe\xf3\x8d\x9cY\xe0`R\x19h\xf8\xbd\xb6\ +B\x98\xb0\xf5\x9c\x19\x1b\xaaV\x16(\xa0\x96\x18\xa9M\ +\xcdr\xb6\x88Uezo\xb5i\x85E\xe5nG\xd0\ +)\xe7\x05t\x9b\xf3\x02\xfe\x0fl\xdfc\xfb\x90\xed\xf5\ +1\xdb\x9e\x13`\xfb\x11\xe05`\x188j{\x9f\xed\ +\xdc\x95\xb2\xa7\x04\xd8\x1e\x06\x9e\xaa+*\x01\x0f\x01/\ +\xe4\xd5\xe9\x19\x01\xb6\xd7\x01/\x93%\x98\x0dl\xb3\xfd\ +h\xb3z=!\xc0\xf6\xf5\xc0\xeb\xc0\xe2\x02\xb3\x8d\xcd\ +\x0a\xbb.\xc0\xf6\x95\xc0[\xc0\x92\x88\xe9\x8b\xcd\x0a\xbb\ +*\xc0\xf6\xe5\xc0Q\xe2\xd9\xf0\x93\x92^i\xf6\xa0k\ +\x02l/\x01\x8e\x00+#\xa6\x07\x80\xc7\xf2\x1evE\ +\x80\xed\xc5\xc0\x1b\xc0\x9a\x88\xe9\xdb\xc0\xa8\xa4\xbf\xf3\x0c\ +\x16\x5c\x80\xed2\xa1Wo\x8d\x98~\x0a\xdc-i\xb6\ +\xc8\xa8\x1b#\xf04po\xc4\xe6K`\x93\xa4_c\ +\xce\x92\x04\xd8.e=\xd7\x11\xb6w\x02\xdb#f\xdf\ +\x03\x1b$\xcd\xa4\xf8,\x0c*\x0b|\x07\xf0;0i\ +{UR\xa4\xcd}\x8d\x02\x8fG\xcc~\x06n\x93\xf4\ +m\xaa\xdf\x5c\x01Y\xfe1N\xd8\xda/\x00\xae\x01\xde\ +\xb3}U\xaa\xf3:_\x9b\x81\xfd\x11\xb3?\x81;%\ +}\xde\x8a\xef\xa2\x11x\x0e\xd8\xdcP\xb6\x0cx\xdf\xf6\ +\x15\xa9\x0d\xd8\xbe\x11x\x95\xf9/\xe4\xf5\xfc\x05\xdc'\ +\xe9\xa3T\xbfs4\x15\x90\xf5\xd8\x839u\x96\x13F\ +by\xcc\xb9\xed*p\x18\x18\x8c\x98>,\xa9\x16\xf3\ +\xd7\x8c\xbc\x11\xb84Ro%A\xc4ey\x06\xb6W\ +\x10v\xd9\x98\xaf\xbd\x92\x9e\x8f\xd8\xe4\x92'\xe0 a\ +\x1d.\xe2j\xe0\x1d\xdb\x177>\xb0}\x09!\xf8\x15\ +\x11\x1f\xfb%\xed\x8eFY@S\x01\x92N\x13\xb2\xbf\ +/\x22\xf5W\x03Gl_8W`{\x90\xf0\xb7\xa9\ +F\xea\x8e\x13r\xfd\x8e\xc8\x9d\xc4\x92N\x02\xeb\x81\xaf\ +\x22>n\x00\x0e\xdb\x1e\xb4=@\x98\xb07E\xeaL\ +\x00\xc3\x92:\xfe\x02X\xb8\x0fH\xfa\x11X\x078\xe2\ +\xe7f\xa0FX*\x1bW\xaeF&\x81;$\xfd\x91\ +\x18c!\xd1\xddU\x92\x09y\xcb\x0f\x11\xd3\x8d\xc0h\ +\xc4\xc6\x84\x8d\xea\xa7\xb4\xf0\xe2$\xa5\x07\x92\xbe&\x8c\ +\xc4\x89\x0e\xda:IH\x11\xbe\xeb\xc0\xc7<\x92\xf3\x1b\ +I\xc7\x80\x0d\xc0/m\xb4\xf3\x1bp\xbb\xa4\xe3m\xd4\ +-\xa4\xa5\x04M\xd2g\xc0\xa6,\xa0Tf\x81-\x92\ +>i\xa5\xadTZ\xce0%M\x00w\x11r\x97\x14\ +\x1e\x90\xf4f\xab\xed\xa4\xd2V\x8a,\xe9]`\x0b\xa1\ +w\x8b\xd8)\xe9\xa5v\xdaH\xa5\xed\x1c_\xd28\xb0\ +\x8d\x90\x885\xe3YIO\xb4\xeb?\x95\x8e^R$\ +\x1d\x04\xeeg\xfe\x9c\xd8\x07\xec\xe8\xc4w*\x1d\x1f%\ +I:`\xfbc\xc2\x06v\x11\xf0a6O\x16\x84s\ +r\x16&\xe9\x1b\xe0\x99s\xe1\xabU\xca\x84\x13\xf0~\ +\xe5T\x85\x90\x9b\xd4\x7f\x9f\x19\xc8N\x03{\x91\xc6\xb7\ +\xba\xc9\x0a!3l\xfc\xc0T\xf4\xfa\xd7KL\x94\x81\ +=\x84c\xfb~c\x1a\xd8[\xcen}l\xa5\xbfD\ +\xcc]\xf6\x98\xf9\xf70!\xbb\xf4\xb1\x8b\xff\xae\xdb\x14\ +}\xab\xef\x06\xa78\xfb\xba\xcd\x09\x80\x7f\x00\xc4\x1e\x10\ +)3[\x85\xf7\x00\x00\x00\x00IEND\xaeB`\ +\x82\ " qt_resource_name = b"\ @@ -692,43 +1287,86 @@ qt_resource_name = b"\ \x07\x03}\xc3\ \x00i\ \x00m\x00a\x00g\x00e\x00s\ -\x00\x15\ -\x0f\xf3\xc0\x07\ -\x00u\ -\x00p\x00_\x00a\x00r\x00r\x00o\x00w\x00_\x00d\x00i\x00s\x00a\x00b\x00l\x00e\x00d\ -\x00.\x00p\x00n\x00g\ +\x00\x0e\ +\x04\xa2\xfc\xa7\ +\x00d\ +\x00o\x00w\x00n\x00_\x00a\x00r\x00r\x00o\x00w\x00.\x00p\x00n\x00g\ +\x00\x11\ +\x0b\xda0\xa7\ +\x00b\ +\x00r\x00a\x00n\x00c\x00h\x00_\x00c\x00l\x00o\x00s\x00e\x00d\x00.\x00p\x00n\x00g\ +\ +\x00\x1d\ +\x09\x07\x81\x07\ +\x00c\ +\x00h\x00e\x00c\x00k\x00b\x00o\x00x\x00_\x00c\x00h\x00e\x00c\x00k\x00e\x00d\x00_\ +\x00d\x00i\x00s\x00a\x00b\x00l\x00e\x00d\x00.\x00p\x00n\x00g\ +\x00\x1c\ +\x08?\xdag\ +\x00c\ +\x00h\x00e\x00c\x00k\x00b\x00o\x00x\x00_\x00u\x00n\x00c\x00h\x00e\x00c\x00k\x00e\ +\x00d\x00_\x00f\x00o\x00c\x00u\x00s\x00.\x00p\x00n\x00g\ +\x00#\ +\x06\xf2\x1aG\ +\x00c\ +\x00h\x00e\x00c\x00k\x00b\x00o\x00x\x00_\x00i\x00n\x00d\x00e\x00t\x00e\x00r\x00m\ +\x00i\x00n\x00a\x00t\x00e\x00_\x00d\x00i\x00s\x00a\x00b\x00l\x00e\x00d\x00.\x00p\ +\x00n\x00g\ \x00\x12\ \x01.\x03'\ \x00c\ \x00o\x00m\x00b\x00o\x00b\x00o\x00x\x00_\x00a\x00r\x00r\x00o\x00w\x00.\x00p\x00n\ \x00g\ -\x00\x0e\ -\x04\xa2\xfc\xa7\ -\x00d\ -\x00o\x00w\x00n\x00_\x00a\x00r\x00r\x00o\x00w\x00.\x00p\x00n\x00g\ -\x00\x1b\ -\x03Z2'\ +\x00\x1c\ +\x0e<\xde\x07\ \x00c\ -\x00o\x00m\x00b\x00o\x00b\x00o\x00x\x00_\x00a\x00r\x00r\x00o\x00w\x00_\x00d\x00i\ -\x00s\x00a\x00b\x00l\x00e\x00d\x00.\x00p\x00n\x00g\ +\x00h\x00e\x00c\x00k\x00b\x00o\x00x\x00_\x00u\x00n\x00c\x00h\x00e\x00c\x00k\x00e\ +\x00d\x00_\x00h\x00o\x00v\x00e\x00r\x00.\x00p\x00n\x00g\ +\x00\x14\ +\x07\xec\xd1\xc7\ +\x00c\ +\x00h\x00e\x00c\x00k\x00b\x00o\x00x\x00_\x00c\x00h\x00e\x00c\x00k\x00e\x00d\x00.\ +\x00p\x00n\x00g\ +\x00\x1a\ +\x05\x11\xe0\xe7\ +\x00c\ +\x00h\x00e\x00c\x00k\x00b\x00o\x00x\x00_\x00c\x00h\x00e\x00c\x00k\x00e\x00d\x00_\ +\x00f\x00o\x00c\x00u\x00s\x00.\x00p\x00n\x00g\ \x00\x18\ \x03\x8e\xdeg\ \x00r\ \x00i\x00g\x00h\x00t\x00_\x00a\x00r\x00r\x00o\x00w\x00_\x00d\x00i\x00s\x00a\x00b\ \x00l\x00e\x00d\x00.\x00p\x00n\x00g\ -\x00\x11\ -\x00\xb8\x8c\x07\ -\x00l\ -\x00e\x00f\x00t\x00_\x00a\x00r\x00r\x00o\x00w\x00_\x00o\x00n\x00.\x00p\x00n\x00g\ -\ -\x00\x0f\ -\x01s\x8b\x07\ +\x00\x15\ +\x03'rg\ +\x00c\ +\x00o\x00m\x00b\x00o\x00b\x00o\x00x\x00_\x00a\x00r\x00r\x00o\x00w\x00_\x00o\x00n\ +\x00.\x00p\x00n\x00g\ +\x00\x12\ +\x03\x8d\x04G\ +\x00r\ +\x00i\x00g\x00h\x00t\x00_\x00a\x00r\x00r\x00o\x00w\x00_\x00o\x00n\x00.\x00p\x00n\ +\x00g\ +\x00\x16\ +\x01u\xcc\x87\ +\x00c\ +\x00h\x00e\x00c\x00k\x00b\x00o\x00x\x00_\x00u\x00n\x00c\x00h\x00e\x00c\x00k\x00e\ +\x00d\x00.\x00p\x00n\x00g\ +\x00\x17\ +\x0c\xabQ\x07\ +\x00d\ +\x00o\x00w\x00n\x00_\x00a\x00r\x00r\x00o\x00w\x00_\x00d\x00i\x00s\x00a\x00b\x00l\ +\x00e\x00d\x00.\x00p\x00n\x00g\ +\x00\x15\ +\x0f\xf3\xc0\x07\ \x00u\ -\x00p\x00_\x00a\x00r\x00r\x00o\x00w\x00_\x00o\x00n\x00.\x00p\x00n\x00g\ -\x00\x0c\ -\x06\xe6\xe6g\ -\x00u\ -\x00p\x00_\x00a\x00r\x00r\x00o\x00w\x00.\x00p\x00n\x00g\ +\x00p\x00_\x00a\x00r\x00r\x00o\x00w\x00_\x00d\x00i\x00s\x00a\x00b\x00l\x00e\x00d\ +\x00.\x00p\x00n\x00g\ +\x00\x14\ +\x04^-\xa7\ +\x00b\ +\x00r\x00a\x00n\x00c\x00h\x00_\x00c\x00l\x00o\x00s\x00e\x00d\x00_\x00o\x00n\x00.\ +\x00p\x00n\x00g\ \x00\x0f\ \x06S%\xa7\ \x00b\ @@ -738,106 +1376,119 @@ qt_resource_name = b"\ \x00l\ \x00e\x00f\x00t\x00_\x00a\x00r\x00r\x00o\x00w\x00_\x00d\x00i\x00s\x00a\x00b\x00l\ \x00e\x00d\x00.\x00p\x00n\x00g\ -\x00\x14\ -\x04^-\xa7\ -\x00b\ -\x00r\x00a\x00n\x00c\x00h\x00_\x00c\x00l\x00o\x00s\x00e\x00d\x00_\x00o\x00n\x00.\ -\x00p\x00n\x00g\ -\x00\x11\ -\x0b\xda0\xa7\ -\x00b\ -\x00r\x00a\x00n\x00c\x00h\x00_\x00c\x00l\x00o\x00s\x00e\x00d\x00.\x00p\x00n\x00g\ -\ \x00\x0e\ \x0e\xde\xfa\xc7\ \x00l\ \x00e\x00f\x00t\x00_\x00a\x00r\x00r\x00o\x00w\x00.\x00p\x00n\x00g\ +\x00\x1f\ +\x0a\xae'G\ +\x00c\ +\x00h\x00e\x00c\x00k\x00b\x00o\x00x\x00_\x00u\x00n\x00c\x00h\x00e\x00c\x00k\x00e\ +\x00d\x00_\x00d\x00i\x00s\x00a\x00b\x00l\x00e\x00d\x00.\x00p\x00n\x00g\ \x00\x11\ -\x01\x1f\xc3\x87\ -\x00d\ -\x00o\x00w\x00n\x00_\x00a\x00r\x00r\x00o\x00w\x00_\x00o\x00n\x00.\x00p\x00n\x00g\ +\x00\xb8\x8c\x07\ +\x00l\ +\x00e\x00f\x00t\x00_\x00a\x00r\x00r\x00o\x00w\x00_\x00o\x00n\x00.\x00p\x00n\x00g\ \ \x00\x0f\ \x02\x9f\x05\x87\ \x00r\ \x00i\x00g\x00h\x00t\x00_\x00a\x00r\x00r\x00o\x00w\x00.\x00p\x00n\x00g\ +\x00\x1b\ +\x03Z2'\ +\x00c\ +\x00o\x00m\x00b\x00o\x00b\x00o\x00x\x00_\x00a\x00r\x00r\x00o\x00w\x00_\x00d\x00i\ +\x00s\x00a\x00b\x00l\x00e\x00d\x00.\x00p\x00n\x00g\ +\x00\x0f\ +\x01s\x8b\x07\ +\x00u\ +\x00p\x00_\x00a\x00r\x00r\x00o\x00w\x00_\x00o\x00n\x00.\x00p\x00n\x00g\ +\x00 \ +\x0f\xd4\x1b\xc7\ +\x00c\ +\x00h\x00e\x00c\x00k\x00b\x00o\x00x\x00_\x00i\x00n\x00d\x00e\x00t\x00e\x00r\x00m\ +\x00i\x00n\x00a\x00t\x00e\x00_\x00h\x00o\x00v\x00e\x00r\x00.\x00p\x00n\x00g\ \x00\x12\ \x05\x8f\x9d\x07\ \x00b\ \x00r\x00a\x00n\x00c\x00h\x00_\x00o\x00p\x00e\x00n\x00_\x00o\x00n\x00.\x00p\x00n\ \x00g\ -\x00\x17\ -\x0c\xabQ\x07\ -\x00d\ -\x00o\x00w\x00n\x00_\x00a\x00r\x00r\x00o\x00w\x00_\x00d\x00i\x00s\x00a\x00b\x00l\ -\x00e\x00d\x00.\x00p\x00n\x00g\ -\x00\x12\ -\x03\x8d\x04G\ -\x00r\ -\x00i\x00g\x00h\x00t\x00_\x00a\x00r\x00r\x00o\x00w\x00_\x00o\x00n\x00.\x00p\x00n\ -\x00g\ -\x00\x15\ -\x03'rg\ +\x00\x1a\ +\x01\x87\xaeg\ \x00c\ -\x00o\x00m\x00b\x00o\x00b\x00o\x00x\x00_\x00a\x00r\x00r\x00o\x00w\x00_\x00o\x00n\ -\x00.\x00p\x00n\x00g\ +\x00h\x00e\x00c\x00k\x00b\x00o\x00x\x00_\x00i\x00n\x00d\x00e\x00t\x00e\x00r\x00m\ +\x00i\x00n\x00a\x00t\x00e\x00.\x00p\x00n\x00g\ +\x00\x0f\ +\x0c\xe2hg\ +\x00t\ +\x00r\x00a\x00n\x00s\x00p\x00a\x00r\x00e\x00n\x00t\x00.\x00p\x00n\x00g\ +\x00\x0c\ +\x06\xe6\xe6g\ +\x00u\ +\x00p\x00_\x00a\x00r\x00r\x00o\x00w\x00.\x00p\x00n\x00g\ +\x00 \ +\x09\xd7\x1f\xa7\ +\x00c\ +\x00h\x00e\x00c\x00k\x00b\x00o\x00x\x00_\x00i\x00n\x00d\x00e\x00t\x00e\x00r\x00m\ +\x00i\x00n\x00a\x00t\x00e\x00_\x00f\x00o\x00c\x00u\x00s\x00.\x00p\x00n\x00g\ +\x00\x11\ +\x01\x1f\xc3\x87\ +\x00d\ +\x00o\x00w\x00n\x00_\x00a\x00r\x00r\x00o\x00w\x00_\x00o\x00n\x00.\x00p\x00n\x00g\ +\ +\x00\x1a\ +\x03\x0e\xe4\x87\ +\x00c\ +\x00h\x00e\x00c\x00k\x00b\x00o\x00x\x00_\x00c\x00h\x00e\x00c\x00k\x00e\x00d\x00_\ +\x00h\x00o\x00v\x00e\x00r\x00.\x00p\x00n\x00g\ " qt_resource_struct = b"\ \x00\x00\x00\x00\x00\x02\x00\x00\x00\x01\x00\x00\x00\x01\ -\x00\x00\x00\x00\x00\x00\x00\x00\ \x00\x00\x00\x00\x00\x02\x00\x00\x00\x01\x00\x00\x00\x02\ -\x00\x00\x00\x00\x00\x00\x00\x00\ -\x00\x00\x00\x16\x00\x02\x00\x00\x00\x13\x00\x00\x00\x03\ -\x00\x00\x00\x00\x00\x00\x00\x00\ -\x00\x00\x01\x16\x00\x00\x00\x00\x00\x01\x00\x00\x03C\ -\x00\x00\x01vA\x9d\xa25\ -\x00\x00\x02P\x00\x00\x00\x00\x00\x01\x00\x00\x1d!\ -\x00\x00\x01vA\x9d\xa25\ -\x00\x00\x00X\x00\x00\x00\x00\x00\x01\x00\x00\x00\xa3\ -\x00\x00\x01y\xb4r\xcc\x9c\ -\x00\x00\x01>\x00\x00\x00\x00\x00\x01\x00\x00\x03\xed\ -\x00\x00\x01vA\x9d\xa29\ -\x00\x00\x02x\x00\x00\x00\x00\x00\x01\x00\x00\x1d\xca\ -\x00\x00\x01vA\x9d\xa27\ -\x00\x00\x03$\x00\x00\x00\x00\x00\x01\x00\x00&\xf0\ -\x00\x00\x01y\xb4r\xcc\x9c\ -\x00\x00\x00\xa4\x00\x00\x00\x00\x00\x01\x00\x00\x01\xf6\ -\x00\x00\x01y\xb4r\xcc\x9c\ -\x00\x00\x02\xfa\x00\x00\x00\x00\x00\x01\x00\x00&L\ -\x00\x00\x01vA\x9d\xa27\ -\x00\x00\x00\xe0\x00\x00\x00\x00\x00\x01\x00\x00\x02\x9f\ -\x00\x00\x01vA\x9d\xa27\ -\x00\x00\x01\xd8\x00\x00\x00\x00\x00\x01\x00\x00\x0c\xe5\ -\x00\x00\x01y\xc2\x05+`\ -\x00\x00\x00\x82\x00\x00\x00\x00\x00\x01\x00\x00\x01M\ -\x00\x00\x01vA\x9d\xa25\ -\x00\x00\x02\x9c\x00\x00\x00\x00\x00\x01\x00\x00\x1en\ -\x00\x00\x01y\xc1\xfc\x16\x91\ -\x00\x00\x01\x80\x00\x00\x00\x00\x00\x01\x00\x00\x051\ -\x00\x00\x01y\xc1\xf9Kx\ -\x00\x00\x01b\x00\x00\x00\x00\x00\x01\x00\x00\x04\x8f\ -\x00\x00\x01vA\x9d\xa29\ -\x00\x00\x02\x06\x00\x00\x00\x00\x00\x01\x00\x00\x14\xc6\ -\x00\x00\x01y\xc2\x05\x91*\ -\x00\x00\x01\xa4\x00\x00\x00\x00\x00\x01\x00\x00\x0c;\ -\x00\x00\x01vA\x9d\xa25\ -\x00\x00\x02\xc6\x00\x00\x00\x00\x00\x01\x00\x00%\xa2\ -\x00\x00\x01vA\x9d\xa25\ -\x00\x00\x02.\x00\x00\x00\x00\x00\x01\x00\x00\x1cw\ -\x00\x00\x01vA\x9d\xa25\ +\x00\x00\x00\x16\x00\x02\x00\x00\x00 \x00\x00\x00\x03\ +\x00\x00\x04\x1e\x00\x00\x00\x00\x00\x01\x00\x000\x5c\ +\x00\x00\x05\xfc\x00\x00\x00\x00\x00\x01\x00\x00F\x05\ +\x00\x00\x01<\x00\x00\x00\x00\x00\x01\x00\x00\x0f\xec\ +\x00\x00\x04\xa6\x00\x00\x00\x00\x00\x01\x00\x002S\ +\x00\x00\x02\x9c\x00\x00\x00\x00\x00\x01\x00\x00\x1b\xf7\ +\x00\x00\x05:\x00\x00\x00\x00\x00\x01\x00\x00<\x1c\ +\x00\x00\x04F\x00\x00\x00\x00\x00\x01\x00\x001\x06\ +\x00\x00\x06$\x00\x00\x00\x00\x00\x01\x00\x00F\xae\ +\x00\x00\x02B\x00\x00\x00\x00\x00\x01\x00\x00\x1a\xa9\ +\x00\x00\x04j\x00\x00\x00\x00\x00\x01\x00\x001\xaa\ +\x00\x00\x02r\x00\x00\x00\x00\x00\x01\x00\x00\x1bS\ +\x00\x00\x02\x0c\x00\x00\x00\x00\x00\x01\x00\x00\x1a\x05\ +\x00\x00\x032\x00\x00\x00\x00\x00\x01\x00\x00\x1e\xa3\ \x00\x00\x00(\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\ -\x00\x00\x01vA\x9d\xa29\ +\x00\x00\x01\xd2\x00\x00\x00\x00\x00\x01\x00\x00\x16\x02\ +\x00\x00\x05\x10\x00\x00\x00\x00\x00\x01\x00\x004\xe8\ +\x00\x00\x03`\x00\x00\x00\x00\x00\x01\x00\x00&\x84\ +\x00\x00\x05\x98\x00\x00\x00\x00\x00\x01\x00\x00C~\ +\x00\x00\x00\xf0\x00\x00\x00\x00\x00\x01\x00\x00\x0d\xec\ +\x00\x00\x01\xa4\x00\x00\x00\x00\x00\x01\x00\x00\x12\x03\ +\x00\x00\x00\xb2\x00\x00\x00\x00\x00\x01\x00\x00\x0c\x91\ +\x00\x00\x00r\x00\x00\x00\x00\x00\x01\x00\x00\x08Z\ +\x00\x00\x05\xb6\x00\x00\x00\x00\x00\x01\x00\x00D \ +\x00\x00\x03\xda\x00\x00\x00\x00\x00\x01\x00\x00.\xe2\ +\x00\x00\x00J\x00\x00\x00\x00\x00\x01\x00\x00\x00\xa9\ +\x00\x00\x03\x84\x00\x00\x00\x00\x00\x01\x00\x00-\x8e\ +\x00\x00\x02\xce\x00\x00\x00\x00\x00\x01\x00\x00\x1dV\ +\x00\x00\x05t\x00\x00\x00\x00\x00\x01\x00\x00=\xfc\ +\x00\x00\x01f\x00\x00\x00\x00\x00\x01\x00\x00\x10\x96\ +\x00\x00\x03\xb8\x00\x00\x00\x00\x00\x01\x00\x00.8\ +\x00\x00\x04\xca\x00\x00\x00\x00\x00\x01\x00\x002\xf5\ +\x00\x00\x03\x02\x00\x00\x00\x00\x00\x01\x00\x00\x1e\x00\ " def qInitResources(): QtCore.qRegisterResourceData( - 0x03, qt_resource_struct, qt_resource_name, qt_resource_data + 0x01, qt_resource_struct, qt_resource_name, qt_resource_data ) def qCleanupResources(): QtCore.qUnregisterResourceData( - 0x03, qt_resource_struct, qt_resource_name, qt_resource_data + 0x01, qt_resource_struct, qt_resource_name, qt_resource_data ) diff --git a/openpype/style/resources.qrc b/openpype/style/resources.qrc index a583d9458e..077f074edb 100644 --- a/openpype/style/resources.qrc +++ b/openpype/style/resources.qrc @@ -19,5 +19,18 @@ images/up_arrow.png images/up_arrow_disabled.png images/up_arrow_on.png + images/checkbox_checked.png + images/checkbox_checked_hover.png + images/checkbox_checked_focus.png + images/checkbox_checked_disabled.png + images/checkbox_unchecked.png + images/checkbox_unchecked_hover.png + images/checkbox_unchecked_focus.png + images/checkbox_unchecked_disabled.png + images/checkbox_indeterminate.png + images/checkbox_indeterminate_hover.png + images/checkbox_indeterminate_focus.png + images/checkbox_indeterminate_disabled.png + images/transparent.png diff --git a/openpype/style/style.css b/openpype/style/style.css index 830ed85f9b..2a2f4e572e 100644 --- a/openpype/style/style.css +++ b/openpype/style/style.css @@ -57,10 +57,73 @@ QAbstractSpinBox:focus, QLineEdit:focus, QPlainTextEdit:focus, QTextEdit:focus{ border-color: {color:border-focus}; } +QAbstractSpinBox:up-button { + margin: 0px; + background-color: transparent; + subcontrol-origin: border; + subcontrol-position: top right; + border-top-right-radius: 0.3em; + border-top: 0px solid transparent; + border-right: 0px solid transparent; + border-left: 1px solid {color:border}; + border-bottom: 1px solid {color:border}; +} + +QAbstractSpinBox:down-button { + margin: 0px; + background-color: transparent; + subcontrol-origin: border; + subcontrol-position: bottom right; + border-bottom-right-radius: 0.3em; + border-bottom: 0px solid transparent; + border-right: 0px solid transparent; + border-left: 1px solid {color:border}; + border-top: 1px solid {color:border}; +} + +QAbstractSpinBox:up-button:focus, QAbstractSpinBox:down-button:focus { + border-color: {color:border-focus}; +} +QAbstractSpinBox::up-arrow, QAbstractSpinBox::up-arrow:off { + image: url(:/openpype/images/up_arrow.png); + width: 0.5em; + height: 1em; + border-width: 1px; +} +QAbstractSpinBox::up-arrow:hover { + image: url(:/openpype/images/up_arrow_on.png); + bottom: 1; +} +QAbstractSpinBox::up-arrow:disabled { + image: url(:/openpype/images/up_arrow_disabled.png); +} +QAbstractSpinBox::up-arrow:pressed { + image: url(:/openpype/images/up_arrow_on.png); + bottom: 0; +} + +QAbstractSpinBox::down-arrow, QAbstractSpinBox::down-arrow:off { + image: url(:/openpype/images/down_arrow.png); + width: 0.5em; + height: 1em; + border-width: 1px; +} +QAbstractSpinBox::down-arrow:hover { + image: url(:/openpype/images/down_arrow_on.png); + bottom: 1; +} +QAbstractSpinBox::down-arrow:disabled { + image: url(:/openpype/images/down_arrow_disabled.png); +} +QAbstractSpinBox::down-arrow:hover:pressed { + image: url(:/openpype/images/down_arrow_on.png); + bottom: 0; +} + /* Buttons */ QPushButton { text-align:center center; - border: 1px solid transparent; + border: 0px solid transparent; border-radius: 0.2em; padding: 3px 5px 3px 5px; background: {color:bg-buttons}; @@ -86,15 +149,15 @@ QPushButton::menu-indicator { } QToolButton { - border: none; - background: transparent; + border: 0px solid transparent; + background: {color:bg-buttons}; border-radius: 0.2em; padding: 2px; } QToolButton:hover { - background: #333840; - border-color: {color:border-hover}; + background: {color:bg-button-hover}; + color: {color:font-hover}; } QToolButton:disabled { @@ -104,14 +167,15 @@ QToolButton:disabled { QToolButton[popupMode="1"], QToolButton[popupMode="MenuButtonPopup"] { /* make way for the popup button */ padding-right: 20px; - border: 1px solid {color:bg-buttons}; } QToolButton::menu-button { width: 16px; - /* Set border only of left side. */ + background: transparent; border: 1px solid transparent; - border-left: 1px solid {color:bg-buttons}; + border-left: 1px solid qlineargradient(x1:0, y1:0, x2:0, y2:1, stop: 0 transparent, stop:0.2 {color:font}, stop:0.8 {color:font}, stop: 1 transparent); + padding: 3px 0px 3px 0px; + border-radius: 0; } QToolButton::menu-arrow { @@ -200,12 +264,13 @@ QComboBox::down-arrow, QComboBox::down-arrow:on, QComboBox::down-arrow:hover, QC } /* Splitter */ -QSplitter { - border: none; +QSplitter::handle { + border: 3px solid transparent; } -QSplitter::handle { - border: 1px dotted {color:bg-menu-separator}; +QSplitter::handle:horizontal, QSplitter::handle:vertical, QSplitter::handle:horizontal:hover, QSplitter::handle:vertical:hover { + /* must be single like because of Nuke*/ + background: transparent; } /* SLider */ @@ -232,18 +297,15 @@ QSlider::groove:focus { border-color: {color:border-focus}; } QSlider::handle { - background: qlineargradient( - x1: 0, y1: 0.5, - x2: 1, y2: 0.5, - stop: 0 {palette:blue-base}, - stop: 1 {palette:green-base} - ); + /* must be single like because of Nuke*/ + background: qlineargradient(x1: 0, y1: 0.5, x2: 1, y2: 0.5,stop: 0 {palette:blue-base},stop: 1 {palette:green-base}); border: 1px solid #5c5c5c; width: 10px; height: 10px; border-radius: 5px; } + QSlider::handle:horizontal { margin: -2px 0; } @@ -252,12 +314,8 @@ QSlider::handle:vertical { } QSlider::handle:disabled { - background: qlineargradient( - x1:0, y1:0, - x2:1, y2:1, - stop:0 {color:bg-buttons}, - stop:1 {color:bg-buttons-disabled} - ); + /* must be single like because of Nuke*/ + background: qlineargradient(x1:0, y1:0,x2:1, y2:1,stop:0 {color:bg-buttons},stop:1 {color:bg-buttons-disabled}); } /* Tab widget*/ @@ -267,55 +325,42 @@ QTabWidget::pane { /* move to the right to not mess with borders of widget underneath */ QTabWidget::tab-bar { - left: 2px; + alignment: left; } QTabBar::tab { - padding: 5px; - border-left: 3px solid transparent; border-top: 1px solid {color:border}; + border-left: 1px solid {color:border}; border-right: 1px solid {color:border}; - background: qlineargradient( - x1: 0, y1: 1, x2: 0, y2: 0, - stop: 0.5 {color:bg}, stop: 1.0 {color:bg-inputs} - ); + padding: 5px; + background: {color:tab-widget:bg}; + color: {color:tab-widget:color}; } QTabBar::tab:selected { - background: {color:grey-lighter}; - border-left: 3px solid {color:border-focus}; - background: qlineargradient( - x1: 0, y1: 1, x2: 0, y2: 0, - stop: 0.5 {color:bg}, stop: 1.0 {color:border} - ); -} - -QTabBar::tab:!selected { - background: {color:grey-light}; + border-left-color: {color:tab-widget:bg-selected}; + border-right-color: {color:tab-widget:bg-selected}; + border-top-color: {color:border-focus}; + background: {color:tab-widget:bg-selected}; + color: {color:tab-widget:color-selected}; } +QTabBar::tab:!selected {} QTabBar::tab:!selected:hover { - background: {color:grey-lighter}; + background: {color:tab-widget:bg-hover}; + color: {color:tab-widget:color-hover}; } -QTabBar::tab:first { - border-left: 1px solid {color:border}; -} -QTabBar::tab:first:selected { - margin-left: 0; - border-left: 3px solid {color:border-focus}; -} - -QTabBar::tab:last:selected { - margin-right: 0; -} - -QTabBar::tab:only-one { - margin: 0; +QTabBar::tab:first {} +QTabBar::tab:first:selected {} +QTabBar::tab:last:!selected { + border-right: 1px solid {color:border}; } +QTabBar::tab:last:selected {} +QTabBar::tab:only-one {} QHeaderView { - border: none; - border-radius: 2px; + border: 0px solid {color:border}; + border-radius: 0px; margin: 0px; padding: 0px; } @@ -335,10 +380,72 @@ QHeaderView::section:first { QHeaderView::section:last { border-right: none; } +QHeaderView::section:only-one { + border-left: none; + border-right: none; +} + +QHeaderView::down-arrow { + image: url(:/openpype/images/down_arrow.png); +} + +QHeaderView::up-arrow { + image: url(:/openpype/images/up_arrow.png); +} + +/* Checkboxes */ +QCheckBox { + background: transparent; +} + +QCheckBox::indicator { + width: 16px; + height: 16px; +} + +QAbstractItemView::indicator:checked, QCheckBox::indicator:checked { + image: url(:/openpype/images/checkbox_checked.png); +} +QAbstractItemView::indicator:checked:focus, QCheckBox::indicator:checked:focus { + image: url(:/openpype/images/checkbox_checked_focus.png); +} +QAbstractItemView::indicator:checked:hover, QAbstractItemView::indicator:checked:pressed, QCheckBox::indicator:checked:hover, QCheckBox::indicator:checked:pressed { + image: url(:/openpype/images/checkbox_checked_hover.png); +} +QAbstractItemView::indicator:checked:disabled, QCheckBox::indicator:checked:disabled { + image: url(:/openpype/images/checkbox_checked_disabled.png); +} + +QAbstractItemView::indicator:unchecked, QCheckBox::indicator:unchecked { + image: url(:/openpype/images/checkbox_unchecked.png); +} +QAbstractItemView::indicator:unchecked:focus, QCheckBox::indicator:unchecked:focus { + image: url(:/openpype/images/checkbox_unchecked_focus.png); +} +QAbstractItemView::indicator:unchecked:hover, QAbstractItemView::indicator:unchecked:pressed, QCheckBox::indicator:unchecked:hover, QCheckBox::indicator:unchecked:pressed { + image: url(:/openpype/images/checkbox_unchecked_hover.png); +} +QAbstractItemView::indicator:unchecked:disabled, QCheckBox::indicator:unchecked:disabled { + image: url(:/openpype/images/checkbox_unchecked_disabled.png); +} + +QAbstractItemView::indicator:indeterminate, QCheckBox::indicator:indeterminate { + image: url(:/openpype/images/checkbox_indeterminate.png); +} +QAbstractItemView::indicator:indeterminate:focus, QCheckBox::indicator:indeterminate:focus { + image: url(:/openpype/images/checkbox_indeterminate_focus.png); +} +QAbstractItemView::indicator:indeterminate:hover, QAbstractItemView::indicator:indeterminate:pressed, QCheckBox::indicator:indeterminate:hover, QCheckBox::indicator:indeterminate:pressed { + image: url(:/openpype/images/checkbox_indeterminate_hover.png); +} +QAbstractItemView::indicator:indeterminate:disabled, QCheckBox::indicator:indeterminate:disabled { + image: url(:/openpype/images/checkbox_indeterminate_disabled.png); +} + /* Views QListView QTreeView QTableView */ QAbstractItemView { border: 0px solid {color:border}; - border-radius: 0.2em; + border-radius: 0px; background: {color:bg-view}; alternate-background-color: {color:bg-view-alternate}; /* Mac shows selection color on branches. */ @@ -353,6 +460,7 @@ QAbstractItemView::item { QAbstractItemView:disabled{ background: {color:bg-view-disabled}; alternate-background-color: {color:bg-view-alternate-disabled}; + border: 1px solid {color:border}; } QAbstractItemView::item:hover { @@ -393,23 +501,42 @@ QAbstractItemView::branch:open:has-children:has-siblings { QAbstractItemView::branch:open:has-children:!has-siblings:hover, QAbstractItemView::branch:open:has-children:has-siblings:hover { border-image: none; - image: url(:/openpype/images//branch_open_on.png); + image: url(:/openpype/images/branch_open_on.png); background: transparent; } QAbstractItemView::branch:has-children:!has-siblings:closed, QAbstractItemView::branch:closed:has-children:has-siblings { border-image: none; - image: url(:/openpype/images//branch_closed.png); + image: url(:/openpype/images/branch_closed.png); background: transparent; } QAbstractItemView::branch:has-children:!has-siblings:closed:hover, QAbstractItemView::branch:closed:has-children:has-siblings:hover { border-image: none; - image: url(:/openpype/images//branch_closed_on.png); + image: url(:/openpype/images/branch_closed_on.png); background: transparent; } +QAbstractItemView::branch:has-siblings:!adjoins-item { + border-image: none; + image: url(:/openpype/images/transparent.png); + background: transparent; +} + +QAbstractItemView::branch:has-siblings:adjoins-item { + border-image: none; + image: url(:/openpype/images/transparent.png); + background: transparent; +} + +QAbstractItemView::branch:!has-children:!has-siblings:adjoins-item { + border-image: none; + image: url(:/openpype/images/transparent.png); + background: transparent; +} + + /* Progress bar */ QProgressBar { border: 1px solid {color:border}; @@ -425,12 +552,8 @@ QProgressBar:vertical { } QProgressBar::chunk { - background: qlineargradient( - x1: 0, y1: 0.5, - x2: 1, y2: 0.5, - stop: 0 {palette:blue-base}, - stop: 1 {palette:green-base} - ); + /* must be single like because of Nuke*/ + background: qlineargradient(x1: 0, y1: 0.5,x2: 1, y2: 0.5,stop: 0 {palette:blue-base},stop: 1 {palette:green-base}); } /* Scroll bars */ @@ -542,7 +665,9 @@ QScrollBar::add-page:vertical, QScrollBar::sub-page:vertical { background: {color:bg-menu-separator}; } -#IconBtn {} +#IconButton { + padding: 4px 4px 4px 4px; +} /* Password dialog*/ #PasswordBtn { @@ -566,6 +691,13 @@ QScrollBar::add-page:vertical, QScrollBar::sub-page:vertical { padding-right: 3px; } +#InfoText { + padding-left: 30px; + padding-top: 20px; + background: transparent; + border: 1px solid {color:border}; +} + #TypeEditor, #ToolEditor, #NameEditor, #NumberEditor { background: transparent; border-radius: 0.3em; @@ -624,8 +756,193 @@ QScrollBar::add-page:vertical, QScrollBar::sub-page:vertical { border: 1px solid {color:border}; border-radius: 0.1em; } +/* Subset Manager */ +#SubsetManagerDetailsText {} +#SubsetManagerDetailsText[state="invalid"] { + border: 1px solid #ff0000; +} /* Python console interpreter */ #PythonInterpreterOutput, #PythonCodeEditor { font-family: "Roboto Mono"; + border-radius: 0px; +} + +#SubsetView::item, #RepresentationView:item { + padding: 5px 1px; + border: 0px; +} + +#OptionalActionBody, #OptionalActionOption { + background: transparent; +} + +#OptionalActionBody[state="hover"], #OptionalActionOption[state="hover"] { + background: {color:bg-view-hover}; +} + +/* New Create/Publish UI */ +#PublishLogConsole { + font-family: "Roboto Mono"; +} + +#VariantInput[state="new"], #VariantInput[state="new"]:focus, #VariantInput[state="new"]:hover { + border-color: {color:publisher:success}; +} +#VariantInput[state="invalid"], #VariantInput[state="invalid"]:focus, #VariantInput[state="invalid"]:hover { + border-color: {color:publisher:error}; +} + +#VariantInput[state="empty"], #VariantInput[state="empty"]:focus, #VariantInput[state="empty"]:hover { + border-color: {color:bg-inputs}; +} + +#VariantInput[state="exists"], #VariantInput[state="exists"]:focus, #VariantInput[state="exists"]:hover { + border-color: #4E76BB; +} + +#MultipleItemView { + background: transparent; + border: none; +} + +#MultipleItemView:item { + background: {color:bg-view-selection}; + border-radius: 0.4em; +} + +#InstanceListView::item { + border-radius: 0.3em; + margin: 1px; +} +#InstanceListGroupWidget { + border: none; + background: transparent; +} + +#CardViewWidget { + background: {color:bg-buttons}; + border-radius: 0.2em; +} +#CardViewWidget:hover { + background: {color:bg-button-hover}; +} +#CardViewWidget[state="selected"] { + background: {color:bg-view-selection}; +} + +#ListViewSubsetName[state="invalid"] { + color: {color:publisher:error}; +} + +#PublishFrame { + background: rgba(0, 0, 0, 127); +} +#PublishFrame[state="1"] { + background: rgb(22, 25, 29); +} +#PublishFrame[state="2"] { + background: {color:bg}; +} + +#PublishInfoFrame { + background: {color:bg}; + border: 2px solid black; + border-radius: 0.3em; +} + +#PublishInfoFrame[state="-1"] { + background: rgb(194, 226, 236); +} + +#PublishInfoFrame[state="0"] { + background: {color:publisher:error}; +} + +#PublishInfoFrame[state="1"] { + background: {color:publisher:success}; +} + +#PublishInfoFrame[state="2"] { + background: {color:publisher:warning}; +} + +#PublishInfoFrame QLabel { + color: black; + font-style: bold; +} + +#PublishInfoMainLabel { + font-size: 12pt; +} + +#PublishContextLabel { + font-size: 13pt; +} + +#ValidationActionButton { + border-radius: 0.2em; + padding: 4px 6px 4px 6px; + background: {color:bg-buttons}; +} + +#ValidationActionButton:hover { + background: {color:bg-button-hover}; + color: {color:font-hover}; +} + +#ValidationActionButton:disabled { + background: {color:bg-buttons-disabled}; +} + +#ValidationErrorTitleFrame { + background: {color:bg-inputs}; + border-left: 4px solid transparent; +} + +#ValidationErrorTitleFrame:hover { + border-left-color: {color:border}; +} + +#ValidationErrorTitleFrame[selected="1"] { + background: {color:bg}; + border-left-color: {palette:blue-light}; +} + +#ValidationErrorInstanceList { + border-radius: 0; +} + +#ValidationErrorInstanceList::item { + border-bottom: 1px solid {color:border}; + border-left: 1px solid {color:border}; +} + +#TasksCombobox[state="invalid"], #AssetNameInput[state="invalid"] { + border-color: {color:publisher:error}; +} + +#PublishProgressBar[state="0"]::chunk { + background: {color:bg-buttons}; +} + +#PublishDetailViews { + background: transparent; +} +#PublishDetailViews::item { + margin: 1px 0px 1px 0px; +} +#PublishCommentInput { + padding: 0.2em; +} +#FamilyIconLabel { + font-size: 14pt; +} +#ArrowBtn, #ArrowBtn:disabled, #ArrowBtn:hover { + background: transparent; +} + +#NiceCheckbox { + /* Default size hint of NiceCheckbox is defined by font size. */ + font-size: 7pt; } diff --git a/openpype/tests/test_mongo_performance.py b/openpype/tests/mongo_performance.py similarity index 82% rename from openpype/tests/test_mongo_performance.py rename to openpype/tests/mongo_performance.py index cd606d6483..2df3363f4b 100644 --- a/openpype/tests/test_mongo_performance.py +++ b/openpype/tests/mongo_performance.py @@ -80,7 +80,7 @@ class TestPerformance(): file_id3 = bson.objectid.ObjectId() self.inserted_ids.extend([file_id, file_id2, file_id3]) - version_str = "v{0:03}".format(i + 1) + version_str = "v{:03d}".format(i + 1) file_name = "test_Cylinder_workfileLookdev_{}.mb".\ format(version_str) @@ -95,7 +95,7 @@ class TestPerformance(): "family": "workfile", "hierarchy": "Assets", "project": {"code": "test", "name": "Test"}, - "version": 1, + "version": i + 1, "asset": "Cylinder", "representation": "mb", "root": self.ROOT_DIR @@ -104,8 +104,8 @@ class TestPerformance(): "name": "mb", "parent": {"oid": '{}'.format(id)}, "data": { - "path": "C:\\projects\\Test\\Assets\\Cylinder\\publish\\workfile\\workfileLookdev\\{}\\{}".format(version_str, file_name), - "template": "{root}\\{project[name]}\\{hierarchy}\\{asset}\\publish\\{family}\\{subset}\\v{version:0>3}\\{project[code]}_{asset}_{subset}_v{version:0>3}<_{output}><.{frame:0>4}>.{representation}" + "path": "C:\\projects\\test_performance\\Assets\\Cylinder\\publish\\workfile\\workfileLookdev\\{}\\{}".format(version_str, file_name), # noqa: E501 + "template": "{root[work]}\\{project[name]}\\{hierarchy}\\{asset}\\publish\\{family}\\{subset}\\v{version:0>3}\\{project[code]}_{asset}_{subset}_v{version:0>3}<_{output}><.{frame:0>4}>.{representation}" # noqa: E501 }, "type": "representation", "schema": "openpype:representation-2.0" @@ -188,30 +188,21 @@ class TestPerformance(): create_files=False): ret = [ { - "path": "{root}" + "/Test/Assets/Cylinder/publish/workfile/" + - "workfileLookdev/v{0:03}/" + - "test_Cylinder_A_workfileLookdev_v{0:03}.dat" - .format(i, i), + "path": "{root[work]}" + "{root[work]}/test_performance/Assets/Cylinder/publish/workfile/workfileLookdev/v{:03d}/test_Cylinder_A_workfileLookdev_v{:03d}.dat".format(i, i), # noqa: E501 "_id": '{}'.format(file_id), "hash": "temphash", "sites": self.get_sites(self.MAX_NUMBER_OF_SITES), "size": random.randint(0, self.MAX_FILE_SIZE_B) }, { - "path": "{root}" + "/Test/Assets/Cylinder/publish/workfile/" + - "workfileLookdev/v{0:03}/" + - "test_Cylinder_B_workfileLookdev_v{0:03}.dat" - .format(i, i), + "path": "{root[work]}" + "/test_performance/Assets/Cylinder/publish/workfile/workfileLookdev/v{:03d}/test_Cylinder_B_workfileLookdev_v{:03d}.dat".format(i, i), # noqa: E501 "_id": '{}'.format(file_id2), "hash": "temphash", "sites": self.get_sites(self.MAX_NUMBER_OF_SITES), "size": random.randint(0, self.MAX_FILE_SIZE_B) }, { - "path": "{root}" + "/Test/Assets/Cylinder/publish/workfile/" + - "workfileLookdev/v{0:03}/" + - "test_Cylinder_C_workfileLookdev_v{0:03}.dat" - .format(i, i), + "path": "{root[work]}" + "/test_performance/Assets/Cylinder/publish/workfile/workfileLookdev/v{:03d}/test_Cylinder_C_workfileLookdev_v{:03d}.dat".format(i, i), # noqa: E501 "_id": '{}'.format(file_id3), "hash": "temphash", "sites": self.get_sites(self.MAX_NUMBER_OF_SITES), @@ -221,7 +212,7 @@ class TestPerformance(): ] if create_files: for f in ret: - path = f.get("path").replace("{root}", self.ROOT_DIR) + path = f.get("path").replace("{root[work]}", self.ROOT_DIR) os.makedirs(os.path.dirname(path), exist_ok=True) with open(path, 'wb') as fp: fp.write(os.urandom(f.get("size"))) @@ -231,26 +222,26 @@ class TestPerformance(): def get_files_doc(self, i, file_id, file_id2, file_id3): ret = {} ret['{}'.format(file_id)] = { - "path": "{root}" + - "/Test/Assets/Cylinder/publish/workfile/workfileLookdev/" - "v001/test_CylinderA_workfileLookdev_v{0:03}.mb".format(i), + "path": "{root[work]}" + + "/test_performance/Assets/Cylinder/publish/workfile/workfileLookdev/" # noqa: E501 + "v{:03d}/test_CylinderA_workfileLookdev_v{:03d}.mb".format(i, i), # noqa: E501 "hash": "temphash", "sites": ["studio"], "size": 87236 } ret['{}'.format(file_id2)] = { - "path": "{root}" + - "/Test/Assets/Cylinder/publish/workfile/workfileLookdev/" - "v001/test_CylinderB_workfileLookdev_v{0:03}.mb".format(i), + "path": "{root[work]}" + + "/test_performance/Assets/Cylinder/publish/workfile/workfileLookdev/" # noqa: E501 + "v{:03d}/test_CylinderB_workfileLookdev_v{:03d}.mb".format(i, i), # noqa: E501 "hash": "temphash", "sites": ["studio"], "size": 87236 } ret['{}'.format(file_id3)] = { - "path": "{root}" + - "/Test/Assets/Cylinder/publish/workfile/workfileLookdev/" - "v001/test_CylinderC_workfileLookdev_v{0:03}.mb".format(i), + "path": "{root[work]}" + + "/test_performance/Assets/Cylinder/publish/workfile/workfileLookdev/" # noqa: E501 + "v{:03d}/test_CylinderC_workfileLookdev_v{:03d}.mb".format(i, i), # noqa: E501 "hash": "temphash", "sites": ["studio"], "size": 87236 @@ -287,7 +278,7 @@ class TestPerformance(): if __name__ == '__main__': tp = TestPerformance('array') - tp.prepare(no_of_records=10, create_files=True) # enable to prepare data + tp.prepare(no_of_records=10000, create_files=True) # tp.run(10, 3) # print('-'*50) diff --git a/openpype/tools/context_dialog/__init__.py b/openpype/tools/context_dialog/__init__.py new file mode 100644 index 0000000000..9b10baf903 --- /dev/null +++ b/openpype/tools/context_dialog/__init__.py @@ -0,0 +1,10 @@ +from .window import ( + ContextDialog, + main +) + + +__all__ = ( + "ContextDialog", + "main" +) diff --git a/openpype/tools/context_dialog/window.py b/openpype/tools/context_dialog/window.py new file mode 100644 index 0000000000..7f3ac75445 --- /dev/null +++ b/openpype/tools/context_dialog/window.py @@ -0,0 +1,406 @@ +import os +import json + +from Qt import QtWidgets, QtCore, QtGui +from avalon.api import AvalonMongoDB + +from openpype import style +from openpype.tools.utils.lib import center_window +from openpype.tools.utils.widgets import AssetWidget +from openpype.tools.utils.constants import ( + PROJECT_NAME_ROLE +) +from openpype.tools.utils.tasks_widget import TasksWidget +from openpype.tools.utils.models import ( + ProjectModel, + ProjectSortFilterProxy +) + + +class ContextDialog(QtWidgets.QDialog): + """Dialog to select a context. + + Context has 3 parts: + - Project + - Aseet + - Task + + It is possible to predefine project and asset. In that case their widgets + will have passed preselected values and will be disabled. + """ + def __init__(self, parent=None): + super(ContextDialog, self).__init__(parent) + + self.setWindowTitle("Select Context") + self.setWindowIcon(QtGui.QIcon(style.app_icon_path())) + + # Enable minimize and maximize for app + window_flags = QtCore.Qt.Window + if not parent: + window_flags |= QtCore.Qt.WindowStaysOnTopHint + self.setWindowFlags(window_flags) + self.setFocusPolicy(QtCore.Qt.StrongFocus) + + dbcon = AvalonMongoDB() + + # UI initialization + main_splitter = QtWidgets.QSplitter(self) + + # Left side widget containt project combobox and asset widget + left_side_widget = QtWidgets.QWidget(main_splitter) + + project_combobox = QtWidgets.QComboBox(left_side_widget) + # Styled delegate to propagate stylessheet + project_delegate = QtWidgets.QStyledItemDelegate(project_combobox) + project_combobox.setItemDelegate(project_delegate) + # Project model with only active projects without default item + project_model = ProjectModel( + dbcon, + only_active=True, + add_default_project=False + ) + # Sorting proxy model + project_proxy = ProjectSortFilterProxy() + project_proxy.setSourceModel(project_model) + project_combobox.setModel(project_proxy) + + # Assets widget + assets_widget = AssetWidget( + dbcon, multiselection=False, parent=left_side_widget + ) + + left_side_layout = QtWidgets.QVBoxLayout(left_side_widget) + left_side_layout.setContentsMargins(0, 0, 0, 0) + left_side_layout.addWidget(project_combobox) + left_side_layout.addWidget(assets_widget) + + # Right side of window contains only tasks + tasks_widget = TasksWidget(dbcon, main_splitter) + + # Add widgets to main splitter + main_splitter.addWidget(left_side_widget) + main_splitter.addWidget(tasks_widget) + + # Set stretch of both sides + main_splitter.setStretchFactor(0, 7) + main_splitter.setStretchFactor(1, 3) + + # Add confimation button to bottom right + ok_btn = QtWidgets.QPushButton("OK", self) + + buttons_layout = QtWidgets.QHBoxLayout() + buttons_layout.setContentsMargins(0, 0, 0, 0) + buttons_layout.addStretch(1) + buttons_layout.addWidget(ok_btn, 0) + + main_layout = QtWidgets.QVBoxLayout(self) + main_layout.addWidget(main_splitter, 1) + main_layout.addLayout(buttons_layout, 0) + + # Timer which will trigger asset refresh + # - this is needed because asset widget triggers + # finished refresh before hides spin box so we need to trigger + # refreshing in small offset if we want re-refresh asset widget + assets_timer = QtCore.QTimer() + assets_timer.setInterval(50) + assets_timer.setSingleShot(True) + + assets_timer.timeout.connect(self._on_asset_refresh_timer) + + project_combobox.currentIndexChanged.connect( + self._on_project_combo_change + ) + assets_widget.selection_changed.connect(self._on_asset_change) + assets_widget.refresh_triggered.connect(self._on_asset_refresh_trigger) + assets_widget.refreshed.connect(self._on_asset_widget_refresh_finished) + tasks_widget.task_changed.connect(self._on_task_change) + ok_btn.clicked.connect(self._on_ok_click) + + self._dbcon = dbcon + + self._project_combobox = project_combobox + self._project_model = project_model + self._project_proxy = project_proxy + self._project_delegate = project_delegate + + self._assets_widget = assets_widget + + self._tasks_widget = tasks_widget + + self._ok_btn = ok_btn + + self._strict = False + + # Values set by `set_context` method + self._set_context_project = None + self._set_context_asset = None + + # Requirements for asset widget refresh + self._assets_timer = assets_timer + self._rerefresh_assets = True + self._assets_refreshing = False + + # Set stylehseet and resize window on first show + self._first_show = True + + # Helper attributes for handling of refresh + self._ignore_value_changes = False + self._refresh_on_next_show = True + + # Output of dialog + self._context_to_store = { + "project": None, + "asset": None, + "task": None + } + + def closeEvent(self, event): + """Ignore close event if is in strict state and context is not done.""" + if self._strict and not self._ok_btn.isEnabled(): + event.ignore() + return + + if self._strict: + self._confirm_values() + super(ContextDialog, self).closeEvent(event) + + def set_strict(self, strict): + """Change strictness of dialog.""" + self._strict = strict + self._validate_strict() + + def _set_refresh_on_next_show(self): + """Refresh will be called on next showEvent. + + If window is already visible then just execute refresh. + """ + self._refresh_on_next_show = True + if self.isVisible(): + self.refresh() + + def _refresh_assets(self): + """Trigger refreshing of asset widget. + + This will set mart to rerefresh asset when current refreshing is done + or do it immidietely if asset widget is not refreshing at the time. + """ + if self._assets_refreshing: + self._rerefresh_assets = True + else: + self._on_asset_refresh_timer() + + def showEvent(self, event): + """Override show event to do some callbacks.""" + super(ContextDialog, self).showEvent(event) + if self._first_show: + self._first_show = False + # Set stylesheet and resize + self.setStyleSheet(style.load_stylesheet()) + self.resize(600, 700) + center_window(self) + + if self._refresh_on_next_show: + self.refresh() + + def refresh(self): + """Refresh all widget one by one. + + When asset refresh is triggered we have to wait when is done so + this method continues with `_on_asset_widget_refresh_finished`. + """ + # Change state of refreshing (no matter how refresh was called) + self._refresh_on_next_show = False + + # Ignore changes of combobox and asset widget + self._ignore_value_changes = True + + # Get current project name to be able set it afterwards + select_project_name = self._dbcon.Session.get("AVALON_PROJECT") + # Trigger project refresh + self._project_model.refresh() + # Sort projects + self._project_proxy.sort(0) + + # Disable combobox if project was passed to `set_context` + if self._set_context_project: + select_project_name = self._set_context_project + self._project_combobox.setEnabled(False) + else: + # Find new project to select + self._project_combobox.setEnabled(True) + if ( + select_project_name is None + and self._project_proxy.rowCount() > 0 + ): + index = self._project_proxy.index(0, 0) + select_project_name = index.data(PROJECT_NAME_ROLE) + + self._ignore_value_changes = False + + idx = self._project_combobox.findText(select_project_name) + if idx >= 0: + self._project_combobox.setCurrentIndex(idx) + self._dbcon.Session["AVALON_PROJECT"] = ( + self._project_combobox.currentText() + ) + + # Trigger asset refresh + self._refresh_assets() + + def _on_asset_refresh_timer(self): + """This is only way how to trigger refresh asset widget. + + Use `_refresh_assets` method to refresh asset widget. + """ + self._assets_widget.refresh() + + def _on_asset_widget_refresh_finished(self): + """Catch when asset widget finished refreshing.""" + # If should refresh again then skip all other callbacks and trigger + # assets timer directly. + self._assets_refreshing = False + if self._rerefresh_assets: + self._rerefresh_assets = False + self._assets_timer.start() + return + + self._ignore_value_changes = True + if self._set_context_asset: + self._dbcon.Session["AVALON_ASSET"] = self._set_context_asset + self._assets_widget.setEnabled(False) + self._assets_widget.select_assets(self._set_context_asset) + self._set_asset_to_tasks_widget() + else: + self._assets_widget.setEnabled(True) + self._assets_widget.set_current_asset_btn_visibility(False) + + # Refresh tasks + self._tasks_widget.refresh() + + self._ignore_value_changes = False + + self._validate_strict() + + def _on_project_combo_change(self): + if self._ignore_value_changes: + return + project_name = self._project_combobox.currentText() + + if self._dbcon.Session.get("AVALON_PROJECT") == project_name: + return + + self._dbcon.Session["AVALON_PROJECT"] = project_name + + self._refresh_assets() + self._validate_strict() + + def _on_asset_refresh_trigger(self): + self._assets_refreshing = True + self._on_asset_change() + + def _on_asset_change(self): + """Selected assets have changed""" + if self._ignore_value_changes: + return + self._set_asset_to_tasks_widget() + + def _on_task_change(self): + self._validate_strict() + + def _set_asset_to_tasks_widget(self): + # filter None docs they are silo + asset_docs = self._assets_widget.get_selected_assets() + asset_ids = [asset_doc["_id"] for asset_doc in asset_docs] + asset_id = None + if asset_ids: + asset_id = asset_ids[0] + self._tasks_widget.set_asset_id(asset_id) + + def _confirm_values(self): + """Store values to output.""" + self._context_to_store["project"] = self.get_selected_project() + self._context_to_store["asset"] = self.get_selected_asset() + self._context_to_store["task"] = self.get_selected_task() + + def _on_ok_click(self): + # Store values to output + self._confirm_values() + # Close dialog + self.accept() + + def get_selected_project(self): + """Get selected project.""" + return self._project_combobox.currentText() + + def get_selected_asset(self): + """Currently selected asset in asset widget.""" + asset_name = None + for asset_doc in self._assets_widget.get_selected_assets(): + asset_name = asset_doc["name"] + break + return asset_name + + def get_selected_task(self): + """Currently selected task.""" + return self._tasks_widget.get_selected_task_name() + + def _validate_strict(self): + if not self._strict: + if not self._ok_btn.isEnabled(): + self._ok_btn.setEnabled(True) + return + + enabled = True + if not self._set_context_project and not self.get_selected_project(): + enabled = False + elif not self._set_context_asset and not self.get_selected_asset(): + enabled = False + elif not self.get_selected_task(): + enabled = False + self._ok_btn.setEnabled(enabled) + + def set_context(self, project_name=None, asset_name=None): + """Set context which will be used and locked in dialog.""" + if project_name is None: + asset_name = None + + self._set_context_project = project_name + self._set_context_asset = asset_name + + self._context_to_store["project"] = project_name + self._context_to_store["asset"] = asset_name + + self._set_refresh_on_next_show() + + def get_context(self): + """Result of dialog.""" + return self._context_to_store + + +def main( + path_to_store, + project_name=None, + asset_name=None, + strict=True +): + # Run Qt application + app = QtWidgets.QApplication.instance() + if app is None: + app = QtWidgets.QApplication([]) + window = ContextDialog() + window.set_strict(strict) + window.set_context(project_name, asset_name) + window.show() + app.exec_() + + # Get result from window + data = window.get_context() + + # Make sure json filepath directory exists + file_dir = os.path.dirname(path_to_store) + if not os.path.exists(file_dir): + os.makedirs(file_dir) + + # Store result into json file + with open(path_to_store, "w") as stream: + json.dump(data, stream) diff --git a/openpype/tools/experimental_tools/__init__.py b/openpype/tools/experimental_tools/__init__.py new file mode 100644 index 0000000000..d6315e4655 --- /dev/null +++ b/openpype/tools/experimental_tools/__init__.py @@ -0,0 +1,14 @@ +from .tools_def import ( + ExperimentalTools, + LOCAL_EXPERIMENTAL_KEY +) + +from .dialog import ExperimentalToolsDialog + + +__all__ = ( + "ExperimentalTools", + "LOCAL_EXPERIMENTAL_KEY", + + "ExperimentalToolsDialog" +) diff --git a/openpype/tools/experimental_tools/dialog.py b/openpype/tools/experimental_tools/dialog.py new file mode 100644 index 0000000000..ad65caa8e3 --- /dev/null +++ b/openpype/tools/experimental_tools/dialog.py @@ -0,0 +1,215 @@ +from Qt import QtWidgets, QtCore, QtGui + +from openpype.style import ( + load_stylesheet, + app_icon_path +) + +from .tools_def import ExperimentalTools + + +class ToolButton(QtWidgets.QPushButton): + triggered = QtCore.Signal(str) + + def __init__(self, identifier, *args, **kwargs): + super(ToolButton, self).__init__(*args, **kwargs) + self._identifier = identifier + + self.clicked.connect(self._on_click) + + def _on_click(self): + self.triggered.emit(self._identifier) + + +class ExperimentalToolsDialog(QtWidgets.QDialog): + refresh_interval = 3000 + + def __init__(self, parent=None): + super(ExperimentalToolsDialog, self).__init__(parent) + self.setWindowTitle("OpenPype Experimental tools") + icon = QtGui.QIcon(app_icon_path()) + self.setWindowIcon(icon) + self.setStyleSheet(load_stylesheet()) + + # Widgets for cases there are not available experimental tools + empty_widget = QtWidgets.QWidget(self) + + empty_label = QtWidgets.QLabel( + "There are no experimental tools available...", empty_widget + ) + + empty_btns_layout = QtWidgets.QHBoxLayout() + ok_btn = QtWidgets.QPushButton("OK", empty_widget) + + empty_btns_layout.setContentsMargins(0, 0, 0, 0) + empty_btns_layout.addStretch(1) + empty_btns_layout.addWidget(ok_btn, 0) + + empty_layout = QtWidgets.QVBoxLayout(empty_widget) + empty_layout.setContentsMargins(0, 0, 0, 0) + empty_layout.addWidget(empty_label) + empty_layout.addStretch(1) + empty_layout.addLayout(empty_btns_layout) + + # Content of Experimental tools + + # Layout where buttons are added + content_layout = QtWidgets.QVBoxLayout() + content_layout.setContentsMargins(0, 0, 0, 0) + + # Separator line + separator_widget = QtWidgets.QWidget(self) + separator_widget.setObjectName("Separator") + separator_widget.setMinimumHeight(2) + separator_widget.setMaximumHeight(2) + + # Label describing how to turn off tools + tool_btns_widget = QtWidgets.QWidget(self) + tool_btns_label = QtWidgets.QLabel( + ( + "You can enable these features in" + "
OpenPype tray -> Settings -> Experimental tools" + ), + tool_btns_widget + ) + tool_btns_label.setAlignment(QtCore.Qt.AlignCenter) + + tool_btns_layout = QtWidgets.QVBoxLayout(tool_btns_widget) + tool_btns_layout.setContentsMargins(0, 0, 0, 0) + tool_btns_layout.addLayout(content_layout) + tool_btns_layout.addStretch(1) + tool_btns_layout.addWidget(separator_widget, 0) + tool_btns_layout.addWidget(tool_btns_label, 0) + + experimental_tools = ExperimentalTools( + parent=parent, filter_hosts=True + ) + + # Main layout + layout = QtWidgets.QVBoxLayout(self) + layout.addWidget(empty_widget, 1) + layout.addWidget(tool_btns_widget, 1) + + refresh_timer = QtCore.QTimer() + refresh_timer.setInterval(self.refresh_interval) + refresh_timer.timeout.connect(self._on_refresh_timeout) + + ok_btn.clicked.connect(self._on_ok_click) + + self._empty_widget = empty_widget + self._tool_btns_widget = tool_btns_widget + self._content_layout = content_layout + + self._experimental_tools = experimental_tools + self._buttons_by_tool_identifier = {} + + self._refresh_timer = refresh_timer + + # Is dialog first shown + self._first_show = True + # Trigger refresh when window get's activity + self._refresh_on_active = True + # Is window active + self._window_is_active = False + + def refresh(self): + self._experimental_tools.refresh_availability() + + buttons_to_remove = set(self._buttons_by_tool_identifier.keys()) + for idx, tool in enumerate(self._experimental_tools.tools): + identifier = tool.identifier + if identifier in buttons_to_remove: + buttons_to_remove.remove(identifier) + is_new = False + button = self._buttons_by_tool_identifier[identifier] + else: + is_new = True + button = ToolButton(identifier, self._tool_btns_widget) + button.triggered.connect(self._on_btn_trigger) + self._buttons_by_tool_identifier[identifier] = button + self._content_layout.insertWidget(idx, button) + + if button.text() != tool.label: + button.setText(tool.label) + + if tool.enabled: + button.setToolTip(tool.tooltip) + + elif is_new or button.isEnabled(): + button.setToolTip(( + "You can enable this tool in local settings." + "\n\nOpenPype Tray > Settings > Experimental Tools" + )) + + if tool.enabled != button.isEnabled(): + button.setEnabled(tool.enabled) + + for identifier in buttons_to_remove: + button = self._buttons_by_tool_identifier.pop(identifier) + button.setVisible(False) + idx = self._content_layout.indexOf(button) + self._content_layout.takeAt(idx) + button.deleteLater() + + self._set_visibility() + + def _is_content_visible(self): + return len(self._buttons_by_tool_identifier) > 0 + + def _set_visibility(self): + content_visible = self._is_content_visible() + self._tool_btns_widget.setVisible(content_visible) + self._empty_widget.setVisible(not content_visible) + + def _on_ok_click(self): + self.close() + + def _on_btn_trigger(self, identifier): + tool = self._experimental_tools.tools_by_identifier.get(identifier) + if tool is not None: + tool.execute() + + def showEvent(self, event): + super(ExperimentalToolsDialog, self).showEvent(event) + + if self._refresh_on_active: + # Start/Restart timer + self._refresh_timer.start() + # Refresh + self.refresh() + + elif not self._refresh_timer.isActive(): + self._refresh_timer.start() + + if self._first_show: + self._first_show = False + # Set stylesheet + self.setStyleSheet(load_stylesheet()) + # Resize dialog if there is not content + if not self._is_content_visible(): + size = self.size() + size.setWidth(size.width() + size.width() / 3) + self.resize(size) + + def changeEvent(self, event): + if event.type() == QtCore.QEvent.ActivationChange: + self._window_is_active = self.isActiveWindow() + if self._window_is_active and self._refresh_on_active: + self._refresh_timer.start() + self.refresh() + + super(ExperimentalToolsDialog, self).changeEvent(event) + + def _on_refresh_timeout(self): + # Stop timer if window is not visible + if not self.isVisible(): + self._refresh_on_active = True + self._refresh_timer.stop() + + # Skip refreshing if window is not active + elif not self._window_is_active: + self._refresh_on_active = True + + # Window is active and visible so we're refreshing buttons + else: + self.refresh() diff --git a/openpype/tools/experimental_tools/tools_def.py b/openpype/tools/experimental_tools/tools_def.py new file mode 100644 index 0000000000..991eb5e4a3 --- /dev/null +++ b/openpype/tools/experimental_tools/tools_def.py @@ -0,0 +1,161 @@ +import os +from openpype.settings import get_local_settings + +# Constant key under which local settings are stored +LOCAL_EXPERIMENTAL_KEY = "experimental_tools" + + +class ExperimentalTool: + """Definition of experimental tool. + + Definition is used in local settings and in experimental tools dialog. + + Args: + identifier (str): String identifier of tool (unique). + label (str): Label shown in UI. + callback (function): Callback for UI button. + tooltip (str): Tooltip showed on button. + hosts_filter (list): List of host names for which is tool available. + Some tools may not be available in all hosts. + """ + def __init__( + self, identifier, label, callback, tooltip, hosts_filter=None + ): + self.identifier = identifier + self.label = label + self.callback = callback + self.tooltip = tooltip + self.hosts_filter = hosts_filter + self._enabled = True + + def is_available_for_host(self, host_name): + if self.hosts_filter: + return host_name in self.hosts_filter + return True + + @property + def enabled(self): + """Is tool enabled and button is clickable.""" + return self._enabled + + def set_enabled(self, enabled=True): + """Change if tool is enabled.""" + self._enabled = enabled + + def execute(self): + """Trigger registerd callback.""" + self.callback() + + +class ExperimentalTools: + """Wrapper around experimental tools. + + To add/remove experimental tool just add/remove tool to + `experimental_tools` variable in __init__ function. + + Args: + parent (QtWidgets.QWidget): Parent widget for tools. + host_name (str): Name of host in which context we're now. Environment + value 'AVALON_APP' is used when not passed. + filter_hosts (bool): Should filter tools. By default is set to 'True' + when 'host_name' is passed. Is always set to 'False' if 'host_name' + is not defined. + """ + def __init__(self, parent=None, host_name=None, filter_hosts=None): + # Definition of experimental tools + experimental_tools = [ + ExperimentalTool( + "publisher", + "New publisher", + self._show_publisher, + "Combined creation and publishing into one tool." + ) + ] + + # --- Example tool (callback will just print on click) --- + # def example_callback(*args): + # print("Triggered tool") + # + # experimental_tools = [ + # ExperimentalTool( + # "example", + # "Example experimental tool", + # example_callback, + # "Example tool tooltip." + # ) + # ] + + # Try to get host name from env variable `AVALON_APP` + if not host_name: + host_name = os.environ.get("AVALON_APP") + + # Decide if filtering by host name should happen + if filter_hosts is None: + filter_hosts = host_name is not None + + if filter_hosts and not host_name: + filter_hosts = False + + # Filter tools by host name + if filter_hosts: + experimental_tools = [ + tool + for tool in experimental_tools + if tool.is_available_for_host(host_name) + ] + + # Store tools by identifier + tools_by_identifier = {} + for tool in experimental_tools: + if tool.identifier in tools_by_identifier: + raise KeyError(( + "Duplicated experimental tool identifier \"{}\"" + ).format(tool.identifier)) + tools_by_identifier[tool.identifier] = tool + + self._tools_by_identifier = tools_by_identifier + self._tools = experimental_tools + self._parent_widget = parent + + self._publisher_tool = None + + @property + def tools(self): + """Tools in list. + + Returns: + list: Tools filtered by host name if filtering was enabled + on initialization. + """ + return self._tools + + @property + def tools_by_identifier(self): + """Tools by their identifier. + + Returns: + dict: Tools by identifier filtered by host name if filtering + was enabled on initialization. + """ + return self._tools_by_identifier + + def refresh_availability(self): + """Reload local settings and check if any tool changed ability.""" + local_settings = get_local_settings() + experimental_settings = ( + local_settings.get(LOCAL_EXPERIMENTAL_KEY) + ) or {} + + for identifier, eperimental_tool in self.tools_by_identifier.items(): + enabled = experimental_settings.get(identifier, False) + eperimental_tool.set_enabled(enabled) + + def _show_publisher(self): + if self._publisher_tool is None: + from openpype.tools import publisher + + self._publisher_tool = publisher.PublisherWindow( + parent=self._parent_widget + ) + + self._publisher_tool.show() diff --git a/openpype/tools/launcher/flickcharm.py b/openpype/tools/flickcharm.py similarity index 100% rename from openpype/tools/launcher/flickcharm.py rename to openpype/tools/flickcharm.py diff --git a/openpype/tools/launcher/models.py b/openpype/tools/launcher/models.py index f87871409e..427475cb4b 100644 --- a/openpype/tools/launcher/models.py +++ b/openpype/tools/launcher/models.py @@ -19,102 +19,6 @@ from openpype.lib import ApplicationManager log = logging.getLogger(__name__) -class TaskModel(QtGui.QStandardItemModel): - """A model listing the tasks combined for a list of assets""" - - def __init__(self, dbcon, parent=None): - super(TaskModel, self).__init__(parent=parent) - self.dbcon = dbcon - - self._num_assets = 0 - - self.default_icon = qtawesome.icon( - "fa.male", color=style.colors.default - ) - self.no_task_icon = qtawesome.icon( - "fa.exclamation-circle", color=style.colors.mid - ) - - self._icons = {} - - self._get_task_icons() - - def _get_task_icons(self): - if not self.dbcon.Session.get("AVALON_PROJECT"): - return - - # Get the project configured icons from database - project = self.dbcon.find_one({"type": "project"}) - for task in project["config"].get("tasks") or []: - icon_name = task.get("icon") - if icon_name: - self._icons[task["name"]] = qtawesome.icon( - "fa.{}".format(icon_name), color=style.colors.default - ) - - def set_assets(self, asset_ids=None, asset_docs=None): - """Set assets to track by their database id - - Arguments: - asset_ids (list): List of asset ids. - asset_docs (list): List of asset entities from MongoDB. - - """ - - if asset_docs is None and asset_ids is not None: - # find assets in db by query - asset_docs = list(self.dbcon.find({ - "type": "asset", - "_id": {"$in": asset_ids} - })) - db_assets_ids = tuple(asset_doc["_id"] for asset_doc in asset_docs) - - # check if all assets were found - not_found = tuple( - str(asset_id) - for asset_id in asset_ids - if asset_id not in db_assets_ids - ) - - assert not not_found, "Assets not found by id: {0}".format( - ", ".join(not_found) - ) - - self.clear() - - if not asset_docs: - return - - task_names = set() - for asset_doc in asset_docs: - asset_tasks = asset_doc.get("data", {}).get("tasks") or set() - task_names.update(asset_tasks) - - self.beginResetModel() - - if not task_names: - item = QtGui.QStandardItem(self.no_task_icon, "No task") - item.setEnabled(False) - self.appendRow(item) - - else: - for task_name in sorted(task_names): - icon = self._icons.get(task_name, self.default_icon) - item = QtGui.QStandardItem(icon, task_name) - self.appendRow(item) - - self.endResetModel() - - def headerData(self, section, orientation, role): - if ( - role == QtCore.Qt.DisplayRole - and orientation == QtCore.Qt.Horizontal - and section == 0 - ): - return "Tasks" - return super(TaskModel, self).headerData(section, orientation, role) - - class ActionModel(QtGui.QStandardItemModel): def __init__(self, dbcon, parent=None): super(ActionModel, self).__init__(parent=parent) diff --git a/openpype/tools/launcher/widgets.py b/openpype/tools/launcher/widgets.py index 35c7d98be1..edda8d08b5 100644 --- a/openpype/tools/launcher/widgets.py +++ b/openpype/tools/launcher/widgets.py @@ -6,8 +6,8 @@ from avalon.vendor import qtawesome from .delegates import ActionDelegate from . import lib -from .models import TaskModel, ActionModel, ProjectModel -from .flickcharm import FlickCharm +from .models import ActionModel +from openpype.tools.flickcharm import FlickCharm from .constants import ( ACTION_ROLE, GROUP_ROLE, @@ -90,9 +90,6 @@ class ActionBar(QtWidgets.QWidget): self.project_handler = project_handler self.dbcon = dbcon - layout = QtWidgets.QHBoxLayout(self) - layout.setContentsMargins(8, 0, 8, 0) - view = QtWidgets.QListView(self) view.setProperty("mode", "icon") view.setObjectName("IconView") @@ -116,6 +113,8 @@ class ActionBar(QtWidgets.QWidget): ) view.setItemDelegate(delegate) + layout = QtWidgets.QHBoxLayout(self) + layout.setContentsMargins(0, 0, 0, 0) layout.addWidget(view) self.model = model @@ -261,92 +260,6 @@ class ActionBar(QtWidgets.QWidget): self.action_clicked.emit(action) -class TasksWidget(QtWidgets.QWidget): - """Widget showing active Tasks""" - - task_changed = QtCore.Signal() - selection_mode = ( - QtCore.QItemSelectionModel.Select | QtCore.QItemSelectionModel.Rows - ) - - def __init__(self, dbcon, parent=None): - super(TasksWidget, self).__init__(parent) - - self.dbcon = dbcon - - view = QtWidgets.QTreeView(self) - view.setIndentation(0) - view.setEditTriggers(QtWidgets.QTreeView.NoEditTriggers) - model = TaskModel(self.dbcon) - view.setModel(model) - - layout = QtWidgets.QVBoxLayout(self) - layout.addWidget(view) - - view.selectionModel().selectionChanged.connect(self.task_changed) - - self.model = model - self.view = view - - self._last_selected_task = None - - def set_asset(self, asset_id): - if asset_id is None: - # Asset deselected - self.model.set_assets() - return - - # Try and preserve the last selected task and reselect it - # after switching assets. If there's no currently selected - # asset keep whatever the "last selected" was prior to it. - current = self.get_current_task() - if current: - self._last_selected_task = current - - self.model.set_assets([asset_id]) - - if self._last_selected_task: - self.select_task(self._last_selected_task) - - # Force a task changed emit. - self.task_changed.emit() - - def select_task(self, task_name): - """Select a task by name. - - If the task does not exist in the current model then selection is only - cleared. - - Args: - task (str): Name of the task to select. - - """ - - # Clear selection - self.view.selectionModel().clearSelection() - - # Select the task - for row in range(self.model.rowCount()): - index = self.model.index(row, 0) - _task_name = index.data(QtCore.Qt.DisplayRole) - if _task_name == task_name: - self.view.selectionModel().select(index, self.selection_mode) - # Set the currently active index - self.view.setCurrentIndex(index) - break - - def get_current_task(self): - """Return name of task at current index (selected) - - Returns: - str: Name of the current task. - - """ - index = self.view.currentIndex() - if self.view.selectionModel().isSelected(index): - return index.data(QtCore.Qt.DisplayRole) - - class ActionHistory(QtWidgets.QPushButton): trigger_history = QtCore.Signal(tuple) diff --git a/openpype/tools/launcher/window.py b/openpype/tools/launcher/window.py index 9b839fb2bc..8d6b609282 100644 --- a/openpype/tools/launcher/window.py +++ b/openpype/tools/launcher/window.py @@ -8,20 +8,20 @@ from avalon.api import AvalonMongoDB from openpype import style from openpype.api import resources -from avalon.tools import lib as tools_lib -from avalon.tools.widgets import AssetWidget +from openpype.tools.utils.widgets import AssetWidget +from openpype.tools.utils.tasks_widget import TasksWidget + from avalon.vendor import qtawesome from .models import ProjectModel from .lib import get_action_label, ProjectHandler from .widgets import ( ProjectBar, ActionBar, - TasksWidget, ActionHistory, SlidePageWidget ) -from .flickcharm import FlickCharm +from openpype.tools.flickcharm import FlickCharm class ProjectIconView(QtWidgets.QListView): @@ -92,8 +92,6 @@ class ProjectsPanel(QtWidgets.QWidget): def __init__(self, project_handler, parent=None): super(ProjectsPanel, self).__init__(parent=parent) - layout = QtWidgets.QVBoxLayout(self) - view = ProjectIconView(parent=self) view.setSelectionMode(QtWidgets.QListView.NoSelection) flick = FlickCharm(parent=self) @@ -101,6 +99,8 @@ class ProjectsPanel(QtWidgets.QWidget): view.setModel(project_handler.model) + layout = QtWidgets.QVBoxLayout(self) + layout.setContentsMargins(0, 0, 0, 0) layout.addWidget(view) view.clicked.connect(self.on_clicked) @@ -124,28 +124,21 @@ class AssetsPanel(QtWidgets.QWidget): self.dbcon = dbcon - # project bar - project_bar_widget = QtWidgets.QWidget(self) - - layout = QtWidgets.QHBoxLayout(project_bar_widget) - layout.setSpacing(4) - + # Project bar btn_back_icon = qtawesome.icon("fa.angle-left", color="white") - btn_back = QtWidgets.QPushButton(project_bar_widget) + btn_back = QtWidgets.QPushButton(self) btn_back.setIcon(btn_back_icon) - project_bar = ProjectBar(project_handler, project_bar_widget) + project_bar = ProjectBar(project_handler, self) - layout.addWidget(btn_back) - layout.addWidget(project_bar) + project_bar_layout = QtWidgets.QHBoxLayout() + project_bar_layout.setContentsMargins(0, 0, 0, 0) + project_bar_layout.setSpacing(4) + project_bar_layout.addWidget(btn_back) + project_bar_layout.addWidget(project_bar) - # assets - assets_proxy_widgets = QtWidgets.QWidget(self) - assets_proxy_widgets.setContentsMargins(0, 0, 0, 0) - assets_layout = QtWidgets.QVBoxLayout(assets_proxy_widgets) - assets_widget = AssetWidget( - dbcon=self.dbcon, parent=assets_proxy_widgets - ) + # Assets widget + assets_widget = AssetWidget(dbcon=self.dbcon, parent=self) # Make assets view flickable flick = FlickCharm(parent=self) @@ -153,18 +146,19 @@ class AssetsPanel(QtWidgets.QWidget): assets_widget.view.setVerticalScrollMode( assets_widget.view.ScrollPerPixel ) - assets_layout.addWidget(assets_widget) - # tasks + # Tasks widget tasks_widget = TasksWidget(self.dbcon, self) - body = QtWidgets.QSplitter() + + # Body + body = QtWidgets.QSplitter(self) body.setContentsMargins(0, 0, 0, 0) body.setSizePolicy( QtWidgets.QSizePolicy.Expanding, QtWidgets.QSizePolicy.Expanding ) body.setOrientation(QtCore.Qt.Horizontal) - body.addWidget(assets_proxy_widgets) + body.addWidget(assets_widget) body.addWidget(tasks_widget) body.setStretchFactor(0, 100) body.setStretchFactor(1, 65) @@ -172,22 +166,21 @@ class AssetsPanel(QtWidgets.QWidget): # main layout layout = QtWidgets.QVBoxLayout(self) layout.setContentsMargins(0, 0, 0, 0) - layout.setSpacing(0) - layout.addWidget(project_bar_widget) + layout.addLayout(project_bar_layout) layout.addWidget(body) # signals - project_handler.project_changed.connect(self.on_project_changed) - assets_widget.selection_changed.connect(self.on_asset_changed) - assets_widget.refreshed.connect(self.on_asset_changed) - tasks_widget.task_changed.connect(self.on_task_change) + project_handler.project_changed.connect(self._on_project_changed) + assets_widget.selection_changed.connect(self._on_asset_changed) + assets_widget.refreshed.connect(self._on_asset_changed) + tasks_widget.task_changed.connect(self._on_task_change) btn_back.clicked.connect(self.back_clicked) self.project_handler = project_handler self.project_bar = project_bar self.assets_widget = assets_widget - self.tasks_widget = tasks_widget + self._tasks_widget = tasks_widget self._btn_back = btn_back def showEvent(self, event): @@ -198,12 +191,16 @@ class AssetsPanel(QtWidgets.QWidget): btn_size = self.project_bar.height() self._btn_back.setFixedSize(QtCore.QSize(btn_size, btn_size)) - def on_project_changed(self): + def select_task_name(self, task_name): + self._on_asset_changed() + self._tasks_widget.select_task_name(task_name) + + def _on_project_changed(self): self.session_changed.emit() self.assets_widget.refresh() - def on_asset_changed(self): + def _on_asset_changed(self): """Callback on asset selection changed This updates the task view. @@ -238,16 +235,17 @@ class AssetsPanel(QtWidgets.QWidget): asset_id = None if asset_doc: asset_id = asset_doc["_id"] - self.tasks_widget.set_asset(asset_id) + self._tasks_widget.set_asset_id(asset_id) - def on_task_change(self): - task_name = self.tasks_widget.get_current_task() + def _on_task_change(self): + task_name = self._tasks_widget.get_selected_task_name() self.dbcon.Session["AVALON_TASK"] = task_name self.session_changed.emit() class LauncherWindow(QtWidgets.QDialog): """Launcher interface""" + message_timeout = 5000 def __init__(self, parent=None): super(LauncherWindow, self).__init__(parent) @@ -284,20 +282,17 @@ class LauncherWindow(QtWidgets.QDialog): actions_bar = ActionBar(project_handler, self.dbcon, self) # statusbar - statusbar = QtWidgets.QWidget() - layout = QtWidgets.QHBoxLayout(statusbar) + message_label = QtWidgets.QLabel(self) - message_label = QtWidgets.QLabel() - message_label.setFixedHeight(15) - - action_history = ActionHistory() + action_history = ActionHistory(self) action_history.setStatusTip("Show Action History") - layout.addWidget(message_label) - layout.addWidget(action_history) + status_layout = QtWidgets.QHBoxLayout() + status_layout.addWidget(message_label, 1) + status_layout.addWidget(action_history, 0) # Vertically split Pages and Actions - body = QtWidgets.QSplitter() + body = QtWidgets.QSplitter(self) body.setContentsMargins(0, 0, 0, 0) body.setSizePolicy( QtWidgets.QSizePolicy.Expanding, @@ -315,19 +310,13 @@ class LauncherWindow(QtWidgets.QDialog): layout = QtWidgets.QVBoxLayout(self) layout.addWidget(body) - layout.addWidget(statusbar) - layout.setSpacing(0) - layout.setContentsMargins(0, 0, 0, 0) + layout.addLayout(status_layout) - self.project_handler = project_handler + message_timer = QtCore.QTimer() + message_timer.setInterval(self.message_timeout) + message_timer.setSingleShot(True) - self.message_label = message_label - self.project_panel = project_panel - self.asset_panel = asset_panel - self.actions_bar = actions_bar - self.action_history = action_history - self.page_slider = page_slider - self._page = 0 + message_timer.timeout.connect(self._on_message_timeout) # signals actions_bar.action_clicked.connect(self.on_action_clicked) @@ -339,6 +328,19 @@ class LauncherWindow(QtWidgets.QDialog): self.resize(520, 740) + self._page = 0 + + self._message_timer = message_timer + + self.project_handler = project_handler + + self._message_label = message_label + self.project_panel = project_panel + self.asset_panel = asset_panel + self.actions_bar = actions_bar + self.action_history = action_history + self.page_slider = page_slider + def showEvent(self, event): self.project_handler.set_active(True) self.project_handler.start_timer(True) @@ -364,9 +366,12 @@ class LauncherWindow(QtWidgets.QDialog): self._page = page self.page_slider.slide_view(page, direction=direction) + def _on_message_timeout(self): + self._message_label.setText("") + def echo(self, message): - self.message_label.setText(str(message)) - QtCore.QTimer.singleShot(5000, lambda: self.message_label.setText("")) + self._message_label.setText(str(message)) + self._message_timer.start() self.log.debug(message) def on_session_changed(self): @@ -449,5 +454,4 @@ class LauncherWindow(QtWidgets.QDialog): if task_name: # requires a forced refresh first - self.asset_panel.on_asset_changed() - self.asset_panel.tasks_widget.select_task(task_name) + self.asset_panel.select_task_name(task_name) diff --git a/openpype/tools/libraryloader/app.py b/openpype/tools/libraryloader/app.py index 3f11157418..710e25bd76 100644 --- a/openpype/tools/libraryloader/app.py +++ b/openpype/tools/libraryloader/app.py @@ -2,8 +2,8 @@ import sys from Qt import QtWidgets, QtCore, QtGui -from avalon import style from avalon.api import AvalonMongoDB +from openpype import style from openpype.tools.utils import lib as tools_lib from openpype.tools.loader.widgets import ( ThumbnailWidget, @@ -28,155 +28,184 @@ class LibraryLoaderWindow(QtWidgets.QDialog): tool_title = "Library Loader 0.5" tool_name = "library_loader" + message_timeout = 5000 + def __init__( self, parent=None, icon=None, show_projects=False, show_libraries=True ): super(LibraryLoaderWindow, self).__init__(parent) - self._initial_refresh = False - self._ignore_project_change = False - - # Enable minimize and maximize for app + # Window modifications self.setWindowTitle(self.tool_title) window_flags = QtCore.Qt.Window if not parent: window_flags |= QtCore.Qt.WindowStaysOnTopHint self.setWindowFlags(window_flags) self.setFocusPolicy(QtCore.Qt.StrongFocus) - if icon is not None: - self.setWindowIcon(icon) - # self.setAttribute(QtCore.Qt.WA_DeleteOnClose) - body = QtWidgets.QWidget() - footer = QtWidgets.QWidget() - footer.setFixedHeight(20) + icon = QtGui.QIcon(style.app_icon_path()) + self.setWindowIcon(icon) - container = QtWidgets.QWidget() + self._first_show = True + self._initial_refresh = False + self._ignore_project_change = False - self.dbcon = AvalonMongoDB() - self.dbcon.install() - self.dbcon.Session["AVALON_PROJECT"] = None + dbcon = AvalonMongoDB() + dbcon.install() + dbcon.Session["AVALON_PROJECT"] = None + + self.dbcon = dbcon self.show_projects = show_projects self.show_libraries = show_libraries # Groups config - self.groups_config = tools_lib.GroupsConfig(self.dbcon) - self.family_config_cache = tools_lib.FamilyConfigCache(self.dbcon) + self.groups_config = tools_lib.GroupsConfig(dbcon) + self.family_config_cache = tools_lib.FamilyConfigCache(dbcon) - assets = AssetWidget( - self.dbcon, multiselection=True, parent=self + # UI initialization + main_splitter = QtWidgets.QSplitter(self) + + # --- Left part --- + left_side_splitter = QtWidgets.QSplitter(main_splitter) + left_side_splitter.setOrientation(QtCore.Qt.Vertical) + + # Project combobox + projects_combobox = QtWidgets.QComboBox(left_side_splitter) + combobox_delegate = QtWidgets.QStyledItemDelegate(self) + projects_combobox.setItemDelegate(combobox_delegate) + + # Assets widget + assets_widget = AssetWidget( + dbcon, multiselection=True, parent=left_side_splitter ) - families = FamilyListView( - self.dbcon, self.family_config_cache, parent=self + + # Families widget + families_filter_view = FamilyListView( + dbcon, self.family_config_cache, left_side_splitter ) - subsets = LibrarySubsetWidget( - self.dbcon, + left_side_splitter.addWidget(projects_combobox) + left_side_splitter.addWidget(assets_widget) + left_side_splitter.addWidget(families_filter_view) + left_side_splitter.setStretchFactor(1, 65) + left_side_splitter.setStretchFactor(2, 35) + + # --- Middle part --- + # Subsets widget + subsets_widget = LibrarySubsetWidget( + dbcon, self.groups_config, self.family_config_cache, tool_name=self.tool_name, parent=self ) - version = VersionWidget(self.dbcon) - thumbnail = ThumbnailWidget(self.dbcon) - - # Project - self.combo_projects = QtWidgets.QComboBox() - - # Create splitter to show / hide family filters - asset_filter_splitter = QtWidgets.QSplitter() - asset_filter_splitter.setOrientation(QtCore.Qt.Vertical) - asset_filter_splitter.addWidget(self.combo_projects) - asset_filter_splitter.addWidget(assets) - asset_filter_splitter.addWidget(families) - asset_filter_splitter.setStretchFactor(1, 65) - asset_filter_splitter.setStretchFactor(2, 35) - - manager = ModulesManager() - sync_server = manager.modules_by_name["sync_server"] - - representations = RepresentationWidget(self.dbcon) - thumb_ver_splitter = QtWidgets.QSplitter() + # --- Right part --- + thumb_ver_splitter = QtWidgets.QSplitter(main_splitter) thumb_ver_splitter.setOrientation(QtCore.Qt.Vertical) - thumb_ver_splitter.addWidget(thumbnail) - thumb_ver_splitter.addWidget(version) - if sync_server.enabled: - thumb_ver_splitter.addWidget(representations) + + thumbnail_widget = ThumbnailWidget(dbcon, parent=thumb_ver_splitter) + version_info_widget = VersionWidget(dbcon, parent=thumb_ver_splitter) + + thumb_ver_splitter.addWidget(thumbnail_widget) + thumb_ver_splitter.addWidget(version_info_widget) + thumb_ver_splitter.setStretchFactor(0, 30) thumb_ver_splitter.setStretchFactor(1, 35) - container_layout = QtWidgets.QHBoxLayout(container) - container_layout.setContentsMargins(0, 0, 0, 0) - split = QtWidgets.QSplitter() - split.addWidget(asset_filter_splitter) - split.addWidget(subsets) - split.addWidget(thumb_ver_splitter) - split.setSizes([180, 950, 200]) - container_layout.addWidget(split) + manager = ModulesManager() + sync_server = manager.modules_by_name.get("sync_server") + sync_server_enabled = False + if sync_server is not None: + sync_server_enabled = sync_server.enabled - body_layout = QtWidgets.QHBoxLayout(body) - body_layout.addWidget(container) - body_layout.setContentsMargins(0, 0, 0, 0) + repres_widget = None + if sync_server_enabled: + repres_widget = RepresentationWidget( + dbcon, self.tool_name, parent=thumb_ver_splitter + ) + thumb_ver_splitter.addWidget(repres_widget) - message = QtWidgets.QLabel() - message.hide() + main_splitter.addWidget(left_side_splitter) + main_splitter.addWidget(subsets_widget) + main_splitter.addWidget(thumb_ver_splitter) + if sync_server_enabled: + main_splitter.setSizes([250, 1000, 550]) + else: + main_splitter.setSizes([250, 850, 200]) - footer_layout = QtWidgets.QVBoxLayout(footer) - footer_layout.addWidget(message) + # --- Footer --- + footer_widget = QtWidgets.QWidget(self) + footer_widget.setFixedHeight(20) + + message_label = QtWidgets.QLabel(footer_widget) + + footer_layout = QtWidgets.QVBoxLayout(footer_widget) footer_layout.setContentsMargins(0, 0, 0, 0) + footer_layout.addWidget(message_label) layout = QtWidgets.QVBoxLayout(self) - layout.addWidget(body) - layout.addWidget(footer) + layout.addWidget(main_splitter) + layout.addWidget(footer_widget) self.data = { - "widgets": { - "families": families, - "assets": assets, - "subsets": subsets, - "version": version, - "thumbnail": thumbnail, - "representations": representations - }, - "label": { - "message": message, - }, "state": { "assetIds": None } } - families.active_changed.connect(subsets.set_family_filters) - assets.selection_changed.connect(self.on_assetschanged) - assets.refresh_triggered.connect(self.on_assetschanged) - assets.view.clicked.connect(self.on_assetview_click) - subsets.active_changed.connect(self.on_subsetschanged) - subsets.version_changed.connect(self.on_versionschanged) - subsets.refreshed.connect(self._on_subset_refresh) - self.combo_projects.currentTextChanged.connect(self.on_project_change) + message_timer = QtCore.QTimer() + message_timer.setInterval(self.message_timeout) + message_timer.setSingleShot(True) + + message_timer.timeout.connect(self._on_message_timeout) + + families_filter_view.active_changed.connect( + self._on_family_filter_change + ) + assets_widget.selection_changed.connect(self.on_assetschanged) + assets_widget.refresh_triggered.connect(self.on_assetschanged) + assets_widget.view.clicked.connect(self.on_assetview_click) + subsets_widget.active_changed.connect(self.on_subsetschanged) + subsets_widget.version_changed.connect(self.on_versionschanged) + subsets_widget.refreshed.connect(self._on_subset_refresh) + projects_combobox.currentTextChanged.connect(self.on_project_change) self.sync_server = sync_server + self._sync_server_enabled = sync_server_enabled - # Set default thumbnail on start - thumbnail.set_thumbnail(None) + self._combobox_delegate = combobox_delegate + self._projects_combobox = projects_combobox + self._assets_widget = assets_widget + self._families_filter_view = families_filter_view - # Defaults - if sync_server.enabled: - split.setSizes([250, 1000, 550]) - self.resize(1800, 900) - else: - split.setSizes([250, 850, 200]) - self.resize(1300, 700) + self._subsets_widget = subsets_widget + + self._version_info_widget = version_info_widget + self._thumbnail_widget = thumbnail_widget + self._repres_widget = repres_widget + + self._message_label = message_label + self._message_timer = message_timer def showEvent(self, event): super(LibraryLoaderWindow, self).showEvent(event) + if self._first_show: + self._first_show = False + self.setStyleSheet(style.load_stylesheet()) + if self._sync_server_enabled: + self.resize(1800, 900) + else: + self.resize(1300, 700) + + tools_lib.center_window(self) + if not self._initial_refresh: + self._initial_refresh = True self.refresh() def on_assetview_click(self, *args): - subsets_widget = self.data["widgets"]["subsets"] - selection_model = subsets_widget.view.selectionModel() + selection_model = self._subsets_widget.view.selectionModel() if selection_model.selectedIndexes(): selection_model.clearSelection() @@ -187,7 +216,7 @@ class LibraryLoaderWindow(QtWidgets.QDialog): self._ignore_project_change = True # Cleanup - self.combo_projects.clear() + self._projects_combobox.clear() # Fill combobox with projects select_project_item = QtGui.QStandardItem("< Select project >") @@ -202,18 +231,18 @@ class LibraryLoaderWindow(QtWidgets.QDialog): item.setData(project_name, QtCore.Qt.UserRole + 1) combobox_items.append(item) - root_item = self.combo_projects.model().invisibleRootItem() + root_item = self._projects_combobox.model().invisibleRootItem() root_item.appendRows(combobox_items) index = 0 self._ignore_project_change = False if old_project_name: - index = self.combo_projects.findText( + index = self._projects_combobox.findText( old_project_name, QtCore.Qt.MatchFixedString ) - self.combo_projects.setCurrentIndex(index) + self._projects_combobox.setCurrentIndex(index) def get_filtered_projects(self): projects = list() @@ -231,8 +260,8 @@ class LibraryLoaderWindow(QtWidgets.QDialog): if self._ignore_project_change: return - row = self.combo_projects.currentIndex() - index = self.combo_projects.model().index(row, 0) + row = self._projects_combobox.currentIndex() + index = self._projects_combobox.model().index(row, 0) project_name = index.data(QtCore.Qt.UserRole + 1) self.dbcon.Session["AVALON_PROJECT"] = project_name @@ -245,11 +274,9 @@ class LibraryLoaderWindow(QtWidgets.QDialog): "Config `%s` has no function `install`" % _config.__name__ ) - subsets = self.data["widgets"]["subsets"] - representations = self.data["widgets"]["representations"] - - subsets.on_project_change(self.dbcon.Session["AVALON_PROJECT"]) - representations.on_project_change(self.dbcon.Session["AVALON_PROJECT"]) + self._subsets_widget.on_project_change(project_name) + if self._repres_widget: + self._repres_widget.on_project_change(project_name) self.family_config_cache.refresh() self.groups_config.refresh() @@ -263,13 +290,7 @@ class LibraryLoaderWindow(QtWidgets.QDialog): @property def current_project(self): - if ( - not self.dbcon.active_project() or - self.dbcon.active_project() == "" - ): - return None - - return self.dbcon.active_project() + return self.dbcon.active_project() or None # ------------------------------- # Delay calling blocking methods @@ -292,12 +313,11 @@ class LibraryLoaderWindow(QtWidgets.QDialog): tools_lib.schedule(self._versionschanged, 150, channel="mongo") def _on_subset_refresh(self, has_item): - subsets_widget = self.data["widgets"]["subsets"] - families_view = self.data["widgets"]["families"] - - subsets_widget.set_loading_state(loading=False, empty=not has_item) - families = subsets_widget.get_subsets_families() - families_view.set_enabled_families(families) + self._subsets_widget.set_loading_state( + loading=False, empty=not has_item + ) + families = self._subsets_widget.get_subsets_families() + self._families_filter_view.set_enabled_families(families) def set_context(self, context, refresh=True): self.echo("Setting context: {}".format(context)) @@ -307,6 +327,9 @@ class LibraryLoaderWindow(QtWidgets.QDialog): ) # ------------------------------ + def _on_family_filter_change(self, families): + self._subsets_widget.set_family_filters(families) + def _refresh(self): if not self._initial_refresh: self._initial_refresh = True @@ -322,74 +345,70 @@ class LibraryLoaderWindow(QtWidgets.QDialog): ) assert project_doc, "This is a bug" - assets_widget = self.data["widgets"]["assets"] - families_view = self.data["widgets"]["families"] - families_view.set_enabled_families(set()) - families_view.refresh() + self._families_filter_view.set_enabled_families(set()) + self._families_filter_view.refresh() - assets_widget.model.stop_fetch_thread() - assets_widget.refresh() - assets_widget.setFocus() + self._assets_widget.model.stop_fetch_thread() + self._assets_widget.refresh() + self._assets_widget.setFocus() def clear_assets_underlines(self): last_asset_ids = self.data["state"]["assetIds"] if not last_asset_ids: return - assets_widget = self.data["widgets"]["assets"] - id_role = assets_widget.model.ObjectIdRole + assets_model = self._assets_widget.model + id_role = assets_model.ObjectIdRole - for index in tools_lib.iter_model_rows(assets_widget.model, 0): + for index in tools_lib.iter_model_rows(assets_model, 0): if index.data(id_role) not in last_asset_ids: continue - assets_widget.model.setData( - index, [], assets_widget.model.subsetColorsRole + assets_model.setData( + index, [], assets_model.subsetColorsRole ) def _assetschanged(self): """Selected assets have changed""" - assets_widget = self.data["widgets"]["assets"] - subsets_widget = self.data["widgets"]["subsets"] - subsets_model = subsets_widget.model + subsets_model = self._subsets_widget.model subsets_model.clear() self.clear_assets_underlines() if not self.dbcon.Session.get("AVALON_PROJECT"): - subsets_widget.set_loading_state( + self._subsets_widget.set_loading_state( loading=False, empty=True ) return # filter None docs they are silo - asset_docs = assets_widget.get_selected_assets() + asset_docs = self._assets_widget.get_selected_assets() if len(asset_docs) == 0: return asset_ids = [asset_doc["_id"] for asset_doc in asset_docs] # Start loading - subsets_widget.set_loading_state( + self._subsets_widget.set_loading_state( loading=bool(asset_ids), empty=True ) subsets_model.set_assets(asset_ids) - subsets_widget.view.setColumnHidden( + self._subsets_widget.view.setColumnHidden( subsets_model.Columns.index("asset"), len(asset_ids) < 2 ) # Clear the version information on asset change - self.data["widgets"]["version"].set_version(None) - self.data["widgets"]["thumbnail"].set_thumbnail(asset_docs) + self._version_info_widget.set_version(None) + self._thumbnail_widget.set_thumbnail(asset_docs) self.data["state"]["assetIds"] = asset_ids - representations = self.data["widgets"]["representations"] # reset repre list - representations.set_version_ids([]) + if self._repres_widget: + self._repres_widget.set_version_ids([]) def _subsetschanged(self): asset_ids = self.data["state"]["assetIds"] @@ -398,8 +417,9 @@ class LibraryLoaderWindow(QtWidgets.QDialog): self._versionschanged() return - subsets = self.data["widgets"]["subsets"] - selected_subsets = subsets.selected_subsets(_merged=True, _other=False) + selected_subsets = self._subsets_widget.selected_subsets( + _merged=True, _other=False + ) asset_models = {} asset_ids = [] @@ -420,26 +440,24 @@ class LibraryLoaderWindow(QtWidgets.QDialog): self.clear_assets_underlines() - assets_widget = self.data["widgets"]["assets"] - indexes = assets_widget.view.selectionModel().selectedRows() + indexes = self._assets_widget.view.selectionModel().selectedRows() + assets_model = self._assets_widget.model for index in indexes: - id = index.data(assets_widget.model.ObjectIdRole) + id = index.data(assets_model.ObjectIdRole) if id not in asset_models: continue - assets_widget.model.setData( - index, asset_models[id], assets_widget.model.subsetColorsRole + assets_model.setData( + index, asset_models[id], assets_model.subsetColorsRole ) # Trigger repaint - assets_widget.view.updateGeometries() + self._assets_widget.view.updateGeometries() # Set version in Version Widget self._versionschanged() def _versionschanged(self): - - subsets = self.data["widgets"]["subsets"] - selection = subsets.view.selectionModel() + selection = self._subsets_widget.view.selectionModel() # Active must be in the selected rows otherwise we # assume it's not actually an "active" current index. @@ -448,7 +466,7 @@ class LibraryLoaderWindow(QtWidgets.QDialog): active = selection.currentIndex() rows = selection.selectedRows(column=active.column()) if active and active in rows: - item = active.data(subsets.model.ItemRole) + item = active.data(self._subsets_widget.model.ItemRole) if ( item is not None and not (item.get("isGroup") or item.get("isMerged")) @@ -460,7 +478,7 @@ class LibraryLoaderWindow(QtWidgets.QDialog): for index in rows: if not index or not index.isValid(): continue - item = index.data(subsets.model.ItemRole) + item = index.data(self._subsets_widget.model.ItemRole) if ( item is None or item.get("isGroup") @@ -469,20 +487,19 @@ class LibraryLoaderWindow(QtWidgets.QDialog): continue version_docs.append(item["version_document"]) - self.data["widgets"]["version"].set_version(version_doc) + self._version_info_widget.set_version(version_doc) thumbnail_docs = version_docs if not thumbnail_docs: - assets_widget = self.data["widgets"]["assets"] - asset_docs = assets_widget.get_selected_assets() + asset_docs = self._assets_widget.get_selected_assets() if len(asset_docs) > 0: thumbnail_docs = asset_docs - self.data["widgets"]["thumbnail"].set_thumbnail(thumbnail_docs) + self._thumbnail_widget.set_thumbnail(thumbnail_docs) - representations = self.data["widgets"]["representations"] version_ids = [doc["_id"] for doc in version_docs or []] - representations.set_version_ids(version_ids) + if self._repres_widget: + self._repres_widget.set_version_ids(version_ids) def _set_context(self, context, refresh=True): """Set the selection in the interface using a context. @@ -510,16 +527,15 @@ class LibraryLoaderWindow(QtWidgets.QDialog): # scheduled refresh and the silo tabs are not shown. self._refresh_assets() - asset_widget = self.data["widgets"]["assets"] - asset_widget.select_assets(asset) + self._assets_widget.select_assets(asset) + + def _on_message_timeout(self): + self._message_label.setText("") def echo(self, message): - widget = self.data["label"]["message"] - widget.setText(str(message)) - widget.show() + self._message_label.setText(str(message)) print(message) - - tools_lib.schedule(widget.hide, 5000, channel="message") + self._message_timer.start() def closeEvent(self, event): # Kill on holding SHIFT @@ -576,7 +592,6 @@ def show( window = LibraryLoaderWindow( parent, icon, show_projects, show_libraries ) - window.setStyleSheet(style.load_stylesheet()) window.show() module.window = window diff --git a/openpype/tools/loader/app.py b/openpype/tools/loader/app.py index bc0eef3bca..9a4f2f1984 100644 --- a/openpype/tools/loader/app.py +++ b/openpype/tools/loader/app.py @@ -1,10 +1,10 @@ import sys from Qt import QtWidgets, QtCore -from avalon import api, io, style, pipeline +from avalon import api, io, pipeline +from openpype import style from openpype.tools.utils.widgets import AssetWidget - from openpype.tools.utils import lib from .widgets import ( @@ -37,6 +37,7 @@ class LoaderWindow(QtWidgets.QDialog): """Asset loader interface""" tool_name = "loader" + message_timeout = 5000 def __init__(self, parent=None): super(LoaderWindow, self).__init__(parent) @@ -57,83 +58,85 @@ class LoaderWindow(QtWidgets.QDialog): self.setWindowFlags(window_flags) self.setFocusPolicy(QtCore.Qt.StrongFocus) - body = QtWidgets.QWidget() - footer = QtWidgets.QWidget() - footer.setFixedHeight(20) + main_splitter = QtWidgets.QSplitter(self) - container = QtWidgets.QWidget() + # --- Left part --- + left_side_splitter = QtWidgets.QSplitter(main_splitter) + left_side_splitter.setOrientation(QtCore.Qt.Vertical) - assets = AssetWidget(io, multiselection=True, parent=self) - assets.set_current_asset_btn_visibility(True) + # Assets widget + assets_widget = AssetWidget( + io, multiselection=True, parent=left_side_splitter + ) + assets_widget.set_current_asset_btn_visibility(True) - families = FamilyListView(io, self.family_config_cache, self) - subsets = SubsetWidget( + # Families widget + families_filter_view = FamilyListView( + io, self.family_config_cache, left_side_splitter + ) + left_side_splitter.addWidget(assets_widget) + left_side_splitter.addWidget(families_filter_view) + left_side_splitter.setStretchFactor(0, 65) + left_side_splitter.setStretchFactor(1, 35) + + # --- Middle part --- + # Subsets widget + subsets_widget = SubsetWidget( io, self.groups_config, self.family_config_cache, tool_name=self.tool_name, - parent=self + parent=main_splitter ) - version = VersionWidget(io) - thumbnail = ThumbnailWidget(io) - representations = RepresentationWidget(io, self.tool_name) - manager = ModulesManager() - sync_server = manager.modules_by_name["sync_server"] - - thumb_ver_splitter = QtWidgets.QSplitter() + # --- Right part --- + thumb_ver_splitter = QtWidgets.QSplitter(main_splitter) thumb_ver_splitter.setOrientation(QtCore.Qt.Vertical) - thumb_ver_splitter.addWidget(thumbnail) - thumb_ver_splitter.addWidget(version) - if sync_server.enabled: - thumb_ver_splitter.addWidget(representations) + + thumbnail_widget = ThumbnailWidget(io, parent=thumb_ver_splitter) + version_info_widget = VersionWidget(io, parent=thumb_ver_splitter) + + thumb_ver_splitter.addWidget(thumbnail_widget) + thumb_ver_splitter.addWidget(version_info_widget) + thumb_ver_splitter.setStretchFactor(0, 30) thumb_ver_splitter.setStretchFactor(1, 35) - # Create splitter to show / hide family filters - asset_filter_splitter = QtWidgets.QSplitter() - asset_filter_splitter.setOrientation(QtCore.Qt.Vertical) - asset_filter_splitter.addWidget(assets) - asset_filter_splitter.addWidget(families) - asset_filter_splitter.setStretchFactor(0, 65) - asset_filter_splitter.setStretchFactor(1, 35) + manager = ModulesManager() + sync_server = manager.modules_by_name.get("sync_server") + sync_server_enabled = False + if sync_server is not None: + sync_server_enabled = sync_server.enabled - container_layout = QtWidgets.QHBoxLayout(container) - container_layout.setContentsMargins(0, 0, 0, 0) - split = QtWidgets.QSplitter() - split.addWidget(asset_filter_splitter) - split.addWidget(subsets) - split.addWidget(thumb_ver_splitter) + repres_widget = None + if sync_server_enabled: + repres_widget = RepresentationWidget( + io, self.tool_name, parent=thumb_ver_splitter + ) + thumb_ver_splitter.addWidget(repres_widget) - container_layout.addWidget(split) + main_splitter.addWidget(left_side_splitter) + main_splitter.addWidget(subsets_widget) + main_splitter.addWidget(thumb_ver_splitter) - body_layout = QtWidgets.QHBoxLayout(body) - body_layout.addWidget(container) - body_layout.setContentsMargins(0, 0, 0, 0) + if sync_server_enabled: + main_splitter.setSizes([250, 1000, 550]) + else: + main_splitter.setSizes([250, 850, 200]) - message = QtWidgets.QLabel() - message.hide() + footer_widget = QtWidgets.QWidget(self) - footer_layout = QtWidgets.QVBoxLayout(footer) - footer_layout.addWidget(message) + message_label = QtWidgets.QLabel(footer_widget) + + footer_layout = QtWidgets.QHBoxLayout(footer_widget) footer_layout.setContentsMargins(0, 0, 0, 0) + footer_layout.addWidget(message_label, 1) layout = QtWidgets.QVBoxLayout(self) - layout.addWidget(body) - layout.addWidget(footer) + layout.addWidget(main_splitter, 1) + layout.addWidget(footer_widget, 0) self.data = { - "widgets": { - "families": families, - "assets": assets, - "subsets": subsets, - "version": version, - "thumbnail": thumbnail, - "representations": representations - }, - "label": { - "message": message, - }, "state": { "assetIds": None } @@ -142,19 +145,44 @@ class LoaderWindow(QtWidgets.QDialog): overlay_frame = OverlayFrame("Loading...", self) overlay_frame.setVisible(False) - families.active_changed.connect(subsets.set_family_filters) - assets.selection_changed.connect(self.on_assetschanged) - assets.refresh_triggered.connect(self.on_assetschanged) - assets.view.clicked.connect(self.on_assetview_click) - subsets.active_changed.connect(self.on_subsetschanged) - subsets.version_changed.connect(self.on_versionschanged) - subsets.refreshed.connect(self._on_subset_refresh) + message_timer = QtCore.QTimer() + message_timer.setInterval(self.message_timeout) + message_timer.setSingleShot(True) - subsets.load_started.connect(self._on_load_start) - subsets.load_ended.connect(self._on_load_end) - representations.load_started.connect(self._on_load_start) - representations.load_ended.connect(self._on_load_end) + message_timer.timeout.connect(self._on_message_timeout) + families_filter_view.active_changed.connect( + self._on_family_filter_change + ) + assets_widget.selection_changed.connect(self.on_assetschanged) + assets_widget.refresh_triggered.connect(self.on_assetschanged) + # TODO do not touch view in asset widget + assets_widget.view.clicked.connect(self.on_assetview_click) + subsets_widget.active_changed.connect(self.on_subsetschanged) + subsets_widget.version_changed.connect(self.on_versionschanged) + subsets_widget.refreshed.connect(self._on_subset_refresh) + + subsets_widget.load_started.connect(self._on_load_start) + subsets_widget.load_ended.connect(self._on_load_end) + if repres_widget: + repres_widget.load_started.connect(self._on_load_start) + repres_widget.load_ended.connect(self._on_load_end) + + self._sync_server_enabled = sync_server_enabled + + self._assets_widget = assets_widget + self._families_filter_view = families_filter_view + + self._subsets_widget = subsets_widget + + self._version_info_widget = version_info_widget + self._thumbnail_widget = thumbnail_widget + self._repres_widget = repres_widget + + self._message_label = message_label + self._message_timer = message_timer + + # TODO add overlay using stack widget self._overlay_frame = overlay_frame self.family_config_cache.refresh() @@ -163,13 +191,7 @@ class LoaderWindow(QtWidgets.QDialog): self._refresh() self._assetschanged() - # Defaults - if sync_server.enabled: - split.setSizes([250, 1000, 550]) - self.resize(1800, 900) - else: - split.setSizes([250, 850, 200]) - self.resize(1300, 700) + self._first_show = True def resizeEvent(self, event): super(LoaderWindow, self).resizeEvent(event) @@ -179,13 +201,24 @@ class LoaderWindow(QtWidgets.QDialog): super(LoaderWindow, self).moveEvent(event) self._overlay_frame.move(0, 0) + def showEvent(self, event): + super(LoaderWindow, self).showEvent(event) + if self._first_show: + self._first_show = False + self.setStyleSheet(style.load_stylesheet()) + if self._sync_server_enabled: + self.resize(1800, 900) + else: + self.resize(1300, 700) + lib.center_window(self) + # ------------------------------- # Delay calling blocking methods # ------------------------------- def on_assetview_click(self, *args): - subsets_widget = self.data["widgets"]["subsets"] - selection_model = subsets_widget.view.selectionModel() + # TODO do not touch inner attributes of subset widget + selection_model = self._subsets_widget.view.selectionModel() if selection_model.selectedIndexes(): selection_model.clearSelection() @@ -219,12 +252,11 @@ class LoaderWindow(QtWidgets.QDialog): self._overlay_frame.setVisible(False) def _on_subset_refresh(self, has_item): - subsets_widget = self.data["widgets"]["subsets"] - families_view = self.data["widgets"]["families"] - - subsets_widget.set_loading_state(loading=False, empty=not has_item) - families = subsets_widget.get_subsets_families() - families_view.set_enabled_families(families) + self._subsets_widget.set_loading_state( + loading=False, empty=not has_item + ) + families = self._subsets_widget.get_subsets_families() + self._families_filter_view.set_enabled_families(families) def _on_load_end(self): # Delay hiding as click events happened during loading should be @@ -232,14 +264,14 @@ class LoaderWindow(QtWidgets.QDialog): QtCore.QTimer.singleShot(100, self._hide_overlay) # ------------------------------ + def _on_family_filter_change(self, families): + self._subsets_widget.set_family_filters(families) def on_context_task_change(self, *args, **kwargs): - assets_widget = self.data["widgets"]["assets"] - families_view = self.data["widgets"]["families"] # Refresh families config - families_view.refresh() + self._families_filter_view.refresh() # Change to context asset on context change - assets_widget.select_assets(io.Session["AVALON_ASSET"]) + self._assets_widget.select_assets(io.Session["AVALON_ASSET"]) def _refresh(self): """Load assets from database""" @@ -248,12 +280,10 @@ class LoaderWindow(QtWidgets.QDialog): project = io.find_one({"type": "project"}, {"type": 1}) assert project, "Project was not found! This is a bug" - assets_widget = self.data["widgets"]["assets"] - assets_widget.refresh() - assets_widget.setFocus() + self._assets_widget.refresh() + self._assets_widget.setFocus() - families_view = self.data["widgets"]["families"] - families_view.refresh() + self._families_filter_view.refresh() def clear_assets_underlines(self): """Clear colors from asset data to remove colored underlines @@ -261,11 +291,12 @@ class LoaderWindow(QtWidgets.QDialog): own selected subsets. These colors must be cleared from asset data on selection change so they match current selection. """ - last_asset_ids = self.data["state"]["assetIds"] + # TODO do not touch inner attributes of asset widget + last_asset_ids = self.data["state"]["assetIds"] or [] if not last_asset_ids: return - assets_widget = self.data["widgets"]["assets"] + assets_widget = self._assets_widget id_role = assets_widget.model.ObjectIdRole for index in lib.iter_model_rows(assets_widget.model, 0): @@ -278,15 +309,15 @@ class LoaderWindow(QtWidgets.QDialog): def _assetschanged(self): """Selected assets have changed""" - assets_widget = self.data["widgets"]["assets"] - subsets_widget = self.data["widgets"]["subsets"] + subsets_widget = self._subsets_widget + # TODO do not touch subset widget inner attributes subsets_model = subsets_widget.model subsets_model.clear() self.clear_assets_underlines() # filter None docs they are silo - asset_docs = assets_widget.get_selected_assets() + asset_docs = self._assets_widget.get_selected_assets() asset_ids = [asset_doc["_id"] for asset_doc in asset_docs] # Start loading @@ -302,14 +333,14 @@ class LoaderWindow(QtWidgets.QDialog): ) # Clear the version information on asset change - self.data["widgets"]["version"].set_version(None) - self.data["widgets"]["thumbnail"].set_thumbnail(asset_docs) + self._thumbnail_widget.set_thumbnail(asset_docs) + self._version_info_widget.set_version(None) self.data["state"]["assetIds"] = asset_ids - representations = self.data["widgets"]["representations"] # reset repre list - representations.set_version_ids([]) + if self._repres_widget is not None: + self._repres_widget.set_version_ids([]) def _subsetschanged(self): asset_ids = self.data["state"]["assetIds"] @@ -318,8 +349,9 @@ class LoaderWindow(QtWidgets.QDialog): self._versionschanged() return - subsets = self.data["widgets"]["subsets"] - selected_subsets = subsets.selected_subsets(_merged=True, _other=False) + selected_subsets = self._subsets_widget.selected_subsets( + _merged=True, _other=False + ) asset_models = {} asset_ids = [] @@ -340,7 +372,8 @@ class LoaderWindow(QtWidgets.QDialog): self.clear_assets_underlines() - assets_widget = self.data["widgets"]["assets"] + # TODO do not use inner attributes of asset widget + assets_widget = self._assets_widget indexes = assets_widget.view.selectionModel().selectedRows() for index in indexes: @@ -357,7 +390,7 @@ class LoaderWindow(QtWidgets.QDialog): self._versionschanged() def _versionschanged(self): - subsets = self.data["widgets"]["subsets"] + subsets = self._subsets_widget selection = subsets.view.selectionModel() # Active must be in the selected rows otherwise we @@ -389,23 +422,24 @@ class LoaderWindow(QtWidgets.QDialog): else: version_docs.append(item["version_document"]) - self.data["widgets"]["version"].set_version(version_doc) + self._version_info_widget.set_version(version_doc) thumbnail_docs = version_docs - assets_widget = self.data["widgets"]["assets"] - asset_docs = assets_widget.get_selected_assets() + asset_docs = self._assets_widget.get_selected_assets() if not thumbnail_docs: if len(asset_docs) > 0: thumbnail_docs = asset_docs - self.data["widgets"]["thumbnail"].set_thumbnail(thumbnail_docs) + self._thumbnail_widget.set_thumbnail(thumbnail_docs) - representations = self.data["widgets"]["representations"] - version_ids = [doc["_id"] for doc in version_docs or []] - representations.set_version_ids(version_ids) + if self._repres_widget is not None: + version_ids = [doc["_id"] for doc in version_docs or []] + self._repres_widget.set_version_ids(version_ids) - # representations.change_visibility("subset", len(rows) > 1) - # representations.change_visibility("asset", len(asset_docs) > 1) + # self._repres_widget.change_visibility("subset", len(rows) > 1) + # self._repres_widget.change_visibility( + # "asset", len(asset_docs) > 1 + # ) def _set_context(self, context, refresh=True): """Set the selection in the interface using a context. @@ -438,16 +472,15 @@ class LoaderWindow(QtWidgets.QDialog): # scheduled refresh and the silo tabs are not shown. self._refresh() - asset_widget = self.data["widgets"]["assets"] - asset_widget.select_assets(asset) + self._assets_widget.select_assets(asset) + + def _on_message_timeout(self): + self._message_label.setText("") def echo(self, message): - widget = self.data["label"]["message"] - widget.setText(str(message)) - widget.show() + self._message_label.setText(str(message)) print(message) - - lib.schedule(widget.hide, 5000, channel="message") + self._message_timer.start() def closeEvent(self, event): # Kill on holding SHIFT @@ -475,7 +508,7 @@ class LoaderWindow(QtWidgets.QDialog): event.setAccepted(True) # Avoid interfering other widgets def show_grouping_dialog(self): - subsets = self.data["widgets"]["subsets"] + subsets = self._subsets_widget if not subsets.is_groupable(): self.echo("Grouping not enabled.") return @@ -514,7 +547,8 @@ class SubsetGroupingDialog(QtWidgets.QDialog): self.items = items self.groups_config = groups_config - self.subsets = parent.data["widgets"]["subsets"] + # TODO do not touch inner attributes + self.subsets = parent._subsets_widget self.asset_ids = parent.data["state"]["assetIds"] name = QtWidgets.QLineEdit() @@ -633,7 +667,6 @@ def show(debug=False, parent=None, use_context=False): with lib.application(): window = LoaderWindow(parent) - window.setStyleSheet(style.load_stylesheet()) window.show() if use_context: diff --git a/openpype/tools/loader/images/default_thumbnail.png b/openpype/tools/loader/images/default_thumbnail.png index 97bd958e0d..adea862e5b 100644 Binary files a/openpype/tools/loader/images/default_thumbnail.png and b/openpype/tools/loader/images/default_thumbnail.png differ diff --git a/openpype/tools/loader/model.py b/openpype/tools/loader/model.py index 6e9c7bf220..96a52fce97 100644 --- a/openpype/tools/loader/model.py +++ b/openpype/tools/loader/model.py @@ -15,6 +15,12 @@ from openpype.tools.utils.models import TreeModel, Item from openpype.tools.utils import lib from openpype.modules import ModulesManager +from openpype.tools.utils.constants import ( + LOCAL_PROVIDER_ROLE, + REMOTE_PROVIDER_ROLE, + LOCAL_AVAILABILITY_ROLE, + REMOTE_AVAILABILITY_ROLE +) def is_filtering_recursible(): @@ -237,9 +243,9 @@ class SubsetsModel(TreeModel, BaseRepresentationModel): # update availability on active site when version changes if self.sync_server.enabled and version: - site = self.active_site query = self._repre_per_version_pipeline([version["_id"]], - site) + self.active_site, + self.remote_site) docs = list(self.dbcon.aggregate(query)) if docs: repre = docs.pop() @@ -333,7 +339,6 @@ class SubsetsModel(TreeModel, BaseRepresentationModel): repre_info = version_data.get("repre_info") if repre_info: item["repre_info"] = repre_info - item["repre_icon"] = version_data.get("repre_icon") def _fetch(self): asset_docs = self.dbcon.find( @@ -445,14 +450,16 @@ class SubsetsModel(TreeModel, BaseRepresentationModel): for _subset_id, doc in last_versions_by_subset_id.items(): version_ids.add(doc["_id"]) - site = self.active_site - query = self._repre_per_version_pipeline(list(version_ids), site) + query = self._repre_per_version_pipeline(list(version_ids), + self.active_site, + self.remote_site) repre_info = {} for doc in self.dbcon.aggregate(query): if self._doc_fetching_stop: return - doc["provider"] = self.active_provider + doc["active_provider"] = self.active_provider + doc["remote_provider"] = self.remote_provider repre_info[doc["_id"]] = doc self._doc_payload["repre_info_by_version_id"] = repre_info @@ -666,8 +673,8 @@ class SubsetsModel(TreeModel, BaseRepresentationModel): if not index.isValid(): return + item = index.internalPointer() if role == self.SortDescendingRole: - item = index.internalPointer() if item.get("isGroup"): # Ensure groups be on top when sorting by descending order prefix = "2" @@ -683,7 +690,6 @@ class SubsetsModel(TreeModel, BaseRepresentationModel): return prefix + order if role == self.SortAscendingRole: - item = index.internalPointer() if item.get("isGroup"): # Ensure groups be on top when sorting by ascending order prefix = "0" @@ -701,14 +707,12 @@ class SubsetsModel(TreeModel, BaseRepresentationModel): if role == QtCore.Qt.DisplayRole: if index.column() == self.columns_index["family"]: # Show familyLabel instead of family - item = index.internalPointer() return item.get("familyLabel", None) elif role == QtCore.Qt.DecorationRole: # Add icon to subset column if index.column() == self.columns_index["subset"]: - item = index.internalPointer() if item.get("isGroup") or item.get("isMerged"): return item["icon"] else: @@ -716,20 +720,32 @@ class SubsetsModel(TreeModel, BaseRepresentationModel): # Add icon to family column if index.column() == self.columns_index["family"]: - item = index.internalPointer() return item.get("familyIcon", None) - if index.column() == self.columns_index.get("repre_info"): - item = index.internalPointer() - return item.get("repre_icon", None) - elif role == QtCore.Qt.ForegroundRole: - item = index.internalPointer() version_doc = item.get("version_document") if version_doc and version_doc.get("type") == "hero_version": if not version_doc["is_from_latest"]: return self.not_last_hero_brush + elif role == LOCAL_AVAILABILITY_ROLE: + if not item.get("isGroup"): + return item.get("repre_info_local") + else: + return None + + elif role == REMOTE_AVAILABILITY_ROLE: + if not item.get("isGroup"): + return item.get("repre_info_remote") + else: + return None + + elif role == LOCAL_PROVIDER_ROLE: + return self.active_provider + + elif role == REMOTE_PROVIDER_ROLE: + return self.remote_provider + return super(SubsetsModel, self).data(index, role) def flags(self, index): @@ -759,19 +775,25 @@ class SubsetsModel(TreeModel, BaseRepresentationModel): return data def _get_repre_dict(self, repre_info): - """Returns icon and str representation of availability""" + """Returns str representation of availability""" data = {} if repre_info: repres_str = "{}/{}".format( - int(math.floor(float(repre_info['avail_repre']))), + int(math.floor(float(repre_info['avail_repre_local']))), int(math.floor(float(repre_info['repre_count'])))) - data["repre_info"] = repres_str - data["repre_icon"] = self.repre_icons.get(self.active_provider) + data["repre_info_local"] = repres_str + + repres_str = "{}/{}".format( + int(math.floor(float(repre_info['avail_repre_remote']))), + int(math.floor(float(repre_info['repre_count'])))) + + data["repre_info_remote"] = repres_str return data - def _repre_per_version_pipeline(self, version_ids, site): + def _repre_per_version_pipeline(self, version_ids, + active_site, remote_site): query = [ {"$match": {"parent": {"$in": version_ids}, "type": "representation", @@ -779,35 +801,70 @@ class SubsetsModel(TreeModel, BaseRepresentationModel): {"$unwind": "$files"}, {'$addFields': { 'order_local': { - '$filter': {'input': '$files.sites', 'as': 'p', - 'cond': {'$eq': ['$$p.name', site]} - }} + '$filter': { + 'input': '$files.sites', 'as': 'p', + 'cond': {'$eq': ['$$p.name', active_site]} + } + } + }}, + {'$addFields': { + 'order_remote': { + '$filter': { + 'input': '$files.sites', 'as': 'p', + 'cond': {'$eq': ['$$p.name', remote_site]} + } + } }}, {'$addFields': { 'progress_local': {"$arrayElemAt": [{ - '$cond': [{'$size': "$order_local.progress"}, - "$order_local.progress", - # if exists created_dt count is as available - {'$cond': [ - {'$size': "$order_local.created_dt"}, - [1], - [0] - ]} - ]}, 0]} + '$cond': [ + {'$size': "$order_local.progress"}, + "$order_local.progress", + # if exists created_dt count is as available + {'$cond': [ + {'$size': "$order_local.created_dt"}, + [1], + [0] + ]} + ]}, + 0 + ]} + }}, + {'$addFields': { + 'progress_remote': {"$arrayElemAt": [{ + '$cond': [ + {'$size': "$order_remote.progress"}, + "$order_remote.progress", + # if exists created_dt count is as available + {'$cond': [ + {'$size': "$order_remote.created_dt"}, + [1], + [0] + ]} + ]}, + 0 + ]} }}, {'$group': { # first group by repre '_id': '$_id', 'parent': {'$first': '$parent'}, - 'files_count': {'$sum': 1}, - 'files_avail': {'$sum': "$progress_local"}, - 'avail_ratio': {'$first': { - '$divide': [{'$sum': "$progress_local"}, {'$sum': 1}]}} + 'avail_ratio_local': { + '$first': { + '$divide': [{'$sum': "$progress_local"}, {'$sum': 1}] + } + }, + 'avail_ratio_remote': { + '$first': { + '$divide': [{'$sum': "$progress_remote"}, {'$sum': 1}] + } + } }}, {'$group': { # second group by parent, eg version_id '_id': '$parent', 'repre_count': {'$sum': 1}, # total representations # fully available representation for site - 'avail_repre': {'$sum': "$avail_ratio"} + 'avail_repre_local': {'$sum': "$avail_ratio_local"}, + 'avail_repre_remote': {'$sum': "$avail_ratio_remote"}, }}, ] return query diff --git a/openpype/tools/loader/widgets.py b/openpype/tools/loader/widgets.py index 1ccbb5796d..08b58eebbe 100644 --- a/openpype/tools/loader/widgets.py +++ b/openpype/tools/loader/widgets.py @@ -31,18 +31,26 @@ from .model import ( ) from . import lib +from openpype.tools.utils.constants import ( + LOCAL_PROVIDER_ROLE, + REMOTE_PROVIDER_ROLE, + LOCAL_AVAILABILITY_ROLE, + REMOTE_AVAILABILITY_ROLE +) + class OverlayFrame(QtWidgets.QFrame): def __init__(self, label, parent): super(OverlayFrame, self).__init__(parent) label_widget = QtWidgets.QLabel(label, self) + label_widget.setAttribute(QtCore.Qt.WA_TranslucentBackground) + main_layout = QtWidgets.QVBoxLayout(self) main_layout.addWidget(label_widget, 1, QtCore.Qt.AlignCenter) self.label_widget = label_widget - label_widget.setStyleSheet("background: transparent;") self.setStyleSheet(( "background: rgba(0, 0, 0, 127);" "font-size: 60pt;" @@ -159,92 +167,85 @@ class SubsetWidget(QtWidgets.QWidget): grouping=enable_grouping ) proxy = SubsetFilterProxyModel() + proxy.setSourceModel(model) + proxy.setDynamicSortFilter(True) + proxy.setFilterCaseSensitivity(QtCore.Qt.CaseInsensitive) + family_proxy = FamiliesFilterProxyModel() family_proxy.setSourceModel(proxy) - subset_filter = QtWidgets.QLineEdit() + subset_filter = QtWidgets.QLineEdit(self) subset_filter.setPlaceholderText("Filter subsets..") - groupable = QtWidgets.QCheckBox("Enable Grouping") - groupable.setChecked(enable_grouping) + group_checkbox = QtWidgets.QCheckBox("Enable Grouping", self) + group_checkbox.setChecked(enable_grouping) top_bar_layout = QtWidgets.QHBoxLayout() top_bar_layout.addWidget(subset_filter) - top_bar_layout.addWidget(groupable) + top_bar_layout.addWidget(group_checkbox) - view = TreeViewSpinner() + view = TreeViewSpinner(self) + view.setModel(family_proxy) view.setObjectName("SubsetView") view.setIndentation(20) - view.setStyleSheet(""" - QTreeView::item{ - padding: 5px 1px; - border: 0px; - } - """) view.setAllColumnsShowFocus(True) - - # Set view delegates - version_delegate = VersionDelegate(self.dbcon) - column = model.Columns.index("version") - view.setItemDelegateForColumn(column, version_delegate) - - time_delegate = PrettyTimeDelegate() - column = model.Columns.index("time") - view.setItemDelegateForColumn(column, time_delegate) - - layout = QtWidgets.QVBoxLayout(self) - layout.setContentsMargins(0, 0, 0, 0) - layout.addLayout(top_bar_layout) - layout.addWidget(view) - view.setContextMenuPolicy(QtCore.Qt.CustomContextMenu) view.setSelectionMode(QtWidgets.QAbstractItemView.ExtendedSelection) view.setSortingEnabled(True) view.sortByColumn(1, QtCore.Qt.AscendingOrder) view.setAlternatingRowColors(True) - self.data = { - "delegates": { - "version": version_delegate, - "time": time_delegate - }, - "state": { - "groupable": groupable - } - } + # Set view delegates + version_delegate = VersionDelegate(self.dbcon, view) + column = model.Columns.index("version") + view.setItemDelegateForColumn(column, version_delegate) - self.proxy = proxy - self.model = model - self.view = view - self.filter = subset_filter - self.family_proxy = family_proxy + time_delegate = PrettyTimeDelegate(view) + column = model.Columns.index("time") + view.setItemDelegateForColumn(column, time_delegate) + + avail_delegate = AvailabilityDelegate(self.dbcon, view) + column = model.Columns.index("repre_info") + view.setItemDelegateForColumn(column, avail_delegate) + + layout = QtWidgets.QVBoxLayout(self) + layout.setContentsMargins(0, 0, 0, 0) + layout.addLayout(top_bar_layout) + layout.addWidget(view) # settings and connections - self.proxy.setSourceModel(self.model) - self.proxy.setDynamicSortFilter(True) - self.proxy.setFilterCaseSensitivity(QtCore.Qt.CaseInsensitive) - - self.view.setModel(self.family_proxy) - self.view.customContextMenuRequested.connect(self.on_context_menu) - for column_name, width in self.default_widths: idx = model.Columns.index(column_name) view.setColumnWidth(idx, width) + self.model = model + self.view = view + actual_project = dbcon.Session["AVALON_PROJECT"] self.on_project_change(actual_project) + view.customContextMenuRequested.connect(self.on_context_menu) + selection = view.selectionModel() selection.selectionChanged.connect(self.active_changed) version_delegate.version_changed.connect(self.version_changed) - groupable.stateChanged.connect(self.set_grouping) + group_checkbox.stateChanged.connect(self.set_grouping) - self.filter.textChanged.connect(self.proxy.setFilterRegExp) - self.filter.textChanged.connect(self.view.expandAll) + subset_filter.textChanged.connect(proxy.setFilterRegExp) + subset_filter.textChanged.connect(view.expandAll) model.refreshed.connect(self.refreshed) + self.proxy = proxy + self.family_proxy = family_proxy + + self._subset_filter = subset_filter + self._group_checkbox = group_checkbox + + self._version_delegate = version_delegate + self._time_delegate = time_delegate + self.model.refresh() def get_subsets_families(self): @@ -254,7 +255,7 @@ class SubsetWidget(QtWidgets.QWidget): self.family_proxy.setFamiliesFilter(families) def is_groupable(self): - return self.data["state"]["groupable"].checkState() + return self._group_checkbox.isChecked() def set_grouping(self, state): with tools_lib.preserve_selection(tree_view=self.view, @@ -755,6 +756,7 @@ class ThumbnailWidget(QtWidgets.QLabel): "default_thumbnail.png" ) self.default_pix = QtGui.QPixmap(default_pix_path) + self.set_pixmap() def height(self): width = self.width() @@ -1131,7 +1133,8 @@ class RepresentationWidget(QtWidgets.QWidget): label = QtWidgets.QLabel("Representations", self) - tree_view = DeselectableTreeView() + tree_view = DeselectableTreeView(parent=self) + tree_view.setObjectName("RepresentationView") tree_view.setModel(proxy_model) tree_view.setAllColumnsShowFocus(True) tree_view.setContextMenuPolicy(QtCore.Qt.CustomContextMenu) @@ -1141,12 +1144,6 @@ class RepresentationWidget(QtWidgets.QWidget): tree_view.sortByColumn(1, QtCore.Qt.AscendingOrder) tree_view.setAlternatingRowColors(True) tree_view.setIndentation(20) - tree_view.setStyleSheet(""" - QTreeView::item{ - padding: 5px 1px; - border: 0px; - } - """) tree_view.collapseAll() for column_name, width in self.default_widths: @@ -1592,3 +1589,54 @@ def _load_subsets_by_loader(loader, subset_contexts, options, )) return error_info + + +class AvailabilityDelegate(QtWidgets.QStyledItemDelegate): + """ + Prints icons and downloaded representation ration for both sides. + """ + + def __init__(self, dbcon, parent=None): + super(AvailabilityDelegate, self).__init__(parent) + self.icons = tools_lib.get_repre_icons() + + def paint(self, painter, option, index): + super(AvailabilityDelegate, self).paint(painter, option, index) + option = QtWidgets.QStyleOptionViewItem(option) + option.showDecorationSelected = True + + provider_active = index.data(LOCAL_PROVIDER_ROLE) + provider_remote = index.data(REMOTE_PROVIDER_ROLE) + + availability_active = index.data(LOCAL_AVAILABILITY_ROLE) + availability_remote = index.data(REMOTE_AVAILABILITY_ROLE) + + if not availability_active or not availability_remote: # group lines + return + + idx = 0 + height = width = 24 + for value, provider in [(availability_active, provider_active), + (availability_remote, provider_remote)]: + icon = self.icons.get(provider) + if not icon: + continue + + pixmap = icon.pixmap(icon.actualSize(QtCore.QSize(height, width))) + padding = 10 + (70 * idx) + point = QtCore.QPoint(option.rect.x() + padding, + option.rect.y() + + (option.rect.height() - pixmap.height()) / 2) + painter.drawPixmap(point, pixmap) + + text_rect = option.rect.translated(padding + width + 10, 0) + painter.drawText( + text_rect, + option.displayAlignment, + value + ) + + idx += 1 + + def displayText(self, value, locale): + pass diff --git a/openpype/tools/mayalookassigner/app.py b/openpype/tools/mayalookassigner/app.py index 1fa3a3868a..fb99333f87 100644 --- a/openpype/tools/mayalookassigner/app.py +++ b/openpype/tools/mayalookassigner/app.py @@ -2,20 +2,27 @@ import sys import time import logging +from Qt import QtWidgets, QtCore + from openpype.hosts.maya.api.lib import assign_look_by_version from avalon import style, io -from avalon.tools import lib -from avalon.vendor.Qt import QtWidgets, QtCore +from openpype.tools.utils.lib import qt_app_context from maya import cmds # old api for MFileIO import maya.OpenMaya import maya.api.OpenMaya as om -from . import widgets -from . import commands -from . vray_proxies import vrayproxy_assign_look +from .widgets import ( + AssetOutliner, + LookOutliner +) +from .commands import ( + get_workfile, + remove_unused_looks +) +from .vray_proxies import vrayproxy_assign_look module = sys.modules[__name__] @@ -31,25 +38,19 @@ class App(QtWidgets.QWidget): # Store callback references self._callbacks = [] + self._connections_set_up = False - filename = commands.get_workfile() + filename = get_workfile() self.setObjectName("lookManager") self.setWindowTitle("Look Manager 1.3.0 - [{}]".format(filename)) self.setWindowFlags(QtCore.Qt.Window) self.setParent(parent) - # Force to delete the window on close so it triggers - # closeEvent only once. Otherwise it's retriggered when - # the widget gets garbage collected. - self.setAttribute(QtCore.Qt.WA_DeleteOnClose) - self.resize(750, 500) self.setup_ui() - self.setup_connections() - # Force refresh check on initialization self._on_renderlayer_switch() @@ -57,13 +58,13 @@ class App(QtWidgets.QWidget): """Build the UI""" # Assets (left) - asset_outliner = widgets.AssetOutliner() + asset_outliner = AssetOutliner() # Looks (right) looks_widget = QtWidgets.QWidget() looks_layout = QtWidgets.QVBoxLayout(looks_widget) - look_outliner = widgets.LookOutliner() # Database look overview + look_outliner = LookOutliner() # Database look overview assign_selected = QtWidgets.QCheckBox("Assign to selected only") assign_selected.setToolTip("Whether to assign only to selected nodes " @@ -104,6 +105,16 @@ class App(QtWidgets.QWidget): asset_outliner.view.setColumnWidth(0, 200) look_outliner.view.setColumnWidth(0, 150) + asset_outliner.selection_changed.connect( + self.on_asset_selection_changed) + + asset_outliner.refreshed.connect( + lambda: self.echo("Loaded assets..") + ) + + look_outliner.menu_apply_action.connect(self.on_process_selected) + remove_unused_btn.clicked.connect(remove_unused_looks) + # Open widgets self.asset_outliner = asset_outliner self.look_outliner = look_outliner @@ -116,15 +127,8 @@ class App(QtWidgets.QWidget): def setup_connections(self): """Connect interactive widgets with actions""" - - self.asset_outliner.selection_changed.connect( - self.on_asset_selection_changed) - - self.asset_outliner.refreshed.connect( - lambda: self.echo("Loaded assets..")) - - self.look_outliner.menu_apply_action.connect(self.on_process_selected) - self.remove_unused.clicked.connect(commands.remove_unused_looks) + if self._connections_set_up: + return # Maya renderlayer switch callback callback = om.MEventMessage.addEventCallback( @@ -132,14 +136,23 @@ class App(QtWidgets.QWidget): self._on_renderlayer_switch ) self._callbacks.append(callback) + self._connections_set_up = True - def closeEvent(self, event): - + def remove_connection(self): # Delete callbacks for callback in self._callbacks: om.MMessage.removeCallback(callback) - return super(App, self).closeEvent(event) + self._callbacks = [] + self._connections_set_up = False + + def showEvent(self, event): + self.setup_connections() + super(App, self).showEvent(event) + + def closeEvent(self, event): + self.remove_connection() + super(App, self).closeEvent(event) def _on_renderlayer_switch(self, *args): """Callback that updates on Maya renderlayer switch""" @@ -251,7 +264,7 @@ def show(): mainwindow = next(widget for widget in top_level_widgets if widget.objectName() == "MayaWindow") - with lib.application(): + with qt_app_context(): window = App(parent=mainwindow) window.setStyleSheet(style.load_stylesheet()) window.show() diff --git a/openpype/tools/mayalookassigner/commands.py b/openpype/tools/mayalookassigner/commands.py index a53251cdef..f7d26f9adb 100644 --- a/openpype/tools/mayalookassigner/commands.py +++ b/openpype/tools/mayalookassigner/commands.py @@ -9,7 +9,7 @@ from openpype.hosts.maya.api import lib from avalon import io, api -import vray_proxies +from .vray_proxies import get_alembic_ids_cache log = logging.getLogger(__name__) @@ -146,7 +146,7 @@ def create_items_from_nodes(nodes): vray_proxy_nodes = cmds.ls(nodes, type="VRayProxy") for vp in vray_proxy_nodes: path = cmds.getAttr("{}.fileName".format(vp)) - ids = vray_proxies.get_alembic_ids_cache(path) + ids = get_alembic_ids_cache(path) parent_id = {} for k, _ in ids.items(): pid = k.split(":")[0] diff --git a/openpype/tools/mayalookassigner/models.py b/openpype/tools/mayalookassigner/models.py index 7c5133de82..80de6c1897 100644 --- a/openpype/tools/mayalookassigner/models.py +++ b/openpype/tools/mayalookassigner/models.py @@ -1,7 +1,8 @@ from collections import defaultdict -from avalon.tools import models -from avalon.vendor.Qt import QtCore +from Qt import QtCore + +from avalon.tools import models from avalon.vendor import qtawesome from avalon.style import colors diff --git a/openpype/tools/mayalookassigner/views.py b/openpype/tools/mayalookassigner/views.py index decf04ee57..993023bb45 100644 --- a/openpype/tools/mayalookassigner/views.py +++ b/openpype/tools/mayalookassigner/views.py @@ -1,4 +1,4 @@ -from avalon.vendor.Qt import QtWidgets, QtCore +from Qt import QtWidgets, QtCore DEFAULT_COLOR = "#fb9c15" diff --git a/openpype/tools/mayalookassigner/widgets.py b/openpype/tools/mayalookassigner/widgets.py index 2dab266af9..625e9ef8c6 100644 --- a/openpype/tools/mayalookassigner/widgets.py +++ b/openpype/tools/mayalookassigner/widgets.py @@ -1,13 +1,16 @@ import logging from collections import defaultdict -from avalon.vendor.Qt import QtWidgets, QtCore +from Qt import QtWidgets, QtCore # TODO: expose this better in avalon core from avalon.tools import lib from avalon.tools.models import TreeModel -from . import models +from .models import ( + AssetModel, + LookModel +) from . import commands from . import views @@ -30,7 +33,7 @@ class AssetOutliner(QtWidgets.QWidget): title.setAlignment(QtCore.Qt.AlignCenter) title.setStyleSheet("font-weight: bold; font-size: 12px") - model = models.AssetModel() + model = AssetModel() view = views.View() view.setModel(model) view.customContextMenuRequested.connect(self.right_mouse_menu) @@ -201,7 +204,7 @@ class LookOutliner(QtWidgets.QWidget): title.setStyleSheet("font-weight: bold; font-size: 12px") title.setAlignment(QtCore.Qt.AlignCenter) - model = models.LookModel() + model = LookModel() # Proxy for dynamic sorting proxy = QtCore.QSortFilterProxyModel() @@ -257,5 +260,3 @@ class LookOutliner(QtWidgets.QWidget): menu.addAction(apply_action) menu.exec_(globalpos) - - diff --git a/openpype/tools/project_manager/project_manager/model.py b/openpype/tools/project_manager/project_manager/model.py index 5b6ed78b50..b7ab9e40d0 100644 --- a/openpype/tools/project_manager/project_manager/model.py +++ b/openpype/tools/project_manager/project_manager/model.py @@ -1456,7 +1456,11 @@ class HierarchyModel(QtCore.QAbstractItemModel): return raw_data = mime_data.data("application/copy_task") - encoded_data = QtCore.QByteArray.fromRawData(raw_data) + if isinstance(raw_data, QtCore.QByteArray): + # Raw data are already QByteArrat and we don't have to load them + encoded_data = raw_data + else: + encoded_data = QtCore.QByteArray.fromRawData(raw_data) stream = QtCore.QDataStream(encoded_data, QtCore.QIODevice.ReadOnly) text = stream.readQString() try: diff --git a/openpype/tools/publisher/__init__.py b/openpype/tools/publisher/__init__.py new file mode 100644 index 0000000000..a7b597eece --- /dev/null +++ b/openpype/tools/publisher/__init__.py @@ -0,0 +1,7 @@ +from .app import show +from .window import PublisherWindow + +__all__ = ( + "show", + "PublisherWindow" +) diff --git a/openpype/tools/publisher/app.py b/openpype/tools/publisher/app.py new file mode 100644 index 0000000000..bc1bd7cfbd --- /dev/null +++ b/openpype/tools/publisher/app.py @@ -0,0 +1,17 @@ +from .window import PublisherWindow + + +class _WindowCache: + window = None + + +def show(parent=None): + window = _WindowCache.window + if window is None: + window = PublisherWindow(parent) + _WindowCache.window = window + + window.show() + window.activateWindow() + + return window diff --git a/openpype/tools/publisher/constants.py b/openpype/tools/publisher/constants.py new file mode 100644 index 0000000000..cf0850bde8 --- /dev/null +++ b/openpype/tools/publisher/constants.py @@ -0,0 +1,34 @@ +from Qt import QtCore + +# ID of context item in instance view +CONTEXT_ID = "context" +CONTEXT_LABEL = "Options" + +# Allowed symbols for subset name (and variant) +# - characters, numbers, unsercore and dash +SUBSET_NAME_ALLOWED_SYMBOLS = "a-zA-Z0-9_." +VARIANT_TOOLTIP = ( + "Variant may contain alphabetical characters (a-Z)" + "\nnumerical characters (0-9) dot (\".\") or underscore (\"_\")." +) + +# Roles for instance views +INSTANCE_ID_ROLE = QtCore.Qt.UserRole + 1 +SORT_VALUE_ROLE = QtCore.Qt.UserRole + 2 +IS_GROUP_ROLE = QtCore.Qt.UserRole + 3 +CREATOR_IDENTIFIER_ROLE = QtCore.Qt.UserRole + 4 +FAMILY_ROLE = QtCore.Qt.UserRole + 5 + + +__all__ = ( + "CONTEXT_ID", + + "SUBSET_NAME_ALLOWED_SYMBOLS", + "VARIANT_TOOLTIP", + + "INSTANCE_ID_ROLE", + "SORT_VALUE_ROLE", + "IS_GROUP_ROLE", + "CREATOR_IDENTIFIER_ROLE", + "FAMILY_ROLE" +) diff --git a/openpype/tools/publisher/control.py b/openpype/tools/publisher/control.py new file mode 100644 index 0000000000..24ec9dcb0e --- /dev/null +++ b/openpype/tools/publisher/control.py @@ -0,0 +1,991 @@ +import os +import copy +import inspect +import logging +import traceback +import collections + +import weakref +try: + from weakref import WeakMethod +except Exception: + from openpype.lib.python_2_comp import WeakMethod + +import avalon.api +import pyblish.api + +from openpype.pipeline import PublishValidationError +from openpype.pipeline.create import CreateContext + +from Qt import QtCore + +# Define constant for plugin orders offset +PLUGIN_ORDER_OFFSET = 0.5 + + +class MainThreadItem: + """Callback with args and kwargs.""" + def __init__(self, callback, *args, **kwargs): + self.callback = callback + self.args = args + self.kwargs = kwargs + + def process(self): + self.callback(*self.args, **self.kwargs) + + +class MainThreadProcess(QtCore.QObject): + """Qt based main thread process executor. + + Has timer which controls each 50ms if there is new item to process. + + This approach gives ability to update UI meanwhile plugin is in progress. + """ + def __init__(self): + super(MainThreadProcess, self).__init__() + self._items_to_process = collections.deque() + + timer = QtCore.QTimer() + timer.setInterval(50) + + timer.timeout.connect(self._execute) + + self._timer = timer + + def add_item(self, item): + self._items_to_process.append(item) + + def _execute(self): + if not self._items_to_process: + return + + item = self._items_to_process.popleft() + item.process() + + def start(self): + if not self._timer.isActive(): + self._timer.start() + + def stop(self): + if self._timer.isActive(): + self._timer.stop() + + def clear(self): + if self._timer.isActive(): + self._timer.stop() + self._items_to_process = collections.deque() + + +class AssetDocsCache: + """Cache asset documents for creation part.""" + projection = { + "_id": True, + "name": True, + "data.visualParent": True, + "data.tasks": True + } + + def __init__(self, controller): + self._controller = controller + self._asset_docs = None + self._task_names_by_asset_name = {} + + @property + def dbcon(self): + return self._controller.dbcon + + def reset(self): + self._asset_docs = None + self._task_names_by_asset_name = {} + + def _query(self): + if self._asset_docs is None: + asset_docs = list(self.dbcon.find( + {"type": "asset"}, + self.projection + )) + task_names_by_asset_name = {} + for asset_doc in asset_docs: + asset_name = asset_doc["name"] + asset_tasks = asset_doc.get("data", {}).get("tasks") or {} + task_names_by_asset_name[asset_name] = list(asset_tasks.keys()) + self._asset_docs = asset_docs + self._task_names_by_asset_name = task_names_by_asset_name + + def get_asset_docs(self): + self._query() + return copy.deepcopy(self._asset_docs) + + def get_task_names_by_asset_name(self): + self._query() + return copy.deepcopy(self._task_names_by_asset_name) + + +class PublishReport: + """Report for single publishing process. + + Report keeps current state of publishing and currently processed plugin. + """ + def __init__(self, controller): + self.controller = controller + self._publish_discover_result = None + self._plugin_data = [] + self._plugin_data_with_plugin = [] + + self._stored_plugins = [] + self._current_plugin_data = [] + self._all_instances_by_id = {} + self._current_context = None + + def reset(self, context, publish_discover_result=None): + """Reset report and clear all data.""" + self._publish_discover_result = publish_discover_result + self._plugin_data = [] + self._plugin_data_with_plugin = [] + self._current_plugin_data = {} + self._all_instances_by_id = {} + self._current_context = context + + def add_plugin_iter(self, plugin, context): + """Add report about single iteration of plugin.""" + for instance in context: + self._all_instances_by_id[instance.id] = instance + + if self._current_plugin_data: + self._current_plugin_data["passed"] = True + + self._current_plugin_data = self._add_plugin_data_item(plugin) + + def _get_plugin_data_item(self, plugin): + store_item = None + for item in self._plugin_data_with_plugin: + if item["plugin"] is plugin: + store_item = item["data"] + break + return store_item + + def _add_plugin_data_item(self, plugin): + if plugin in self._stored_plugins: + raise ValueError("Plugin is already stored") + + self._stored_plugins.append(plugin) + + label = None + if hasattr(plugin, "label"): + label = plugin.label + + plugin_data_item = { + "name": plugin.__name__, + "label": label, + "order": plugin.order, + "instances_data": [], + "actions_data": [], + "skipped": False, + "passed": False + } + self._plugin_data_with_plugin.append({ + "plugin": plugin, + "data": plugin_data_item + }) + self._plugin_data.append(plugin_data_item) + return plugin_data_item + + def set_plugin_skipped(self): + """Set that current plugin has been skipped.""" + self._current_plugin_data["skipped"] = True + + def add_result(self, result): + """Handle result of one plugin and it's instance.""" + instance = result["instance"] + instance_id = None + if instance is not None: + instance_id = instance.id + self._current_plugin_data["instances_data"].append({ + "id": instance_id, + "logs": self._extract_instance_log_items(result) + }) + + def add_action_result(self, action, result): + """Add result of single action.""" + plugin = result["plugin"] + + store_item = self._get_plugin_data_item(plugin) + if store_item is None: + store_item = self._add_plugin_data_item(plugin) + + action_name = action.__name__ + action_label = action.label or action_name + log_items = self._extract_log_items(result) + store_item["actions_data"].append({ + "success": result["success"], + "name": action_name, + "label": action_label, + "logs": log_items + }) + + def get_report(self, publish_plugins=None): + """Report data with all details of current state.""" + instances_details = {} + for instance in self._all_instances_by_id.values(): + instances_details[instance.id] = self._extract_instance_data( + instance, instance in self._current_context + ) + + plugins_data = copy.deepcopy(self._plugin_data) + if plugins_data and not plugins_data[-1]["passed"]: + plugins_data[-1]["passed"] = True + + if publish_plugins: + for plugin in publish_plugins: + if plugin not in self._stored_plugins: + plugins_data.append(self._add_plugin_data_item(plugin)) + + crashed_file_paths = {} + if self._publish_discover_result is not None: + items = self._publish_discover_result.crashed_file_paths.items() + for filepath, exc_info in items: + crashed_file_paths[filepath] = "".join( + traceback.format_exception(*exc_info) + ) + + return { + "plugins_data": plugins_data, + "instances": instances_details, + "context": self._extract_context_data(self._current_context), + "crashed_file_paths": crashed_file_paths + } + + def _extract_context_data(self, context): + return { + "label": context.data.get("label") + } + + def _extract_instance_data(self, instance, exists): + return { + "name": instance.data.get("name"), + "label": instance.data.get("label"), + "family": instance.data["family"], + "families": instance.data.get("families") or [], + "exists": exists + } + + def _extract_instance_log_items(self, result): + instance = result["instance"] + instance_id = None + if instance: + instance_id = instance.id + + log_items = self._extract_log_items(result) + for item in log_items: + item["instance_id"] = instance_id + return log_items + + def _extract_log_items(self, result): + output = [] + records = result.get("records") or [] + for record in records: + record_exc_info = record.exc_info + if record_exc_info is not None: + record_exc_info = "".join( + traceback.format_exception(*record_exc_info) + ) + + try: + msg = record.getMessage() + except Exception: + msg = str(record.msg) + + output.append({ + "type": "record", + "msg": msg, + "name": record.name, + "lineno": record.lineno, + "levelno": record.levelno, + "levelname": record.levelname, + "threadName": record.threadName, + "filename": record.filename, + "pathname": record.pathname, + "msecs": record.msecs, + "exc_info": record_exc_info + }) + + exception = result.get("error") + if exception: + fname, line_no, func, exc = exception.traceback + output.append({ + "type": "error", + "msg": str(exception), + "filename": str(fname), + "lineno": str(line_no), + "func": str(func), + "traceback": exception.formatted_traceback + }) + + return output + + +class PublisherController: + """Middleware between UI, CreateContext and publish Context. + + Handle both creation and publishing parts. + + Args: + dbcon (AvalonMongoDB): Connection to mongo with context. + headless (bool): Headless publishing. ATM not implemented or used. + """ + def __init__(self, dbcon=None, headless=False): + self.log = logging.getLogger("PublisherController") + self.host = avalon.api.registered_host() + self.headless = headless + + self.create_context = CreateContext( + self.host, dbcon, headless=headless, reset=False + ) + + # pyblish.api.Context + self._publish_context = None + # Pyblish report + self._publish_report = PublishReport(self) + # Store exceptions of validation error + self._publish_validation_errors = [] + # Currently processing plugin errors + self._publish_current_plugin_validation_errors = None + # Any other exception that happened during publishing + self._publish_error = None + # Publishing is in progress + self._publish_is_running = False + # Publishing is over validation order + self._publish_validated = False + # Publishing should stop at validation stage + self._publish_up_validation = False + # All publish plugins are processed + self._publish_finished = False + self._publish_max_progress = 0 + self._publish_progress = 0 + # This information is not much important for controller but for widget + # which can change (and set) the comment. + self._publish_comment_is_set = False + + # Validation order + # - plugin with order same or higher than this value is extractor or + # higher + self._validation_order = ( + pyblish.api.ValidatorOrder + PLUGIN_ORDER_OFFSET + ) + + # Qt based main thread processor + self._main_thread_processor = MainThreadProcess() + # Plugin iterator + self._main_thread_iter = None + + # Variables where callbacks are stored + self._instances_refresh_callback_refs = set() + self._plugins_refresh_callback_refs = set() + + self._publish_reset_callback_refs = set() + self._publish_started_callback_refs = set() + self._publish_validated_callback_refs = set() + self._publish_stopped_callback_refs = set() + + self._publish_instance_changed_callback_refs = set() + self._publish_plugin_changed_callback_refs = set() + + # State flags to prevent executing method which is already in progress + self._resetting_plugins = False + self._resetting_instances = False + + # Cacher of avalon documents + self._asset_docs_cache = AssetDocsCache(self) + + @property + def project_name(self): + """Current project context.""" + return self.dbcon.Session["AVALON_PROJECT"] + + @property + def dbcon(self): + """Pointer to AvalonMongoDB in creator context.""" + return self.create_context.dbcon + + @property + def instances(self): + """Current instances in create context.""" + return self.create_context.instances + + @property + def creators(self): + """All creators loaded in create context.""" + return self.create_context.creators + + @property + def manual_creators(self): + """Creators that can be shown in create dialog.""" + return self.create_context.manual_creators + + @property + def host_is_valid(self): + """Host is valid for creation.""" + return self.create_context.host_is_valid + + @property + def publish_plugins(self): + """Publish plugins.""" + return self.create_context.publish_plugins + + @property + def plugins_with_defs(self): + """Publish plugins with possible attribute definitions.""" + return self.create_context.plugins_with_defs + + def _create_reference(self, callback): + if inspect.ismethod(callback): + ref = WeakMethod(callback) + elif callable(callback): + ref = weakref.ref(callback) + else: + raise TypeError("Expected function or method got {}".format( + str(type(callback)) + )) + return ref + + def add_instances_refresh_callback(self, callback): + """Callbacks triggered on instances refresh.""" + ref = self._create_reference(callback) + self._instances_refresh_callback_refs.add(ref) + + def add_plugins_refresh_callback(self, callback): + """Callbacks triggered on plugins refresh.""" + ref = self._create_reference(callback) + self._plugins_refresh_callback_refs.add(ref) + + # --- Publish specific callbacks --- + def add_publish_reset_callback(self, callback): + """Callbacks triggered on publishing reset.""" + ref = self._create_reference(callback) + self._publish_reset_callback_refs.add(ref) + + def add_publish_started_callback(self, callback): + """Callbacks triggered on publishing start.""" + ref = self._create_reference(callback) + self._publish_started_callback_refs.add(ref) + + def add_publish_validated_callback(self, callback): + """Callbacks triggered on passing last possible validation order.""" + ref = self._create_reference(callback) + self._publish_validated_callback_refs.add(ref) + + def add_instance_change_callback(self, callback): + """Callbacks triggered before next publish instance process.""" + ref = self._create_reference(callback) + self._publish_instance_changed_callback_refs.add(ref) + + def add_plugin_change_callback(self, callback): + """Callbacks triggered before next plugin processing.""" + ref = self._create_reference(callback) + self._publish_plugin_changed_callback_refs.add(ref) + + def add_publish_stopped_callback(self, callback): + """Callbacks triggered on publishing stop (any reason).""" + ref = self._create_reference(callback) + self._publish_stopped_callback_refs.add(ref) + + def get_asset_docs(self): + """Get asset documents from cache for whole project.""" + return self._asset_docs_cache.get_asset_docs() + + def get_context_title(self): + """Get context title for artist shown at the top of main window.""" + context_title = None + if hasattr(self.host, "get_context_title"): + context_title = self.host.get_context_title() + + if context_title is None: + context_title = os.environ.get("AVALON_APP_NAME") + if context_title is None: + context_title = os.environ.get("AVALON_APP") + + return context_title + + def get_asset_hierarchy(self): + """Prepare asset documents into hierarchy.""" + _queue = collections.deque(self.get_asset_docs()) + + output = collections.defaultdict(list) + while _queue: + asset_doc = _queue.popleft() + parent_id = asset_doc["data"]["visualParent"] + output[parent_id].append(asset_doc) + return output + + def get_task_names_by_asset_names(self, asset_names): + """Prepare task names by asset name.""" + task_names_by_asset_name = ( + self._asset_docs_cache.get_task_names_by_asset_name() + ) + result = {} + for asset_name in asset_names: + result[asset_name] = set( + task_names_by_asset_name.get(asset_name) or [] + ) + return result + + def _trigger_callbacks(self, callbacks, *args, **kwargs): + """Helper method to trigger callbacks stored by their rerence.""" + # Trigger reset callbacks + to_remove = set() + for ref in callbacks: + callback = ref() + if callback: + callback(*args, **kwargs) + else: + to_remove.add(ref) + + for ref in to_remove: + callbacks.remove(ref) + + def reset(self): + """Reset everything related to creation and publishing.""" + # Stop publishing + self.stop_publish() + + # Reset avalon context + self.create_context.reset_avalon_context() + + self._reset_plugins() + # Publish part must be resetted after plugins + self._reset_publish() + self._reset_instances() + + def _reset_plugins(self): + """Reset to initial state.""" + if self._resetting_plugins: + return + + self._resetting_plugins = True + + self.create_context.reset_plugins() + + self._resetting_plugins = False + + self._trigger_callbacks(self._plugins_refresh_callback_refs) + + def _reset_instances(self): + """Reset create instances.""" + if self._resetting_instances: + return + + self._resetting_instances = True + + self.create_context.reset_context_data() + with self.create_context.bulk_instances_collection(): + self.create_context.reset_instances() + self.create_context.execute_autocreators() + + self._resetting_instances = False + + self._trigger_callbacks(self._instances_refresh_callback_refs) + + def get_creator_attribute_definitions(self, instances): + """Collect creator attribute definitions for multuple instances. + + Args: + instances(list): List of created instances for + which should be attribute definitions returned. + """ + output = [] + _attr_defs = {} + for instance in instances: + for attr_def in instance.creator_attribute_defs: + found_idx = None + for idx, _attr_def in _attr_defs.items(): + if attr_def == _attr_def: + found_idx = idx + break + + value = instance.creator_attributes[attr_def.key] + if found_idx is None: + idx = len(output) + output.append((attr_def, [instance], [value])) + _attr_defs[idx] = attr_def + else: + item = output[found_idx] + item[1].append(instance) + item[2].append(value) + return output + + def get_publish_attribute_definitions(self, instances, include_context): + """Collect publish attribute definitions for passed instances. + + Args: + instances(list): List of created instances for + which should be attribute definitions returned. + include_context(bool): Add context specific attribute definitions. + """ + _tmp_items = [] + if include_context: + _tmp_items.append(self.create_context) + + for instance in instances: + _tmp_items.append(instance) + + all_defs_by_plugin_name = {} + all_plugin_values = {} + for item in _tmp_items: + for plugin_name, attr_val in item.publish_attributes.items(): + attr_defs = attr_val.attr_defs + if not attr_defs: + continue + + if plugin_name not in all_defs_by_plugin_name: + all_defs_by_plugin_name[plugin_name] = attr_val.attr_defs + + if plugin_name not in all_plugin_values: + all_plugin_values[plugin_name] = {} + + plugin_values = all_plugin_values[plugin_name] + + for attr_def in attr_defs: + if attr_def.key not in plugin_values: + plugin_values[attr_def.key] = [] + attr_values = plugin_values[attr_def.key] + + value = attr_val[attr_def.key] + attr_values.append((item, value)) + + output = [] + for plugin in self.plugins_with_defs: + plugin_name = plugin.__name__ + if plugin_name not in all_defs_by_plugin_name: + continue + output.append(( + plugin_name, + all_defs_by_plugin_name[plugin_name], + all_plugin_values + )) + return output + + def get_icon_for_family(self, family): + """TODO rename to get creator icon.""" + creator = self.creators.get(family) + if creator is not None: + return creator.get_icon() + return None + + def create( + self, creator_identifier, subset_name, instance_data, options + ): + """Trigger creation and refresh of instances in UI.""" + creator = self.creators[creator_identifier] + creator.create(subset_name, instance_data, options) + + self._trigger_callbacks(self._instances_refresh_callback_refs) + + def save_changes(self): + """Save changes happened during creation.""" + if self.create_context.host_is_valid: + self.create_context.save_changes() + + def remove_instances(self, instances): + """""" + # QUESTION Expect that instaces are really removed? In that case save + # reset is not required and save changes too. + self.save_changes() + + self.create_context.remove_instances(instances) + + self._trigger_callbacks(self._instances_refresh_callback_refs) + + # --- Publish specific implementations --- + @property + def publish_has_finished(self): + return self._publish_finished + + @property + def publish_is_running(self): + return self._publish_is_running + + @property + def publish_has_validated(self): + return self._publish_validated + + @property + def publish_has_crashed(self): + return bool(self._publish_error) + + @property + def publish_has_validation_errors(self): + return bool(self._publish_validation_errors) + + @property + def publish_max_progress(self): + return self._publish_max_progress + + @property + def publish_progress(self): + return self._publish_progress + + @property + def publish_comment_is_set(self): + return self._publish_comment_is_set + + def get_publish_crash_error(self): + return self._publish_error + + def get_publish_report(self): + return self._publish_report.get_report(self.publish_plugins) + + def get_validation_errors(self): + return self._publish_validation_errors + + def _reset_publish(self): + self._publish_is_running = False + self._publish_validated = False + self._publish_up_validation = False + self._publish_finished = False + self._publish_comment_is_set = False + self._main_thread_processor.clear() + self._main_thread_iter = self._publish_iterator() + self._publish_context = pyblish.api.Context() + # Make sure "comment" is set on publish context + self._publish_context.data["comment"] = "" + # Add access to create context during publishing + # - must not be used for changing CreatedInstances during publishing! + # QUESTION + # - pop the key after first collector using it would be safest option? + self._publish_context.data["create_context"] = self.create_context + + self._publish_report.reset( + self._publish_context, + self.create_context.publish_discover_result + ) + self._publish_validation_errors = [] + self._publish_current_plugin_validation_errors = None + self._publish_error = None + + self._publish_max_progress = len(self.publish_plugins) + self._publish_progress = 0 + + self._trigger_callbacks(self._publish_reset_callback_refs) + + def set_comment(self, comment): + self._publish_context.data["comment"] = comment + self._publish_comment_is_set = True + + def publish(self): + """Run publishing.""" + self._publish_up_validation = False + self._start_publish() + + def validate(self): + """Run publishing and stop after Validation.""" + if self._publish_validated: + return + self._publish_up_validation = True + self._start_publish() + + def _start_publish(self): + """Start or continue in publishing.""" + if self._publish_is_running: + return + + # Make sure changes are saved + self.save_changes() + + self._publish_is_running = True + self._trigger_callbacks(self._publish_started_callback_refs) + self._main_thread_processor.start() + self._publish_next_process() + + def _stop_publish(self): + """Stop or pause publishing.""" + self._publish_is_running = False + self._main_thread_processor.stop() + self._trigger_callbacks(self._publish_stopped_callback_refs) + + def stop_publish(self): + """Stop publishing process (any reason).""" + if self._publish_is_running: + self._stop_publish() + + def run_action(self, plugin, action): + # TODO handle result in UI + result = pyblish.plugin.process( + plugin, self._publish_context, None, action.id + ) + self._publish_report.add_action_result(action, result) + + def _publish_next_process(self): + # Validations of progress before using iterator + # - same conditions may be inside iterator but they may be used + # only in specific cases (e.g. when it happens for a first time) + + # There are validation errors and validation is passed + # - can't do any progree + if ( + self._publish_validated + and self._publish_validation_errors + ): + item = MainThreadItem(self.stop_publish) + + # Any unexpected error happened + # - everything should stop + elif self._publish_error: + item = MainThreadItem(self.stop_publish) + + # Everything is ok so try to get new processing item + else: + item = next(self._main_thread_iter) + + self._main_thread_processor.add_item(item) + + def _publish_iterator(self): + """Main logic center of publishing. + + Iterator returns `MainThreadItem` objects with callbacks that should be + processed in main thread (threaded in future?). Cares about changing + states of currently processed publish plugin and instance. Also + change state of processed orders like validation order has passed etc. + + Also stops publishing if should stop on validation. + + QUESTION: + Does validate button still make sense? + """ + for idx, plugin in enumerate(self.publish_plugins): + self._publish_progress = idx + # Add plugin to publish report + self._publish_report.add_plugin_iter(plugin, self._publish_context) + + # Reset current plugin validations error + self._publish_current_plugin_validation_errors = None + + # Check if plugin is over validation order + if not self._publish_validated: + self._publish_validated = ( + plugin.order >= self._validation_order + ) + # Trigger callbacks when validation stage is passed + if self._publish_validated: + self._trigger_callbacks( + self._publish_validated_callback_refs + ) + + # Stop if plugin is over validation order and process + # should process up to validation. + if self._publish_up_validation and self._publish_validated: + yield MainThreadItem(self.stop_publish) + + # Stop if validation is over and validation errors happened + if ( + self._publish_validated + and self._publish_validation_errors + ): + yield MainThreadItem(self.stop_publish) + + # Trigger callback that new plugin is going to be processed + self._trigger_callbacks( + self._publish_plugin_changed_callback_refs, plugin + ) + # Plugin is instance plugin + if plugin.__instanceEnabled__: + instances = pyblish.logic.instances_by_plugin( + self._publish_context, plugin + ) + if not instances: + self._publish_report.set_plugin_skipped() + continue + + for instance in instances: + if instance.data.get("publish") is False: + continue + + self._trigger_callbacks( + self._publish_instance_changed_callback_refs, + self._publish_context, + instance + ) + yield MainThreadItem( + self._process_and_continue, plugin, instance + ) + else: + families = collect_families_from_instances( + self._publish_context, only_active=True + ) + plugins = pyblish.logic.plugins_by_families( + [plugin], families + ) + if plugins: + self._trigger_callbacks( + self._publish_instance_changed_callback_refs, + self._publish_context, + None + ) + yield MainThreadItem( + self._process_and_continue, plugin, None + ) + else: + self._publish_report.set_plugin_skipped() + + # Cleanup of publishing process + self._publish_finished = True + self._publish_progress = self._publish_max_progress + yield MainThreadItem(self.stop_publish) + + def _add_validation_error(self, result): + if self._publish_current_plugin_validation_errors is None: + self._publish_current_plugin_validation_errors = { + "plugin": result["plugin"], + "errors": [] + } + self._publish_validation_errors.append( + self._publish_current_plugin_validation_errors + ) + + self._publish_current_plugin_validation_errors["errors"].append({ + "exception": result["error"], + "instance": result["instance"] + }) + + def _process_and_continue(self, plugin, instance): + result = pyblish.plugin.process( + plugin, self._publish_context, instance + ) + + self._publish_report.add_result(result) + + exception = result.get("error") + if exception: + if ( + isinstance(exception, PublishValidationError) + and not self._publish_validated + ): + self._add_validation_error(result) + + else: + self._publish_error = exception + + self._publish_next_process() + + +def collect_families_from_instances(instances, only_active=False): + """Collect all families for passed publish instances. + + Args: + instances(list): List of publish instances from + which are families collected. + only_active(bool): Return families only for active instances. + """ + all_families = set() + for instance in instances: + if only_active: + if instance.data.get("publish") is False: + continue + family = instance.data.get("family") + if family: + all_families.add(family) + + families = instance.data.get("families") or tuple() + for family in families: + all_families.add(family) + + return list(all_families) diff --git a/openpype/tools/publisher/publish_report_viewer/__init__.py b/openpype/tools/publisher/publish_report_viewer/__init__.py new file mode 100644 index 0000000000..3cfaaa5a05 --- /dev/null +++ b/openpype/tools/publisher/publish_report_viewer/__init__.py @@ -0,0 +1,14 @@ +from .widgets import ( + PublishReportViewerWidget +) + +from .window import ( + PublishReportViewerWindow +) + + +__all__ = ( + "PublishReportViewerWidget", + + "PublishReportViewerWindow", +) diff --git a/openpype/tools/publisher/publish_report_viewer/constants.py b/openpype/tools/publisher/publish_report_viewer/constants.py new file mode 100644 index 0000000000..8fbb9342ca --- /dev/null +++ b/openpype/tools/publisher/publish_report_viewer/constants.py @@ -0,0 +1,20 @@ +from Qt import QtCore + + +ITEM_ID_ROLE = QtCore.Qt.UserRole + 1 +ITEM_IS_GROUP_ROLE = QtCore.Qt.UserRole + 2 +ITEM_LABEL_ROLE = QtCore.Qt.UserRole + 3 +ITEM_ERRORED_ROLE = QtCore.Qt.UserRole + 4 +PLUGIN_SKIPPED_ROLE = QtCore.Qt.UserRole + 5 +PLUGIN_PASSED_ROLE = QtCore.Qt.UserRole + 6 +INSTANCE_REMOVED_ROLE = QtCore.Qt.UserRole + 7 + + +__all__ = ( + "ITEM_ID_ROLE", + "ITEM_IS_GROUP_ROLE", + "ITEM_LABEL_ROLE", + "ITEM_ERRORED_ROLE", + "PLUGIN_SKIPPED_ROLE", + "INSTANCE_REMOVED_ROLE" +) diff --git a/openpype/tools/publisher/publish_report_viewer/delegates.py b/openpype/tools/publisher/publish_report_viewer/delegates.py new file mode 100644 index 0000000000..9cd4f52174 --- /dev/null +++ b/openpype/tools/publisher/publish_report_viewer/delegates.py @@ -0,0 +1,331 @@ +import collections +from Qt import QtWidgets, QtCore, QtGui +from .constants import ( + ITEM_IS_GROUP_ROLE, + ITEM_ERRORED_ROLE, + PLUGIN_SKIPPED_ROLE, + PLUGIN_PASSED_ROLE, + INSTANCE_REMOVED_ROLE +) + +colors = { + "error": QtGui.QColor("#ff4a4a"), + "warning": QtGui.QColor("#ff9900"), + "ok": QtGui.QColor("#77AE24"), + "active": QtGui.QColor("#99CEEE"), + "idle": QtCore.Qt.white, + "inactive": QtGui.QColor("#888"), + "hover": QtGui.QColor(255, 255, 255, 5), + "selected": QtGui.QColor(255, 255, 255, 10), + "outline": QtGui.QColor("#333"), + "group": QtGui.QColor("#21252B"), + "group-hover": QtGui.QColor("#3c3c3c"), + "group-selected-hover": QtGui.QColor("#555555") +} + + +class GroupItemDelegate(QtWidgets.QStyledItemDelegate): + """Generic delegate for instance header""" + + _item_icons_by_name_and_size = collections.defaultdict(dict) + + _minus_pixmaps = {} + _plus_pixmaps = {} + _path_stroker = None + + _item_pix_offset_ratio = 1.0 / 5.0 + _item_border_size = 1.0 / 7.0 + _group_pix_offset_ratio = 1.0 / 3.0 + _group_pix_stroke_size_ratio = 1.0 / 7.0 + + @classmethod + def _get_path_stroker(cls): + if cls._path_stroker is None: + path_stroker = QtGui.QPainterPathStroker() + path_stroker.setCapStyle(QtCore.Qt.RoundCap) + path_stroker.setJoinStyle(QtCore.Qt.RoundJoin) + + cls._path_stroker = path_stroker + return cls._path_stroker + + @classmethod + def _get_plus_pixmap(cls, size): + pix = cls._minus_pixmaps.get(size) + if pix is not None: + return pix + + pix = QtGui.QPixmap(size, size) + pix.fill(QtCore.Qt.transparent) + + offset = int(size * cls._group_pix_offset_ratio) + pnt_1 = QtCore.QPoint(offset, int(size / 2)) + pnt_2 = QtCore.QPoint(size - offset, int(size / 2)) + pnt_3 = QtCore.QPoint(int(size / 2), offset) + pnt_4 = QtCore.QPoint(int(size / 2), size - offset) + path_1 = QtGui.QPainterPath(pnt_1) + path_1.lineTo(pnt_2) + path_2 = QtGui.QPainterPath(pnt_3) + path_2.lineTo(pnt_4) + + path_stroker = cls._get_path_stroker() + path_stroker.setWidth(size * cls._group_pix_stroke_size_ratio) + stroked_path_1 = path_stroker.createStroke(path_1) + stroked_path_2 = path_stroker.createStroke(path_2) + + pix = QtGui.QPixmap(size, size) + pix.fill(QtCore.Qt.transparent) + + painter = QtGui.QPainter(pix) + painter.setRenderHint(QtGui.QPainter.Antialiasing) + painter.setPen(QtCore.Qt.transparent) + painter.setBrush(QtCore.Qt.white) + painter.drawPath(stroked_path_1) + painter.drawPath(stroked_path_2) + painter.end() + + cls._minus_pixmaps[size] = pix + + return pix + + @classmethod + def _get_minus_pixmap(cls, size): + pix = cls._plus_pixmaps.get(size) + if pix is not None: + return pix + + offset = int(size * cls._group_pix_offset_ratio) + pnt_1 = QtCore.QPoint(offset, int(size / 2)) + pnt_2 = QtCore.QPoint(size - offset, int(size / 2)) + path = QtGui.QPainterPath(pnt_1) + path.lineTo(pnt_2) + path_stroker = cls._get_path_stroker() + path_stroker.setWidth(size * cls._group_pix_stroke_size_ratio) + stroked_path = path_stroker.createStroke(path) + + pix = QtGui.QPixmap(size, size) + pix.fill(QtCore.Qt.transparent) + + painter = QtGui.QPainter(pix) + painter.setRenderHint(QtGui.QPainter.Antialiasing) + painter.setPen(QtCore.Qt.transparent) + painter.setBrush(QtCore.Qt.white) + painter.drawPath(stroked_path) + painter.end() + + cls._plus_pixmaps[size] = pix + + return pix + + @classmethod + def _get_icon_color(cls, name): + if name == "error": + return QtGui.QColor(colors["error"]) + return QtGui.QColor(QtCore.Qt.white) + + @classmethod + def _get_icon(cls, name, size): + icons_by_size = cls._item_icons_by_name_and_size[name] + if icons_by_size and size in icons_by_size: + return icons_by_size[size] + + offset = int(size * cls._item_pix_offset_ratio) + offset_size = size - (2 * offset) + pix = QtGui.QPixmap(size, size) + pix.fill(QtCore.Qt.transparent) + + painter = QtGui.QPainter(pix) + painter.setRenderHint(QtGui.QPainter.Antialiasing) + + draw_ellipse = True + if name == "error": + color = QtGui.QColor(colors["error"]) + painter.setPen(QtCore.Qt.NoPen) + painter.setBrush(color) + + elif name == "skipped": + color = QtGui.QColor(QtCore.Qt.white) + pen = QtGui.QPen(color) + pen.setWidth(int(size * cls._item_border_size)) + painter.setPen(pen) + painter.setBrush(QtCore.Qt.transparent) + + elif name == "passed": + color = QtGui.QColor(colors["ok"]) + painter.setPen(QtCore.Qt.NoPen) + painter.setBrush(color) + + elif name == "removed": + draw_ellipse = False + + offset = offset * 1.5 + p1 = QtCore.QPoint(offset, offset) + p2 = QtCore.QPoint(size - offset, size - offset) + p3 = QtCore.QPoint(offset, size - offset) + p4 = QtCore.QPoint(size - offset, offset) + + pen = QtGui.QPen(QtCore.Qt.white) + pen.setWidth(offset_size / 4) + pen.setCapStyle(QtCore.Qt.RoundCap) + painter.setPen(pen) + painter.setBrush(QtCore.Qt.transparent) + painter.drawLine(p1, p2) + painter.drawLine(p3, p4) + + else: + color = QtGui.QColor(QtCore.Qt.white) + painter.setPen(QtCore.Qt.NoPen) + painter.setBrush(color) + + if draw_ellipse: + painter.drawEllipse(offset, offset, offset_size, offset_size) + + painter.end() + + cls._item_icons_by_name_and_size[name][size] = pix + + return pix + + def paint(self, painter, option, index): + if index.data(ITEM_IS_GROUP_ROLE): + self.group_item_paint(painter, option, index) + else: + self.item_paint(painter, option, index) + + def item_paint(self, painter, option, index): + self.initStyleOption(option, index) + + widget = option.widget + if widget: + style = widget.style() + else: + style = QtWidgets.QApplicaion.style() + + style.proxy().drawPrimitive( + style.PE_PanelItemViewItem, option, painter, widget + ) + _rect = style.proxy().subElementRect( + style.SE_ItemViewItemText, option, widget + ) + bg_rect = QtCore.QRectF(option.rect) + bg_rect.setY(_rect.y()) + bg_rect.setHeight(_rect.height()) + + expander_rect = QtCore.QRectF(bg_rect) + expander_rect.setWidth(expander_rect.height() + 5) + + label_rect = QtCore.QRectF( + expander_rect.x() + expander_rect.width(), + expander_rect.y(), + bg_rect.width() - expander_rect.width(), + expander_rect.height() + ) + + icon_size = expander_rect.height() + if index.data(ITEM_ERRORED_ROLE): + expander_icon = self._get_icon("error", icon_size) + elif index.data(PLUGIN_SKIPPED_ROLE): + expander_icon = self._get_icon("skipped", icon_size) + elif index.data(PLUGIN_PASSED_ROLE): + expander_icon = self._get_icon("passed", icon_size) + elif index.data(INSTANCE_REMOVED_ROLE): + expander_icon = self._get_icon("removed", icon_size) + else: + expander_icon = self._get_icon("", icon_size) + + label = index.data(QtCore.Qt.DisplayRole) + label = option.fontMetrics.elidedText( + label, QtCore.Qt.ElideRight, label_rect.width() + ) + + painter.save() + # Draw icon + pix_point = QtCore.QPoint( + expander_rect.center().x() - int(expander_icon.width() / 2), + expander_rect.top() + ) + painter.drawPixmap(pix_point, expander_icon) + + # Draw label + painter.setFont(option.font) + painter.drawText(label_rect, QtCore.Qt.AlignVCenter, label) + + # Ok, we're done, tidy up. + painter.restore() + + def group_item_paint(self, painter, option, index): + """Paint text + _ + My label + """ + self.initStyleOption(option, index) + + widget = option.widget + if widget: + style = widget.style() + else: + style = QtWidgets.QApplicaion.style() + _rect = style.proxy().subElementRect( + style.SE_ItemViewItemText, option, widget + ) + + bg_rect = QtCore.QRectF(option.rect) + bg_rect.setY(_rect.y()) + bg_rect.setHeight(_rect.height()) + + expander_height = bg_rect.height() + expander_width = expander_height + 5 + expander_y_offset = expander_height % 2 + expander_height -= expander_y_offset + expander_rect = QtCore.QRectF( + bg_rect.x(), + bg_rect.y() + expander_y_offset, + expander_width, + expander_height + ) + + label_rect = QtCore.QRectF( + bg_rect.x() + expander_width, + bg_rect.y(), + bg_rect.width() - expander_width, + bg_rect.height() + ) + + bg_path = QtGui.QPainterPath() + radius = (bg_rect.height() / 2) - 0.01 + bg_path.addRoundedRect(bg_rect, radius, radius) + + painter.fillPath(bg_path, colors["group"]) + + selected = option.state & QtWidgets.QStyle.State_Selected + hovered = option.state & QtWidgets.QStyle.State_MouseOver + + if selected and hovered: + painter.fillPath(bg_path, colors["selected"]) + elif hovered: + painter.fillPath(bg_path, colors["hover"]) + + expanded = self.parent().isExpanded(index) + if expanded: + expander_icon = self._get_minus_pixmap(expander_height) + else: + expander_icon = self._get_plus_pixmap(expander_height) + + label = index.data(QtCore.Qt.DisplayRole) + label = option.fontMetrics.elidedText( + label, QtCore.Qt.ElideRight, label_rect.width() + ) + + # Maintain reference to state, so we can restore it once we're done + painter.save() + pix_point = QtCore.QPoint( + expander_rect.center().x() - int(expander_icon.width() / 2), + expander_rect.top() + ) + painter.drawPixmap(pix_point, expander_icon) + + # Draw label + painter.setFont(option.font) + painter.drawText(label_rect, QtCore.Qt.AlignVCenter, label) + + # Ok, we're done, tidy up. + painter.restore() diff --git a/openpype/tools/publisher/publish_report_viewer/model.py b/openpype/tools/publisher/publish_report_viewer/model.py new file mode 100644 index 0000000000..460d3e12d1 --- /dev/null +++ b/openpype/tools/publisher/publish_report_viewer/model.py @@ -0,0 +1,200 @@ +import uuid +from Qt import QtCore, QtGui + +import pyblish.api + +from .constants import ( + ITEM_ID_ROLE, + ITEM_IS_GROUP_ROLE, + ITEM_LABEL_ROLE, + ITEM_ERRORED_ROLE, + PLUGIN_SKIPPED_ROLE, + PLUGIN_PASSED_ROLE, + INSTANCE_REMOVED_ROLE +) + + +class InstancesModel(QtGui.QStandardItemModel): + def __init__(self, *args, **kwargs): + super(InstancesModel, self).__init__(*args, **kwargs) + + self._items_by_id = {} + self._plugin_items_by_id = {} + + def get_items_by_id(self): + return self._items_by_id + + def set_report(self, report_item): + self.clear() + self._items_by_id.clear() + self._plugin_items_by_id.clear() + + root_item = self.invisibleRootItem() + + families = set(report_item.instance_items_by_family.keys()) + families.remove(None) + all_families = list(sorted(families)) + all_families.insert(0, None) + + family_items = [] + for family in all_families: + items = [] + instance_items = report_item.instance_items_by_family[family] + all_removed = True + for instance_item in instance_items: + item = QtGui.QStandardItem(instance_item.label) + item.setData(instance_item.label, ITEM_LABEL_ROLE) + item.setData(instance_item.errored, ITEM_ERRORED_ROLE) + item.setData(instance_item.id, ITEM_ID_ROLE) + item.setData(instance_item.removed, INSTANCE_REMOVED_ROLE) + if all_removed and not instance_item.removed: + all_removed = False + item.setData(False, ITEM_IS_GROUP_ROLE) + items.append(item) + self._items_by_id[instance_item.id] = item + self._plugin_items_by_id[instance_item.id] = item + + if family is None: + family_items.extend(items) + continue + + family_item = QtGui.QStandardItem(family) + family_item.setData(family, ITEM_LABEL_ROLE) + family_item.setFlags(QtCore.Qt.ItemIsEnabled) + family_id = uuid.uuid4() + family_item.setData(family_id, ITEM_ID_ROLE) + family_item.setData(all_removed, INSTANCE_REMOVED_ROLE) + family_item.setData(True, ITEM_IS_GROUP_ROLE) + family_item.appendRows(items) + family_items.append(family_item) + self._items_by_id[family_id] = family_item + + root_item.appendRows(family_items) + + +class InstanceProxyModel(QtCore.QSortFilterProxyModel): + def __init__(self, *args, **kwargs): + super(InstanceProxyModel, self).__init__(*args, **kwargs) + + self._ignore_removed = True + + @property + def ignore_removed(self): + return self._ignore_removed + + def set_ignore_removed(self, value): + if value == self._ignore_removed: + return + self._ignore_removed = value + + if self.sourceModel(): + self.invalidateFilter() + + def filterAcceptsRow(self, row, parent): + source_index = self.sourceModel().index(row, 0, parent) + if self._ignore_removed and source_index.data(INSTANCE_REMOVED_ROLE): + return False + return True + + +class PluginsModel(QtGui.QStandardItemModel): + order_label_mapping = ( + (pyblish.api.CollectorOrder + 0.5, "Collect"), + (pyblish.api.ValidatorOrder + 0.5, "Validate"), + (pyblish.api.ExtractorOrder + 0.5, "Extract"), + (pyblish.api.IntegratorOrder + 0.5, "Integrate"), + (None, "Other") + ) + + def __init__(self, *args, **kwargs): + super(PluginsModel, self).__init__(*args, **kwargs) + + self._items_by_id = {} + self._plugin_items_by_id = {} + + def get_items_by_id(self): + return self._items_by_id + + def set_report(self, report_item): + self.clear() + self._items_by_id.clear() + self._plugin_items_by_id.clear() + + root_item = self.invisibleRootItem() + + labels_iter = iter(self.order_label_mapping) + cur_order, cur_label = next(labels_iter) + cur_plugin_items = [] + + plugin_items_by_group_labels = [] + plugin_items_by_group_labels.append((cur_label, cur_plugin_items)) + for plugin_id in report_item.plugins_id_order: + plugin_item = report_item.plugins_items_by_id[plugin_id] + if cur_order is not None and plugin_item.order >= cur_order: + cur_order, cur_label = next(labels_iter) + cur_plugin_items = [] + plugin_items_by_group_labels.append( + (cur_label, cur_plugin_items) + ) + + cur_plugin_items.append(plugin_item) + + group_items = [] + for group_label, plugin_items in plugin_items_by_group_labels: + group_id = uuid.uuid4() + group_item = QtGui.QStandardItem(group_label) + group_item.setData(group_label, ITEM_LABEL_ROLE) + group_item.setData(group_id, ITEM_ID_ROLE) + group_item.setData(True, ITEM_IS_GROUP_ROLE) + group_item.setFlags(QtCore.Qt.ItemIsEnabled) + group_items.append(group_item) + + self._items_by_id[group_id] = group_item + + if not plugin_items: + continue + + items = [] + for plugin_item in plugin_items: + item = QtGui.QStandardItem(plugin_item.label) + item.setData(False, ITEM_IS_GROUP_ROLE) + item.setData(plugin_item.label, ITEM_LABEL_ROLE) + item.setData(plugin_item.id, ITEM_ID_ROLE) + item.setData(plugin_item.skipped, PLUGIN_SKIPPED_ROLE) + item.setData(plugin_item.passed, PLUGIN_PASSED_ROLE) + item.setData(plugin_item.errored, ITEM_ERRORED_ROLE) + items.append(item) + self._items_by_id[plugin_item.id] = item + self._plugin_items_by_id[plugin_item.id] = item + group_item.appendRows(items) + + root_item.appendRows(group_items) + + +class PluginProxyModel(QtCore.QSortFilterProxyModel): + def __init__(self, *args, **kwargs): + super(PluginProxyModel, self).__init__(*args, **kwargs) + + self._ignore_skipped = True + + @property + def ignore_skipped(self): + return self._ignore_skipped + + def set_ignore_skipped(self, value): + if value == self._ignore_skipped: + return + self._ignore_skipped = value + + if self.sourceModel(): + self.invalidateFilter() + + def filterAcceptsRow(self, row, parent): + model = self.sourceModel() + source_index = model.index(row, 0, parent) + if source_index.data(ITEM_IS_GROUP_ROLE): + return model.rowCount(source_index) > 0 + + if self._ignore_skipped and source_index.data(PLUGIN_SKIPPED_ROLE): + return False + return True diff --git a/openpype/tools/publisher/publish_report_viewer/widgets.py b/openpype/tools/publisher/publish_report_viewer/widgets.py new file mode 100644 index 0000000000..24f1d33d0e --- /dev/null +++ b/openpype/tools/publisher/publish_report_viewer/widgets.py @@ -0,0 +1,334 @@ +import copy +import uuid + +from Qt import QtWidgets, QtCore + +from openpype.widgets.nice_checkbox import NiceCheckbox + +from .constants import ( + ITEM_ID_ROLE, + ITEM_IS_GROUP_ROLE +) +from .delegates import GroupItemDelegate +from .model import ( + InstancesModel, + InstanceProxyModel, + PluginsModel, + PluginProxyModel +) + + +class PluginItem: + def __init__(self, plugin_data): + self._id = uuid.uuid4() + + self.name = plugin_data["name"] + self.label = plugin_data["label"] + self.order = plugin_data["order"] + self.skipped = plugin_data["skipped"] + self.passed = plugin_data["passed"] + + logs = [] + errored = False + for instance_data in plugin_data["instances_data"]: + for log_item in instance_data["logs"]: + if not errored: + errored = log_item["type"] == "error" + logs.append(copy.deepcopy(log_item)) + + self.errored = errored + self.logs = logs + + @property + def id(self): + return self._id + + +class InstanceItem: + def __init__(self, instance_id, instance_data, report_data): + self._id = instance_id + self.label = instance_data.get("label") or instance_data.get("name") + self.family = instance_data.get("family") + self.removed = not instance_data.get("exists", True) + + logs = [] + for plugin_data in report_data["plugins_data"]: + for instance_data_item in plugin_data["instances_data"]: + if instance_data_item["id"] == self._id: + logs.extend(copy.deepcopy(instance_data_item["logs"])) + + errored = False + for log in logs: + if log["type"] == "error": + errored = True + break + + self.errored = errored + self.logs = logs + + @property + def id(self): + return self._id + + +class PublishReport: + def __init__(self, report_data): + data = copy.deepcopy(report_data) + + context_data = data["context"] + context_data["name"] = "context" + context_data["label"] = context_data["label"] or "Context" + + instance_items_by_id = {} + instance_items_by_family = {} + context_item = InstanceItem(None, context_data, data) + instance_items_by_id[context_item.id] = context_item + instance_items_by_family[context_item.family] = [context_item] + + for instance_id, instance_data in data["instances"].items(): + item = InstanceItem(instance_id, instance_data, data) + instance_items_by_id[item.id] = item + if item.family not in instance_items_by_family: + instance_items_by_family[item.family] = [] + instance_items_by_family[item.family].append(item) + + all_logs = [] + plugins_items_by_id = {} + plugins_id_order = [] + for plugin_data in data["plugins_data"]: + item = PluginItem(plugin_data) + plugins_id_order.append(item.id) + plugins_items_by_id[item.id] = item + all_logs.extend(copy.deepcopy(item.logs)) + + self.instance_items_by_id = instance_items_by_id + self.instance_items_by_family = instance_items_by_family + + self.plugins_id_order = plugins_id_order + self.plugins_items_by_id = plugins_items_by_id + + self.logs = all_logs + + +class DetailsWidget(QtWidgets.QWidget): + def __init__(self, parent): + super(DetailsWidget, self).__init__(parent) + + output_widget = QtWidgets.QPlainTextEdit(self) + output_widget.setTextInteractionFlags(QtCore.Qt.TextBrowserInteraction) + output_widget.setObjectName("PublishLogConsole") + + layout = QtWidgets.QVBoxLayout(self) + layout.setContentsMargins(0, 0, 0, 0) + layout.addWidget(output_widget) + + self._output_widget = output_widget + + def clear(self): + self._output_widget.setPlainText("") + + def set_logs(self, logs): + lines = [] + for log in logs: + if log["type"] == "record": + message = "{}: {}".format(log["levelname"], log["msg"]) + + lines.append(message) + exc_info = log["exc_info"] + if exc_info: + lines.append(exc_info) + + elif log["type"] == "error": + lines.append(log["traceback"]) + + else: + print(log["type"]) + + text = "\n".join(lines) + self._output_widget.setPlainText(text) + + +class PublishReportViewerWidget(QtWidgets.QWidget): + def __init__(self, parent=None): + super(PublishReportViewerWidget, self).__init__(parent) + + instances_model = InstancesModel() + instances_proxy = InstanceProxyModel() + instances_proxy.setSourceModel(instances_model) + + plugins_model = PluginsModel() + plugins_proxy = PluginProxyModel() + plugins_proxy.setSourceModel(plugins_model) + + removed_instances_check = NiceCheckbox(parent=self) + removed_instances_check.setChecked(instances_proxy.ignore_removed) + removed_instances_label = QtWidgets.QLabel( + "Hide removed instances", self + ) + + removed_instances_layout = QtWidgets.QHBoxLayout() + removed_instances_layout.setContentsMargins(0, 0, 0, 0) + removed_instances_layout.addWidget(removed_instances_check, 0) + removed_instances_layout.addWidget(removed_instances_label, 1) + + instances_view = QtWidgets.QTreeView(self) + instances_view.setObjectName("PublishDetailViews") + instances_view.setModel(instances_proxy) + instances_view.setIndentation(0) + instances_view.setHeaderHidden(True) + instances_view.setEditTriggers(QtWidgets.QTreeView.NoEditTriggers) + instances_view.setExpandsOnDoubleClick(False) + + instances_delegate = GroupItemDelegate(instances_view) + instances_view.setItemDelegate(instances_delegate) + + skipped_plugins_check = NiceCheckbox(parent=self) + skipped_plugins_check.setChecked(plugins_proxy.ignore_skipped) + skipped_plugins_label = QtWidgets.QLabel("Hide skipped plugins", self) + + skipped_plugins_layout = QtWidgets.QHBoxLayout() + skipped_plugins_layout.setContentsMargins(0, 0, 0, 0) + skipped_plugins_layout.addWidget(skipped_plugins_check, 0) + skipped_plugins_layout.addWidget(skipped_plugins_label, 1) + + plugins_view = QtWidgets.QTreeView(self) + plugins_view.setObjectName("PublishDetailViews") + plugins_view.setModel(plugins_proxy) + plugins_view.setIndentation(0) + plugins_view.setHeaderHidden(True) + plugins_view.setEditTriggers(QtWidgets.QTreeView.NoEditTriggers) + plugins_view.setExpandsOnDoubleClick(False) + + plugins_delegate = GroupItemDelegate(plugins_view) + plugins_view.setItemDelegate(plugins_delegate) + + details_widget = DetailsWidget(self) + + layout = QtWidgets.QGridLayout(self) + # Row 1 + layout.addLayout(removed_instances_layout, 0, 0) + layout.addLayout(skipped_plugins_layout, 0, 1) + # Row 2 + layout.addWidget(instances_view, 1, 0) + layout.addWidget(plugins_view, 1, 1) + layout.addWidget(details_widget, 1, 2) + + layout.setColumnStretch(2, 1) + + instances_view.selectionModel().selectionChanged.connect( + self._on_instance_change + ) + instances_view.clicked.connect(self._on_instance_view_clicked) + plugins_view.clicked.connect(self._on_plugin_view_clicked) + plugins_view.selectionModel().selectionChanged.connect( + self._on_plugin_change + ) + + skipped_plugins_check.stateChanged.connect( + self._on_skipped_plugin_check + ) + removed_instances_check.stateChanged.connect( + self._on_removed_instances_check + ) + + self._ignore_selection_changes = False + self._report_item = None + self._details_widget = details_widget + + self._removed_instances_check = removed_instances_check + self._instances_view = instances_view + self._instances_model = instances_model + self._instances_proxy = instances_proxy + + self._instances_delegate = instances_delegate + self._plugins_delegate = plugins_delegate + + self._skipped_plugins_check = skipped_plugins_check + self._plugins_view = plugins_view + self._plugins_model = plugins_model + self._plugins_proxy = plugins_proxy + + def _on_instance_view_clicked(self, index): + if not index.isValid() or not index.data(ITEM_IS_GROUP_ROLE): + return + + if self._instances_view.isExpanded(index): + self._instances_view.collapse(index) + else: + self._instances_view.expand(index) + + def _on_plugin_view_clicked(self, index): + if not index.isValid() or not index.data(ITEM_IS_GROUP_ROLE): + return + + if self._plugins_view.isExpanded(index): + self._plugins_view.collapse(index) + else: + self._plugins_view.expand(index) + + def set_report(self, report_data): + self._ignore_selection_changes = True + + report_item = PublishReport(report_data) + self._report_item = report_item + + self._instances_model.set_report(report_item) + self._plugins_model.set_report(report_item) + self._details_widget.set_logs(report_item.logs) + + self._ignore_selection_changes = False + + def _on_instance_change(self, *_args): + if self._ignore_selection_changes: + return + + valid_index = None + for index in self._instances_view.selectedIndexes(): + if index.isValid(): + valid_index = index + break + + if valid_index is None: + return + + if self._plugins_view.selectedIndexes(): + self._ignore_selection_changes = True + self._plugins_view.selectionModel().clearSelection() + self._ignore_selection_changes = False + + plugin_id = valid_index.data(ITEM_ID_ROLE) + instance_item = self._report_item.instance_items_by_id[plugin_id] + self._details_widget.set_logs(instance_item.logs) + + def _on_plugin_change(self, *_args): + if self._ignore_selection_changes: + return + + valid_index = None + for index in self._plugins_view.selectedIndexes(): + if index.isValid(): + valid_index = index + break + + if valid_index is None: + self._details_widget.set_logs(self._report_item.logs) + return + + if self._instances_view.selectedIndexes(): + self._ignore_selection_changes = True + self._instances_view.selectionModel().clearSelection() + self._ignore_selection_changes = False + + plugin_id = valid_index.data(ITEM_ID_ROLE) + plugin_item = self._report_item.plugins_items_by_id[plugin_id] + self._details_widget.set_logs(plugin_item.logs) + + def _on_skipped_plugin_check(self): + self._plugins_proxy.set_ignore_skipped( + self._skipped_plugins_check.isChecked() + ) + + def _on_removed_instances_check(self): + self._instances_proxy.set_ignore_removed( + self._removed_instances_check.isChecked() + ) diff --git a/openpype/tools/publisher/publish_report_viewer/window.py b/openpype/tools/publisher/publish_report_viewer/window.py new file mode 100644 index 0000000000..7a0fef7d91 --- /dev/null +++ b/openpype/tools/publisher/publish_report_viewer/window.py @@ -0,0 +1,29 @@ +from Qt import QtWidgets + +from openpype import style +if __package__: + from .widgets import PublishReportViewerWidget +else: + from widgets import PublishReportViewerWidget + + +class PublishReportViewerWindow(QtWidgets.QWidget): + # TODO add buttons to be able load report file or paste content of report + default_width = 1200 + default_height = 600 + + def __init__(self, parent=None): + super(PublishReportViewerWindow, self).__init__(parent) + + main_widget = PublishReportViewerWidget(self) + + layout = QtWidgets.QHBoxLayout(self) + layout.addWidget(main_widget) + + self._main_widget = main_widget + + self.resize(self.default_width, self.default_height) + self.setStyleSheet(style.load_stylesheet()) + + def set_report(self, report_data): + self._main_widget.set_report(report_data) diff --git a/openpype/tools/publisher/widgets/__init__.py b/openpype/tools/publisher/widgets/__init__.py new file mode 100644 index 0000000000..9b22a6cf25 --- /dev/null +++ b/openpype/tools/publisher/widgets/__init__.py @@ -0,0 +1,64 @@ +from .icons import ( + get_icon_path, + get_pixmap, + get_icon +) +from .border_label_widget import ( + BorderedLabelWidget +) +from .widgets import ( + SubsetAttributesWidget, + + PixmapLabel, + + StopBtn, + ResetBtn, + ValidateBtn, + PublishBtn, + + CreateInstanceBtn, + RemoveInstanceBtn, + ChangeViewBtn +) +from .publish_widget import ( + PublishFrame +) +from .create_dialog import ( + CreateDialog +) + +from .card_view_widgets import ( + InstanceCardView +) + +from .list_view_widgets import ( + InstanceListView +) + + +__all__ = ( + "get_icon_path", + "get_pixmap", + "get_icon", + + "SubsetAttributesWidget", + "BorderedLabelWidget", + + "PixmapLabel", + + "StopBtn", + "ResetBtn", + "ValidateBtn", + "PublishBtn", + + "CreateInstanceBtn", + "RemoveInstanceBtn", + "ChangeViewBtn", + + "PublishFrame", + + "CreateDialog", + + "InstanceCardView", + "InstanceListView", +) diff --git a/openpype/tools/publisher/widgets/border_label_widget.py b/openpype/tools/publisher/widgets/border_label_widget.py new file mode 100644 index 0000000000..3d49af410a --- /dev/null +++ b/openpype/tools/publisher/widgets/border_label_widget.py @@ -0,0 +1,255 @@ +# -*- coding: utf-8 -*- + +from Qt import QtWidgets, QtCore, QtGui + +from openpype.style import get_objected_colors + + +class _VLineWidget(QtWidgets.QWidget): + """Widget drawing 1px wide vertical line. + + ``` β”‚ ``` + + Line is drawn in the middle of widget. + + It is expected that parent widget will set width. + """ + def __init__(self, color, left, parent): + super(_VLineWidget, self).__init__(parent) + self._color = color + self._left = left + + def paintEvent(self, event): + if not self.isVisible(): + return + + if self._left: + pos_x = 0 + else: + pos_x = self.width() + painter = QtGui.QPainter(self) + painter.setRenderHints( + painter.Antialiasing + | painter.SmoothPixmapTransform + ) + if self._color: + pen = QtGui.QPen(self._color) + else: + pen = painter.pen() + pen.setWidth(1) + painter.setPen(pen) + painter.setBrush(QtCore.Qt.transparent) + painter.drawLine(pos_x, 0, pos_x, self.height()) + painter.end() + + +class _HBottomLineWidget(QtWidgets.QWidget): + """Widget drawing 1px wide vertical line with side lines going upwards. + + ```β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜``` + + Corners may have curve set by radius (`set_radius`). Radius should expect + height of widget. + + Bottom line is drawed at the bottom of widget. If radius is 0 then height + of widget should be 1px. + + It is expected that parent widget will set height and radius. + """ + def __init__(self, color, parent): + super(_HBottomLineWidget, self).__init__(parent) + self._color = color + self._radius = 0 + + def set_radius(self, radius): + self._radius = radius + + def paintEvent(self, event): + if not self.isVisible(): + return + + rect = QtCore.QRect( + 0, -self._radius, self.width(), self.height() + self._radius + ) + painter = QtGui.QPainter(self) + painter.setRenderHints( + painter.Antialiasing + | painter.SmoothPixmapTransform + ) + if self._color: + pen = QtGui.QPen(self._color) + else: + pen = painter.pen() + pen.setWidth(1) + painter.setPen(pen) + painter.setBrush(QtCore.Qt.transparent) + painter.drawRoundedRect(rect, self._radius, self._radius) + painter.end() + + +class _HTopCornerLineWidget(QtWidgets.QWidget): + """Widget drawing 1px wide horizontal line with side line going downwards. + + ```────────┐``` + or + ```β”Œβ”€β”€β”€β”€β”€β”€β”€``` + + Horizontal line is drawed in the middle of widget. + + Widget represents left or right corner. Corner may have curve set by + radius (`set_radius`). Radius should expect height of widget (maximum half + height of widget). + + It is expected that parent widget will set height and radius. + """ + def __init__(self, color, left_side, parent): + super(_HTopCornerLineWidget, self).__init__(parent) + self._left_side = left_side + self._color = color + self._radius = 0 + + def set_radius(self, radius): + self._radius = radius + + def paintEvent(self, event): + if not self.isVisible(): + return + + pos_y = self.height() / 2 + + if self._left_side: + rect = QtCore.QRect( + 0, pos_y, self.width() + self._radius, self.height() + ) + else: + rect = QtCore.QRect( + -self._radius, + pos_y, + self.width() + self._radius, + self.height() + ) + + painter = QtGui.QPainter(self) + painter.setRenderHints( + painter.Antialiasing + | painter.SmoothPixmapTransform + ) + if self._color: + pen = QtGui.QPen(self._color) + else: + pen = painter.pen() + pen.setWidth(1) + painter.setPen(pen) + painter.setBrush(QtCore.Qt.transparent) + painter.drawRoundedRect(rect, self._radius, self._radius) + painter.end() + + +class BorderedLabelWidget(QtWidgets.QFrame): + """Draws borders around widget with label in the middle of top. + + β”Œβ”€β”€β”€β”€β”€β”€β”€ Label ────────┐ + β”‚ β”‚ + β”‚ β”‚ + β”‚ CONTENT β”‚ + β”‚ β”‚ + β”‚ β”‚ + β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ + """ + def __init__(self, label, parent): + super(BorderedLabelWidget, self).__init__(parent) + colors_data = get_objected_colors() + color_value = colors_data.get("border") + color = None + if color_value: + color = color_value.get_qcolor() + + top_left_w = _HTopCornerLineWidget(color, True, self) + top_right_w = _HTopCornerLineWidget(color, False, self) + + label_widget = QtWidgets.QLabel(label, self) + + top_layout = QtWidgets.QHBoxLayout() + top_layout.setContentsMargins(0, 0, 0, 0) + top_layout.setSpacing(5) + top_layout.addWidget(top_left_w, 1) + top_layout.addWidget(label_widget, 0) + top_layout.addWidget(top_right_w, 1) + + left_w = _VLineWidget(color, True, self) + right_w = _VLineWidget(color, False, self) + + bottom_w = _HBottomLineWidget(color, self) + + center_layout = QtWidgets.QHBoxLayout() + center_layout.setContentsMargins(5, 5, 5, 5) + + layout = QtWidgets.QGridLayout(self) + layout.setContentsMargins(0, 0, 0, 0) + layout.setSpacing(0) + + layout.addLayout(top_layout, 0, 0, 1, 3) + + layout.addWidget(left_w, 1, 0) + layout.addLayout(center_layout, 1, 1) + layout.addWidget(right_w, 1, 2) + + layout.addWidget(bottom_w, 2, 0, 1, 3) + + layout.setColumnStretch(1, 1) + layout.setRowStretch(1, 1) + + self._widget = None + + self._radius = 0 + + self._top_left_w = top_left_w + self._top_right_w = top_right_w + self._left_w = left_w + self._right_w = right_w + self._bottom_w = bottom_w + self._label_widget = label_widget + self._center_layout = center_layout + + def set_content_margins(self, value): + """Set margins around content.""" + self._center_layout.setContentsMargins( + value, value, value, value + ) + + def showEvent(self, event): + super(BorderedLabelWidget, self).showEvent(event) + + height = self._label_widget.height() + radius = (height + (height % 2)) / 2 + self._radius = radius + + side_width = 1 + radius + # Dont't use fixed width/height as that would set also set + # the other size (When fixed width is set then is also set + # fixed height). + self._left_w.setMinimumWidth(side_width) + self._left_w.setMaximumWidth(side_width) + self._right_w.setMinimumWidth(side_width) + self._right_w.setMaximumWidth(side_width) + self._bottom_w.setMinimumHeight(radius) + self._bottom_w.setMaximumHeight(radius) + self._bottom_w.set_radius(radius) + self._top_right_w.set_radius(radius) + self._top_left_w.set_radius(radius) + if self._widget: + self._widget.update() + + def set_center_widget(self, widget): + """Set content widget and add it to center.""" + while self._center_layout.count(): + item = self._center_layout.takeAt(0) + widget = item.widget() + if widget: + widget.deleteLater() + + self._widget = widget + if isinstance(widget, QtWidgets.QLayout): + self._center_layout.addLayout(widget) + else: + self._center_layout.addWidget(widget) diff --git a/openpype/tools/publisher/widgets/card_view_widgets.py b/openpype/tools/publisher/widgets/card_view_widgets.py new file mode 100644 index 0000000000..271d06e94c --- /dev/null +++ b/openpype/tools/publisher/widgets/card_view_widgets.py @@ -0,0 +1,539 @@ +# -*- coding: utf-8 -*- +"""Card view instance with more information about each instance. + +Instances are grouped under groups. Groups are defined by `creator_label` +attribute on instance (Group defined by creator). + +Only one item can be selected at a time. + +``` + : Icon. Can have Warning icon when context is not right +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚ Options β”‚ +β”‚ ────────── β”‚ +β”‚ [x]β”‚ +β”‚ [x]β”‚ +β”‚ ────────── β”‚ +β”‚ [x]β”‚ +β”‚ ... β”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ +``` +""" + +import re +import collections + +from Qt import QtWidgets, QtCore + +from openpype.widgets.nice_checkbox import NiceCheckbox + +from .widgets import ( + AbstractInstanceView, + ContextWarningLabel, + ClickableFrame, + IconValuePixmapLabel, + TransparentPixmapLabel +) +from ..constants import ( + CONTEXT_ID, + CONTEXT_LABEL +) + + +class GroupWidget(QtWidgets.QWidget): + """Widget wrapping instances under group.""" + selected = QtCore.Signal(str, str) + active_changed = QtCore.Signal() + removed_selected = QtCore.Signal() + + def __init__(self, group_name, group_icons, parent): + super(GroupWidget, self).__init__(parent) + + label_widget = QtWidgets.QLabel(group_name, self) + + line_widget = QtWidgets.QWidget(self) + line_widget.setObjectName("Separator") + line_widget.setMinimumHeight(2) + line_widget.setMaximumHeight(2) + + label_layout = QtWidgets.QHBoxLayout() + label_layout.setAlignment(QtCore.Qt.AlignVCenter) + label_layout.setSpacing(10) + label_layout.setContentsMargins(0, 0, 0, 0) + label_layout.addWidget(label_widget, 0) + label_layout.addWidget(line_widget, 1) + + layout = QtWidgets.QVBoxLayout(self) + layout.setContentsMargins(0, 0, 0, 0) + layout.addLayout(label_layout, 0) + + self._group = group_name + self._group_icons = group_icons + + self._widgets_by_id = {} + + self._label_widget = label_widget + self._content_layout = layout + + def get_widget_by_instance_id(self, instance_id): + """Get instance widget by it's id.""" + return self._widgets_by_id.get(instance_id) + + def update_instance_values(self): + """Trigger update on instance widgets.""" + for widget in self._widgets_by_id.values(): + widget.update_instance_values() + + def confirm_remove_instance_id(self, instance_id): + """Delete widget by instance id.""" + widget = self._widgets_by_id.pop(instance_id) + widget.setVisible(False) + self._content_layout.removeWidget(widget) + widget.deleteLater() + + def update_instances(self, instances): + """Update instances for the group. + + Args: + instances(list): List of instances in + CreateContext. + """ + # Store instances by id and by subset name + instances_by_id = {} + instances_by_subset_name = collections.defaultdict(list) + for instance in instances: + instances_by_id[instance.id] = instance + subset_name = instance["subset"] + instances_by_subset_name[subset_name].append(instance) + + # Remove instance widgets that are not in passed instances + for instance_id in tuple(self._widgets_by_id.keys()): + if instance_id in instances_by_id: + continue + + widget = self._widgets_by_id.pop(instance_id) + if widget.is_selected: + self.removed_selected.emit() + + widget.setVisible(False) + self._content_layout.removeWidget(widget) + widget.deleteLater() + + # Sort instances by subset name + sorted_subset_names = list(sorted(instances_by_subset_name.keys())) + # Add new instances to widget + widget_idx = 1 + for subset_names in sorted_subset_names: + for instance in instances_by_subset_name[subset_names]: + if instance.id in self._widgets_by_id: + widget = self._widgets_by_id[instance.id] + widget.update_instance(instance) + else: + group_icon = self._group_icons[instance.creator_identifier] + widget = InstanceCardWidget( + instance, group_icon, self + ) + widget.selected.connect(self.selected) + widget.active_changed.connect(self.active_changed) + self._widgets_by_id[instance.id] = widget + self._content_layout.insertWidget(widget_idx, widget) + widget_idx += 1 + + +class CardWidget(ClickableFrame): + """Clickable card used as bigger button.""" + selected = QtCore.Signal(str, str) + # Group identifier of card + # - this must be set because if send when mouse is released with card id + _group_identifier = None + + def __init__(self, parent): + super(CardWidget, self).__init__(parent) + self.setObjectName("CardViewWidget") + + self._selected = False + self._id = None + + @property + def is_selected(self): + """Is card selected.""" + return self._selected + + def set_selected(self, selected): + """Set card as selected.""" + if selected == self._selected: + return + self._selected = selected + state = "selected" if selected else "" + self.setProperty("state", state) + self.style().polish(self) + + def _mouse_release_callback(self): + """Trigger selected signal.""" + self.selected.emit(self._id, self._group_identifier) + + +class ContextCardWidget(CardWidget): + """Card for global context. + + Is not visually under group widget and is always at the top of card view. + """ + def __init__(self, parent): + super(ContextCardWidget, self).__init__(parent) + + self._id = CONTEXT_ID + self._group_identifier = "" + + icon_widget = TransparentPixmapLabel(self) + icon_widget.setObjectName("FamilyIconLabel") + + label_widget = QtWidgets.QLabel(CONTEXT_LABEL, self) + + icon_layout = QtWidgets.QHBoxLayout() + icon_layout.setContentsMargins(5, 5, 5, 5) + icon_layout.addWidget(icon_widget) + + layout = QtWidgets.QHBoxLayout(self) + layout.setContentsMargins(0, 5, 10, 5) + layout.addLayout(icon_layout, 0) + layout.addWidget(label_widget, 1) + + self._icon_widget = icon_widget + self._label_widget = label_widget + + +class InstanceCardWidget(CardWidget): + """Card widget representing instance.""" + active_changed = QtCore.Signal() + + def __init__(self, instance, group_icon, parent): + super(InstanceCardWidget, self).__init__(parent) + + self._id = instance.id + self._group_identifier = instance.creator_label + self._group_icon = group_icon + + self.instance = instance + + self._last_subset_name = None + self._last_variant = None + + icon_widget = IconValuePixmapLabel(group_icon, self) + icon_widget.setObjectName("FamilyIconLabel") + context_warning = ContextWarningLabel(self) + + icon_layout = QtWidgets.QHBoxLayout() + icon_layout.setContentsMargins(10, 5, 5, 5) + icon_layout.addWidget(icon_widget) + icon_layout.addWidget(context_warning) + + label_widget = QtWidgets.QLabel(self) + active_checkbox = NiceCheckbox(parent=self) + + expand_btn = QtWidgets.QToolButton(self) + # Not yet implemented + expand_btn.setVisible(False) + expand_btn.setObjectName("ArrowBtn") + expand_btn.setArrowType(QtCore.Qt.DownArrow) + expand_btn.setMaximumWidth(14) + expand_btn.setEnabled(False) + + detail_widget = QtWidgets.QWidget(self) + detail_widget.setVisible(False) + self.detail_widget = detail_widget + + top_layout = QtWidgets.QHBoxLayout() + top_layout.addLayout(icon_layout, 0) + top_layout.addWidget(label_widget, 1) + top_layout.addWidget(context_warning, 0) + top_layout.addWidget(active_checkbox, 0) + top_layout.addWidget(expand_btn, 0) + + layout = QtWidgets.QHBoxLayout(self) + layout.setContentsMargins(0, 5, 10, 5) + layout.addLayout(top_layout) + layout.addWidget(detail_widget) + + active_checkbox.setAttribute(QtCore.Qt.WA_TranslucentBackground) + expand_btn.setAttribute(QtCore.Qt.WA_TranslucentBackground) + + active_checkbox.stateChanged.connect(self._on_active_change) + expand_btn.clicked.connect(self._on_expend_clicked) + + self._icon_widget = icon_widget + self._label_widget = label_widget + self._context_warning = context_warning + self._active_checkbox = active_checkbox + self._expand_btn = expand_btn + + self.update_instance_values() + + def set_active(self, new_value): + """Set instance as active.""" + checkbox_value = self._active_checkbox.isChecked() + instance_value = self.instance["active"] + + # First change instance value and them change checkbox + # - prevent to trigger `active_changed` signal + if instance_value != new_value: + self.instance["active"] = new_value + + if checkbox_value != new_value: + self._active_checkbox.setChecked(new_value) + + def update_instance(self, instance): + """Update instance object and update UI.""" + self.instance = instance + self.update_instance_values() + + def _validate_context(self): + valid = self.instance.has_valid_context + self._icon_widget.setVisible(valid) + self._context_warning.setVisible(not valid) + + def _update_subset_name(self): + variant = self.instance["variant"] + subset_name = self.instance["subset"] + if ( + variant == self._last_variant + and subset_name == self._last_subset_name + ): + return + + self._last_variant = variant + self._last_subset_name = subset_name + # Make `variant` bold + found_parts = set(re.findall(variant, subset_name, re.IGNORECASE)) + if found_parts: + for part in found_parts: + replacement = "{}".format(part) + subset_name = subset_name.replace(part, replacement) + + self._label_widget.setText(subset_name) + # HTML text will cause that label start catch mouse clicks + # - disabling with changing interaction flag + self._label_widget.setTextInteractionFlags( + QtCore.Qt.NoTextInteraction + ) + + def update_instance_values(self): + """Update instance data""" + self._update_subset_name() + self.set_active(self.instance["active"]) + self._validate_context() + + def _set_expanded(self, expanded=None): + if expanded is None: + expanded = not self.detail_widget.isVisible() + self.detail_widget.setVisible(expanded) + + def _on_active_change(self): + new_value = self._active_checkbox.isChecked() + old_value = self.instance["active"] + if new_value == old_value: + return + + self.instance["active"] = new_value + self.active_changed.emit() + + def _on_expend_clicked(self): + self._set_expanded() + + +class InstanceCardView(AbstractInstanceView): + """Publish access to card view. + + Wrapper of all widgets in card view. + """ + def __init__(self, controller, parent): + super(InstanceCardView, self).__init__(parent) + + self.controller = controller + + scroll_area = QtWidgets.QScrollArea(self) + scroll_area.setWidgetResizable(True) + scroll_area.setHorizontalScrollBarPolicy(QtCore.Qt.ScrollBarAlwaysOff) + scroll_area.setVerticalScrollBarPolicy(QtCore.Qt.ScrollBarAsNeeded) + scrollbar_bg = scroll_area.verticalScrollBar().parent() + if scrollbar_bg: + scrollbar_bg.setAttribute(QtCore.Qt.WA_TranslucentBackground) + scroll_area.setViewportMargins(0, 0, 0, 0) + + content_widget = QtWidgets.QWidget(scroll_area) + + scroll_area.setWidget(content_widget) + + content_layout = QtWidgets.QVBoxLayout(content_widget) + content_layout.setContentsMargins(0, 0, 0, 0) + content_layout.addStretch(1) + + layout = QtWidgets.QVBoxLayout(self) + layout.setContentsMargins(0, 0, 0, 0) + layout.addWidget(scroll_area) + + self._scroll_area = scroll_area + self._content_layout = content_layout + self._content_widget = content_widget + + self._widgets_by_group = {} + self._context_widget = None + + self._selected_group = None + self._selected_instance_id = None + + self.setSizePolicy( + QtWidgets.QSizePolicy.Minimum, + self.sizePolicy().verticalPolicy() + ) + + def sizeHint(self): + """Modify sizeHint based on visibility of scroll bars.""" + # Calculate width hint by content widget and verticall scroll bar + scroll_bar = self._scroll_area.verticalScrollBar() + width = ( + self._content_widget.sizeHint().width() + + scroll_bar.sizeHint().width() + ) + + result = super(InstanceCardView, self).sizeHint() + result.setWidth(width) + return result + + def _get_selected_widget(self): + if self._selected_instance_id == CONTEXT_ID: + return self._context_widget + + group_widget = self._widgets_by_group.get( + self._selected_group + ) + if group_widget is not None: + widget = group_widget.get_widget_by_instance_id( + self._selected_instance_id + ) + if widget is not None: + return widget + + return None + + def refresh(self): + """Refresh instances in view based on CreatedContext.""" + # Create context item if is not already existing + # - this must be as first thing to do as context item should be at the + # top + if self._context_widget is None: + widget = ContextCardWidget(self._content_widget) + widget.selected.connect(self._on_widget_selection) + + self._context_widget = widget + + self.selection_changed.emit() + self._content_layout.insertWidget(0, widget) + + self.select_item(CONTEXT_ID, None) + + # Prepare instances by group and identifiers by group + instances_by_group = collections.defaultdict(list) + identifiers_by_group = collections.defaultdict(set) + for instance in self.controller.instances: + group_name = instance.creator_label + instances_by_group[group_name].append(instance) + identifiers_by_group[group_name].add( + instance.creator_identifier + ) + + # Remove groups that were not found in apassed instances + for group_name in tuple(self._widgets_by_group.keys()): + if group_name in instances_by_group: + continue + + if group_name == self._selected_group: + self._on_remove_selected() + widget = self._widgets_by_group.pop(group_name) + widget.setVisible(False) + self._content_layout.removeWidget(widget) + widget.deleteLater() + + # Sort groups + sorted_group_names = list(sorted(instances_by_group.keys())) + # Keep track of widget indexes + # - we start with 1 because Context item as at the top + widget_idx = 1 + for group_name in sorted_group_names: + if group_name in self._widgets_by_group: + group_widget = self._widgets_by_group[group_name] + else: + group_icons = { + idenfier: self.controller.get_icon_for_family(idenfier) + for idenfier in identifiers_by_group[group_name] + } + + group_widget = GroupWidget( + group_name, group_icons, self._content_widget + ) + group_widget.active_changed.connect(self._on_active_changed) + group_widget.selected.connect(self._on_widget_selection) + group_widget.removed_selected.connect( + self._on_remove_selected + ) + self._content_layout.insertWidget(widget_idx, group_widget) + self._widgets_by_group[group_name] = group_widget + + widget_idx += 1 + group_widget.update_instances( + instances_by_group[group_name] + ) + + def refresh_instance_states(self): + """Trigger update of instances on group widgets.""" + for widget in self._widgets_by_group.values(): + widget.update_instance_values() + + def _on_active_changed(self): + self.active_changed.emit() + + def _on_widget_selection(self, instance_id, group_name): + self.select_item(instance_id, group_name) + + def select_item(self, instance_id, group_name): + """Select specific item by instance id. + + Pass `CONTEXT_ID` as instance id and empty string as group to select + global context item. + """ + if instance_id == CONTEXT_ID: + new_widget = self._context_widget + else: + group_widget = self._widgets_by_group[group_name] + new_widget = group_widget.get_widget_by_instance_id(instance_id) + + selected_widget = self._get_selected_widget() + if new_widget is selected_widget: + return + + if selected_widget is not None: + selected_widget.set_selected(False) + + self._selected_instance_id = instance_id + self._selected_group = group_name + if new_widget is not None: + new_widget.set_selected(True) + + self.selection_changed.emit() + + def _on_remove_selected(self): + selected_widget = self._get_selected_widget() + if selected_widget is None: + self._on_widget_selection(CONTEXT_ID, None) + + def get_selected_items(self): + """Get selected instance ids and context.""" + instances = [] + context_selected = False + selected_widget = self._get_selected_widget() + if selected_widget is self._context_widget: + context_selected = True + + elif selected_widget is not None: + instances.append(selected_widget.instance) + + return instances, context_selected diff --git a/openpype/tools/publisher/widgets/create_dialog.py b/openpype/tools/publisher/widgets/create_dialog.py new file mode 100644 index 0000000000..0206f038fb --- /dev/null +++ b/openpype/tools/publisher/widgets/create_dialog.py @@ -0,0 +1,559 @@ +import sys +import re +import traceback +import copy + +try: + import commonmark +except Exception: + commonmark = None +from Qt import QtWidgets, QtCore, QtGui + +from openpype.pipeline.create import CreatorError + +from .widgets import IconValuePixmapLabel +from ..constants import ( + SUBSET_NAME_ALLOWED_SYMBOLS, + VARIANT_TOOLTIP, + CREATOR_IDENTIFIER_ROLE, + FAMILY_ROLE +) + +SEPARATORS = ("---separator---", "---") + + +class CreateErrorMessageBox(QtWidgets.QDialog): + def __init__( + self, + creator_label, + subset_name, + asset_name, + exc_msg, + formatted_traceback, + parent=None + ): + super(CreateErrorMessageBox, self).__init__(parent) + self.setWindowTitle("Creation failed") + self.setFocusPolicy(QtCore.Qt.StrongFocus) + if not parent: + self.setWindowFlags( + self.windowFlags() | QtCore.Qt.WindowStaysOnTopHint + ) + + body_layout = QtWidgets.QVBoxLayout(self) + + main_label = ( + "Failed to create" + ) + main_label_widget = QtWidgets.QLabel(main_label, self) + body_layout.addWidget(main_label_widget) + + item_name_template = ( + "Creator: {}
" + "Subset: {}
" + "Asset: {}
" + ) + exc_msg_template = "{}" + + line = self._create_line() + body_layout.addWidget(line) + + item_name = item_name_template.format( + creator_label, subset_name, asset_name + ) + item_name_widget = QtWidgets.QLabel( + item_name.replace("\n", "
"), self + ) + body_layout.addWidget(item_name_widget) + + exc_msg = exc_msg_template.format(exc_msg.replace("\n", "
")) + message_label_widget = QtWidgets.QLabel(exc_msg, self) + body_layout.addWidget(message_label_widget) + + if formatted_traceback: + tb_widget = QtWidgets.QLabel( + formatted_traceback.replace("\n", "
"), self + ) + tb_widget.setTextInteractionFlags( + QtCore.Qt.TextBrowserInteraction + ) + body_layout.addWidget(tb_widget) + + footer_widget = QtWidgets.QWidget(self) + footer_layout = QtWidgets.QHBoxLayout(footer_widget) + button_box = QtWidgets.QDialogButtonBox(QtCore.Qt.Vertical) + button_box.setStandardButtons( + QtWidgets.QDialogButtonBox.StandardButton.Ok + ) + button_box.accepted.connect(self._on_accept) + footer_layout.addWidget(button_box, alignment=QtCore.Qt.AlignRight) + body_layout.addWidget(footer_widget) + + def _on_accept(self): + self.close() + + def _create_line(self): + line = QtWidgets.QFrame(self) + line.setFixedHeight(2) + line.setFrameShape(QtWidgets.QFrame.HLine) + line.setFrameShadow(QtWidgets.QFrame.Sunken) + return line + + +# TODO add creator identifier/label to details +class CreatorDescriptionWidget(QtWidgets.QWidget): + def __init__(self, parent=None): + super(CreatorDescriptionWidget, self).__init__(parent=parent) + + icon_widget = IconValuePixmapLabel(None, self) + icon_widget.setObjectName("FamilyIconLabel") + + family_label = QtWidgets.QLabel("family") + family_label.setAlignment( + QtCore.Qt.AlignBottom | QtCore.Qt.AlignLeft + ) + + description_label = QtWidgets.QLabel("description") + description_label.setAlignment( + QtCore.Qt.AlignTop | QtCore.Qt.AlignLeft + ) + + detail_description_widget = QtWidgets.QTextEdit(self) + detail_description_widget.setObjectName("InfoText") + detail_description_widget.setTextInteractionFlags( + QtCore.Qt.TextBrowserInteraction + ) + + label_layout = QtWidgets.QVBoxLayout() + label_layout.setSpacing(0) + label_layout.addWidget(family_label) + label_layout.addWidget(description_label) + + top_layout = QtWidgets.QHBoxLayout() + top_layout.setContentsMargins(0, 0, 0, 0) + top_layout.addWidget(icon_widget, 0) + top_layout.addLayout(label_layout, 1) + + layout = QtWidgets.QVBoxLayout(self) + layout.setContentsMargins(0, 0, 0, 0) + layout.addLayout(top_layout, 0) + layout.addWidget(detail_description_widget, 1) + + self.icon_widget = icon_widget + self.family_label = family_label + self.description_label = description_label + self.detail_description_widget = detail_description_widget + + def set_plugin(self, plugin=None): + if not plugin: + self.icon_widget.set_icon_def(None) + self.family_label.setText("") + self.description_label.setText("") + self.detail_description_widget.setPlainText("") + return + + plugin_icon = plugin.get_icon() + description = plugin.get_description() or "" + detailed_description = plugin.get_detail_description() or "" + + self.icon_widget.set_icon_def(plugin_icon) + self.family_label.setText("{}".format(plugin.family)) + self.family_label.setTextInteractionFlags(QtCore.Qt.NoTextInteraction) + self.description_label.setText(description) + + if commonmark: + html = commonmark.commonmark(detailed_description) + self.detail_description_widget.setHtml(html) + else: + self.detail_description_widget.setMarkdown(detailed_description) + + +class CreateDialog(QtWidgets.QDialog): + def __init__( + self, controller, asset_name=None, task_name=None, parent=None + ): + super(CreateDialog, self).__init__(parent) + + self.setWindowTitle("Create new instance") + + self.controller = controller + + if asset_name is None: + asset_name = self.dbcon.Session.get("AVALON_ASSET") + + if task_name is None: + task_name = self.dbcon.Session.get("AVALON_TASK") + + self._asset_name = asset_name + self._task_name = task_name + + self._last_pos = None + self._asset_doc = None + self._subset_names = None + self._selected_creator = None + + self._prereq_available = False + + self.message_dialog = None + + name_pattern = "^[{}]*$".format(SUBSET_NAME_ALLOWED_SYMBOLS) + self._name_pattern = name_pattern + self._compiled_name_pattern = re.compile(name_pattern) + + creator_description_widget = CreatorDescriptionWidget(self) + + creators_view = QtWidgets.QListView(self) + creators_model = QtGui.QStandardItemModel() + creators_view.setModel(creators_model) + + variant_input = QtWidgets.QLineEdit(self) + variant_input.setObjectName("VariantInput") + variant_input.setToolTip(VARIANT_TOOLTIP) + + variant_hints_btn = QtWidgets.QPushButton(self) + variant_hints_btn.setFixedWidth(18) + + variant_hints_menu = QtWidgets.QMenu(variant_hints_btn) + variant_hints_group = QtWidgets.QActionGroup(variant_hints_menu) + variant_hints_btn.setMenu(variant_hints_menu) + + variant_layout = QtWidgets.QHBoxLayout() + variant_layout.setContentsMargins(0, 0, 0, 0) + variant_layout.setSpacing(0) + variant_layout.addWidget(variant_input, 1) + variant_layout.addWidget(variant_hints_btn, 0) + + subset_name_input = QtWidgets.QLineEdit(self) + subset_name_input.setEnabled(False) + + create_btn = QtWidgets.QPushButton("Create", self) + create_btn.setEnabled(False) + + form_layout = QtWidgets.QFormLayout() + form_layout.addRow("Name:", variant_layout) + form_layout.addRow("Subset:", subset_name_input) + + left_layout = QtWidgets.QVBoxLayout() + left_layout.addWidget(QtWidgets.QLabel("Choose family:", self)) + left_layout.addWidget(creators_view, 1) + left_layout.addLayout(form_layout, 0) + left_layout.addWidget(create_btn, 0) + + layout = QtWidgets.QHBoxLayout(self) + layout.addLayout(left_layout, 0) + layout.addSpacing(5) + layout.addWidget(creator_description_widget, 1) + + create_btn.clicked.connect(self._on_create) + variant_input.returnPressed.connect(self._on_create) + variant_input.textChanged.connect(self._on_variant_change) + creators_view.selectionModel().currentChanged.connect( + self._on_item_change + ) + variant_hints_menu.triggered.connect(self._on_variant_action) + + controller.add_plugins_refresh_callback(self._on_plugins_refresh) + + self.creator_description_widget = creator_description_widget + + self.subset_name_input = subset_name_input + + self.variant_input = variant_input + self.variant_hints_btn = variant_hints_btn + self.variant_hints_menu = variant_hints_menu + self.variant_hints_group = variant_hints_group + + self.creators_model = creators_model + self.creators_view = creators_view + self.create_btn = create_btn + + @property + def dbcon(self): + return self.controller.dbcon + + def refresh(self): + self._prereq_available = True + + # Refresh data before update of creators + self._refresh_asset() + # Then refresh creators which may trigger callbacks using refreshed + # data + self._refresh_creators() + + if self._asset_doc is None: + # QUESTION how to handle invalid asset? + self.subset_name_input.setText("< Asset is not set >") + self._prereq_available = False + + if self.creators_model.rowCount() < 1: + self._prereq_available = False + + self.create_btn.setEnabled(self._prereq_available) + self.creators_view.setEnabled(self._prereq_available) + self.variant_input.setEnabled(self._prereq_available) + self.variant_hints_btn.setEnabled(self._prereq_available) + + def _refresh_asset(self): + asset_name = self._asset_name + + # Skip if asset did not change + if self._asset_doc and self._asset_doc["name"] == asset_name: + return + + # Make sure `_asset_doc` and `_subset_names` variables are reset + self._asset_doc = None + self._subset_names = None + if asset_name is None: + return + + asset_doc = self.dbcon.find_one({ + "type": "asset", + "name": asset_name + }) + self._asset_doc = asset_doc + + if asset_doc: + subset_docs = self.dbcon.find( + { + "type": "subset", + "parent": asset_doc["_id"] + }, + {"name": 1} + ) + self._subset_names = set(subset_docs.distinct("name")) + + def _refresh_creators(self): + # Refresh creators and add their families to list + existing_items = {} + old_creators = set() + for row in range(self.creators_model.rowCount()): + item = self.creators_model.item(row, 0) + identifier = item.data(CREATOR_IDENTIFIER_ROLE) + existing_items[identifier] = item + old_creators.add(identifier) + + # Add new families + new_creators = set() + for identifier, creator in self.controller.manual_creators.items(): + # TODO add details about creator + new_creators.add(identifier) + if identifier in existing_items: + item = existing_items[identifier] + else: + item = QtGui.QStandardItem() + item.setFlags( + QtCore.Qt.ItemIsEnabled | QtCore.Qt.ItemIsSelectable + ) + self.creators_model.appendRow(item) + + label = creator.label or identifier + item.setData(label, QtCore.Qt.DisplayRole) + item.setData(identifier, CREATOR_IDENTIFIER_ROLE) + item.setData(creator.family, FAMILY_ROLE) + + # Remove families that are no more available + for identifier in (old_creators - new_creators): + item = existing_items[identifier] + self.creators_model.takeRow(item.row()) + + if self.creators_model.rowCount() < 1: + return + + # Make sure there is a selection + indexes = self.creators_view.selectedIndexes() + if not indexes: + index = self.creators_model.index(0, 0) + self.creators_view.setCurrentIndex(index) + + def _on_plugins_refresh(self): + # Trigger refresh only if is visible + if self.isVisible(): + self.refresh() + + def _on_item_change(self, new_index, _old_index): + identifier = None + if new_index.isValid(): + identifier = new_index.data(CREATOR_IDENTIFIER_ROLE) + + creator = self.controller.manual_creators.get(identifier) + + self.creator_description_widget.set_plugin(creator) + + self._selected_creator = creator + if not creator: + return + + default_variants = creator.get_default_variants() + if not default_variants: + default_variants = ["Main"] + + default_variant = creator.get_default_variant() + if not default_variant: + default_variant = default_variants[0] + + for action in tuple(self.variant_hints_menu.actions()): + self.variant_hints_menu.removeAction(action) + action.deleteLater() + + for variant in default_variants: + if variant in SEPARATORS: + self.variant_hints_menu.addSeparator() + elif variant: + self.variant_hints_menu.addAction(variant) + + self.variant_input.setText(default_variant or "Main") + + def _on_variant_action(self, action): + value = action.text() + if self.variant_input.text() != value: + self.variant_input.setText(value) + + def _on_variant_change(self, variant_value): + if not self._prereq_available or not self._selected_creator: + if self.subset_name_input.text(): + self.subset_name_input.setText("") + return + + match = self._compiled_name_pattern.match(variant_value) + valid = bool(match) + self.create_btn.setEnabled(valid) + if not valid: + self._set_variant_state_property("invalid") + self.subset_name_input.setText("< Invalid variant >") + return + + project_name = self.controller.project_name + task_name = self._task_name + + asset_doc = copy.deepcopy(self._asset_doc) + # Calculate subset name with Creator plugin + subset_name = self._selected_creator.get_subset_name( + variant_value, task_name, asset_doc, project_name + ) + self.subset_name_input.setText(subset_name) + + self._validate_subset_name(subset_name, variant_value) + + def _validate_subset_name(self, subset_name, variant_value): + # Get all subsets of the current asset + if self._subset_names: + existing_subset_names = set(self._subset_names) + else: + existing_subset_names = set() + existing_subset_names_low = set( + _name.lower() + for _name in existing_subset_names + ) + + # Replace + compare_regex = re.compile(re.sub( + variant_value, "(.+)", subset_name, flags=re.IGNORECASE + )) + variant_hints = set() + if variant_value: + for _name in existing_subset_names: + _result = compare_regex.search(_name) + if _result: + variant_hints |= set(_result.groups()) + + # Remove previous hints from menu + for action in tuple(self.variant_hints_group.actions()): + self.variant_hints_group.removeAction(action) + self.variant_hints_menu.removeAction(action) + action.deleteLater() + + # Add separator if there are hints and menu already has actions + if variant_hints and self.variant_hints_menu.actions(): + self.variant_hints_menu.addSeparator() + + # Add hints to actions + for variant_hint in variant_hints: + action = self.variant_hints_menu.addAction(variant_hint) + self.variant_hints_group.addAction(action) + + # Indicate subset existence + if not variant_value: + property_value = "empty" + + elif subset_name.lower() in existing_subset_names_low: + # validate existence of subset name with lowered text + # - "renderMain" vs. "rendermain" mean same path item for + # windows + property_value = "exists" + else: + property_value = "new" + + self._set_variant_state_property(property_value) + + variant_is_valid = variant_value.strip() != "" + if variant_is_valid != self.create_btn.isEnabled(): + self.create_btn.setEnabled(variant_is_valid) + + def _set_variant_state_property(self, state): + current_value = self.variant_input.property("state") + if current_value != state: + self.variant_input.setProperty("state", state) + self.variant_input.style().polish(self.variant_input) + + def moveEvent(self, event): + super(CreateDialog, self).moveEvent(event) + self._last_pos = self.pos() + + def showEvent(self, event): + super(CreateDialog, self).showEvent(event) + if self._last_pos is not None: + self.move(self._last_pos) + + self.refresh() + + def _on_create(self): + indexes = self.creators_view.selectedIndexes() + if not indexes or len(indexes) > 1: + return + + if not self.create_btn.isEnabled(): + return + + index = indexes[0] + creator_label = index.data(QtCore.Qt.DisplayRole) + creator_identifier = index.data(CREATOR_IDENTIFIER_ROLE) + family = index.data(FAMILY_ROLE) + subset_name = self.subset_name_input.text() + variant = self.variant_input.text() + asset_name = self._asset_name + task_name = self._task_name + options = {} + # Where to define these data? + # - what data show be stored? + instance_data = { + "asset": asset_name, + "task": task_name, + "variant": variant, + "family": family + } + + error_info = None + try: + self.controller.create( + creator_identifier, subset_name, instance_data, options + ) + + except CreatorError as exc: + error_info = (str(exc), None) + + # Use bare except because some hosts raise their exceptions that + # do not inherit from python's `BaseException` + except: + exc_type, exc_value, exc_traceback = sys.exc_info() + formatted_traceback = "".join(traceback.format_exception( + exc_type, exc_value, exc_traceback + )) + error_info = (str(exc_value), formatted_traceback) + + if error_info: + box = CreateErrorMessageBox( + creator_label, subset_name, asset_name, *error_info + ) + box.show() + # Store dialog so is not garbage collected before is shown + self.message_dialog = box diff --git a/openpype/tools/publisher/widgets/icons.py b/openpype/tools/publisher/widgets/icons.py new file mode 100644 index 0000000000..fd5c45f901 --- /dev/null +++ b/openpype/tools/publisher/widgets/icons.py @@ -0,0 +1,45 @@ +import os + +from Qt import QtGui + + +def get_icon_path(icon_name=None, filename=None): + """Path to image in './images' folder.""" + if icon_name is None and filename is None: + return None + + if filename is None: + filename = "{}.png".format(icon_name) + + path = os.path.join( + os.path.dirname(os.path.abspath(__file__)), + "images", + filename + ) + if os.path.exists(path): + return path + return None + + +def get_image(icon_name=None, filename=None): + """Load image from './images' as QImage.""" + path = get_icon_path(icon_name, filename) + if path: + return QtGui.QImage(path) + return None + + +def get_pixmap(icon_name=None, filename=None): + """Load image from './images' as QPixmap.""" + path = get_icon_path(icon_name, filename) + if path: + return QtGui.QPixmap(path) + return None + + +def get_icon(icon_name=None, filename=None): + """Load image from './images' as QICon.""" + pix = get_pixmap(icon_name, filename) + if pix: + return QtGui.QIcon(pix) + return None diff --git a/openpype/tools/publisher/widgets/images/add.png b/openpype/tools/publisher/widgets/images/add.png new file mode 100644 index 0000000000..7fece2f3c6 Binary files /dev/null and b/openpype/tools/publisher/widgets/images/add.png differ diff --git a/openpype/tools/publisher/widgets/images/branch_closed.png b/openpype/tools/publisher/widgets/images/branch_closed.png new file mode 100644 index 0000000000..135cd0b29d Binary files /dev/null and b/openpype/tools/publisher/widgets/images/branch_closed.png differ diff --git a/openpype/tools/publisher/widgets/images/branch_open.png b/openpype/tools/publisher/widgets/images/branch_open.png new file mode 100644 index 0000000000..1a83955306 Binary files /dev/null and b/openpype/tools/publisher/widgets/images/branch_open.png differ diff --git a/openpype/tools/publisher/widgets/images/change_view.png b/openpype/tools/publisher/widgets/images/change_view.png new file mode 100644 index 0000000000..bda0ef1689 Binary files /dev/null and b/openpype/tools/publisher/widgets/images/change_view.png differ diff --git a/openpype/tools/publisher/widgets/images/copy.png b/openpype/tools/publisher/widgets/images/copy.png new file mode 100644 index 0000000000..522afcdc87 Binary files /dev/null and b/openpype/tools/publisher/widgets/images/copy.png differ diff --git a/openpype/tools/publisher/widgets/images/delete.png b/openpype/tools/publisher/widgets/images/delete.png new file mode 100644 index 0000000000..ab02768ba3 Binary files /dev/null and b/openpype/tools/publisher/widgets/images/delete.png differ diff --git a/openpype/tools/publisher/widgets/images/download_arrow.png b/openpype/tools/publisher/widgets/images/download_arrow.png new file mode 100644 index 0000000000..a35a12fb39 Binary files /dev/null and b/openpype/tools/publisher/widgets/images/download_arrow.png differ diff --git a/openpype/tools/publisher/widgets/images/minus.png b/openpype/tools/publisher/widgets/images/minus.png new file mode 100644 index 0000000000..4d0d6f486c Binary files /dev/null and b/openpype/tools/publisher/widgets/images/minus.png differ diff --git a/openpype/tools/publisher/widgets/images/play.png b/openpype/tools/publisher/widgets/images/play.png new file mode 100644 index 0000000000..7019bf19e9 Binary files /dev/null and b/openpype/tools/publisher/widgets/images/play.png differ diff --git a/openpype/tools/publisher/widgets/images/refresh.png b/openpype/tools/publisher/widgets/images/refresh.png new file mode 100644 index 0000000000..0b7f1565a7 Binary files /dev/null and b/openpype/tools/publisher/widgets/images/refresh.png differ diff --git a/openpype/tools/publisher/widgets/images/stop.png b/openpype/tools/publisher/widgets/images/stop.png new file mode 100644 index 0000000000..eda18d1db1 Binary files /dev/null and b/openpype/tools/publisher/widgets/images/stop.png differ diff --git a/openpype/tools/publisher/widgets/images/thumbnail.png b/openpype/tools/publisher/widgets/images/thumbnail.png new file mode 100644 index 0000000000..adea862e5b Binary files /dev/null and b/openpype/tools/publisher/widgets/images/thumbnail.png differ diff --git a/openpype/tools/publisher/widgets/images/validate.png b/openpype/tools/publisher/widgets/images/validate.png new file mode 100644 index 0000000000..d3cfa0b75d Binary files /dev/null and b/openpype/tools/publisher/widgets/images/validate.png differ diff --git a/openpype/tools/publisher/widgets/images/view_report.png b/openpype/tools/publisher/widgets/images/view_report.png new file mode 100644 index 0000000000..50e214c3f8 Binary files /dev/null and b/openpype/tools/publisher/widgets/images/view_report.png differ diff --git a/openpype/tools/publisher/widgets/images/warning.png b/openpype/tools/publisher/widgets/images/warning.png new file mode 100644 index 0000000000..76d1e34b6c Binary files /dev/null and b/openpype/tools/publisher/widgets/images/warning.png differ diff --git a/openpype/tools/publisher/widgets/list_view_widgets.py b/openpype/tools/publisher/widgets/list_view_widgets.py new file mode 100644 index 0000000000..e87ea3e130 --- /dev/null +++ b/openpype/tools/publisher/widgets/list_view_widgets.py @@ -0,0 +1,815 @@ +"""Simple easy instance view grouping instances into collapsible groups. + +View has multiselection ability. Groups are defined by `creator_label` +attribute on instance (Group defined by creator). + +Each item can be enabled/disabled with their checkbox, whole group +can be enabled/disabled with checkbox on group or +selection can be enabled disabled using checkbox or keyboard key presses: +- Space - change state of selection to oposite +- Enter - enable selection +- Backspace - disable selection + +``` +|- Options +|- [x] +| |- [x] +| |- [x] +| ... +|- [ ] +| |- [ ] +| ... +... +``` +""" +import collections + +from Qt import QtWidgets, QtCore, QtGui + +from openpype.style import get_objected_colors +from openpype.widgets.nice_checkbox import NiceCheckbox +from .widgets import AbstractInstanceView +from ..constants import ( + INSTANCE_ID_ROLE, + SORT_VALUE_ROLE, + IS_GROUP_ROLE, + CONTEXT_ID, + CONTEXT_LABEL +) + + +class ListItemDelegate(QtWidgets.QStyledItemDelegate): + """Generic delegate for instance group. + + All indexes having `IS_GROUP_ROLE` data set to True will use + `group_item_paint` method to draw it's content otherwise default styled + item delegate paint method is used. + + Goal is to draw group items with different colors for normal, hover and + pressed state. + """ + radius_ratio = 0.3 + + def __init__(self, parent): + super(ListItemDelegate, self).__init__(parent) + + colors_data = get_objected_colors() + group_color_info = colors_data["publisher"]["list-view-group"] + + self._group_colors = { + key: value.get_qcolor() + for key, value in group_color_info.items() + } + + def paint(self, painter, option, index): + if index.data(IS_GROUP_ROLE): + self.group_item_paint(painter, option, index) + else: + super(ListItemDelegate, self).paint(painter, option, index) + + def group_item_paint(self, painter, option, index): + """Paint group item.""" + self.initStyleOption(option, index) + + bg_rect = QtCore.QRectF( + option.rect.left(), option.rect.top() + 1, + option.rect.width(), option.rect.height() - 2 + ) + ratio = bg_rect.height() * self.radius_ratio + bg_path = QtGui.QPainterPath() + bg_path.addRoundedRect( + QtCore.QRectF(bg_rect), ratio, ratio + ) + + painter.save() + painter.setRenderHints( + painter.Antialiasing + | painter.SmoothPixmapTransform + | painter.TextAntialiasing + ) + + # Draw backgrounds + painter.fillPath(bg_path, self._group_colors["bg"]) + selected = option.state & QtWidgets.QStyle.State_Selected + hovered = option.state & QtWidgets.QStyle.State_MouseOver + if selected and hovered: + painter.fillPath(bg_path, self._group_colors["bg-selected-hover"]) + + elif hovered: + painter.fillPath(bg_path, self._group_colors["bg-hover"]) + + painter.restore() + + +class InstanceListItemWidget(QtWidgets.QWidget): + """Widget with instance info drawn over delegate paint. + + This is required to be able use custom checkbox on custom place. + """ + active_changed = QtCore.Signal(str, bool) + + def __init__(self, instance, parent): + super(InstanceListItemWidget, self).__init__(parent) + + self.instance = instance + + subset_name_label = QtWidgets.QLabel(instance["subset"], self) + subset_name_label.setObjectName("ListViewSubsetName") + + active_checkbox = NiceCheckbox(parent=self) + active_checkbox.setChecked(instance["active"]) + + layout = QtWidgets.QHBoxLayout(self) + content_margins = layout.contentsMargins() + layout.setContentsMargins(content_margins.left() + 2, 0, 2, 0) + layout.addWidget(subset_name_label) + layout.addStretch(1) + layout.addWidget(active_checkbox) + + self.setAttribute(QtCore.Qt.WA_TranslucentBackground) + subset_name_label.setAttribute(QtCore.Qt.WA_TranslucentBackground) + active_checkbox.setAttribute(QtCore.Qt.WA_TranslucentBackground) + + active_checkbox.stateChanged.connect(self._on_active_change) + + self._subset_name_label = subset_name_label + self._active_checkbox = active_checkbox + + self._has_valid_context = None + + self._set_valid_property(instance.has_valid_context) + + def _set_valid_property(self, valid): + if self._has_valid_context == valid: + return + self._has_valid_context = valid + state = "" + if not valid: + state = "invalid" + self._subset_name_label.setProperty("state", state) + self._subset_name_label.style().polish(self._subset_name_label) + + def is_active(self): + """Instance is activated.""" + return self.instance["active"] + + def set_active(self, new_value): + """Change active state of instance and checkbox.""" + checkbox_value = self._active_checkbox.isChecked() + instance_value = self.instance["active"] + if new_value is None: + new_value = not instance_value + + # First change instance value and them change checkbox + # - prevent to trigger `active_changed` signal + if instance_value != new_value: + self.instance["active"] = new_value + + if checkbox_value != new_value: + self._active_checkbox.setChecked(new_value) + + def update_instance(self, instance): + """Update instance object.""" + self.instance = instance + self.update_instance_values() + + def update_instance_values(self): + """Update instance data propagated to widgets.""" + # Check subset name + subset_name = self.instance["subset"] + if subset_name != self._subset_name_label.text(): + self._subset_name_label.setText(subset_name) + # Check active state + self.set_active(self.instance["active"]) + # Check valid states + self._set_valid_property(self.instance.has_valid_context) + + def _on_active_change(self): + new_value = self._active_checkbox.isChecked() + old_value = self.instance["active"] + if new_value == old_value: + return + + self.instance["active"] = new_value + self.active_changed.emit(self.instance.id, new_value) + + +class ListContextWidget(QtWidgets.QFrame): + """Context (or global attributes) widget.""" + def __init__(self, parent): + super(ListContextWidget, self).__init__(parent) + + label_widget = QtWidgets.QLabel(CONTEXT_LABEL, self) + + layout = QtWidgets.QHBoxLayout(self) + layout.setContentsMargins(5, 0, 2, 0) + layout.addWidget( + label_widget, 1, QtCore.Qt.AlignLeft | QtCore.Qt.AlignVCenter + ) + + self.setAttribute(QtCore.Qt.WA_TranslucentBackground) + label_widget.setAttribute(QtCore.Qt.WA_TranslucentBackground) + + self.label_widget = label_widget + + +class InstanceListGroupWidget(QtWidgets.QFrame): + """Widget representing group of instances. + + Has collapse/expand indicator, label of group and checkbox modifying all of + it's children. + """ + expand_changed = QtCore.Signal(str, bool) + toggle_requested = QtCore.Signal(str, int) + + def __init__(self, group_name, parent): + super(InstanceListGroupWidget, self).__init__(parent) + self.setObjectName("InstanceListGroupWidget") + + self.group_name = group_name + self._expanded = False + + expand_btn = QtWidgets.QToolButton(self) + expand_btn.setObjectName("ArrowBtn") + expand_btn.setArrowType(QtCore.Qt.RightArrow) + expand_btn.setMaximumWidth(14) + + name_label = QtWidgets.QLabel(group_name, self) + + toggle_checkbox = NiceCheckbox(parent=self) + + layout = QtWidgets.QHBoxLayout(self) + layout.setContentsMargins(5, 0, 2, 0) + layout.addWidget(expand_btn) + layout.addWidget( + name_label, 1, QtCore.Qt.AlignLeft | QtCore.Qt.AlignVCenter + ) + layout.addWidget(toggle_checkbox, 0) + + name_label.setAttribute(QtCore.Qt.WA_TranslucentBackground) + expand_btn.setAttribute(QtCore.Qt.WA_TranslucentBackground) + + expand_btn.clicked.connect(self._on_expand_clicked) + toggle_checkbox.stateChanged.connect(self._on_checkbox_change) + + self._ignore_state_change = False + + self._expected_checkstate = None + + self.name_label = name_label + self.expand_btn = expand_btn + self.toggle_checkbox = toggle_checkbox + + def set_checkstate(self, state): + """Change checkstate of "active" checkbox. + + Args: + state(QtCore.Qt.CheckState): Checkstate of checkbox. Have 3 + variants Unchecked, Checked and PartiallyChecked. + """ + if self.checkstate() == state: + return + self._ignore_state_change = True + self.toggle_checkbox.setCheckState(state) + self._ignore_state_change = False + + def checkstate(self): + """CUrrent checkstate of "active" checkbox.""" + return self.toggle_checkbox.checkState() + + def _on_checkbox_change(self, state): + if not self._ignore_state_change: + self.toggle_requested.emit(self.group_name, state) + + def _on_expand_clicked(self): + self.expand_changed.emit(self.group_name, not self._expanded) + + def set_expanded(self, expanded): + """Change icon of collapse/expand identifier.""" + if self._expanded == expanded: + return + + self._expanded = expanded + if expanded: + self.expand_btn.setArrowType(QtCore.Qt.DownArrow) + else: + self.expand_btn.setArrowType(QtCore.Qt.RightArrow) + + +class InstanceTreeView(QtWidgets.QTreeView): + """View showing instances and their groups.""" + toggle_requested = QtCore.Signal(int) + + def __init__(self, *args, **kwargs): + super(InstanceTreeView, self).__init__(*args, **kwargs) + + self.setObjectName("InstanceListView") + self.setHeaderHidden(True) + self.setIndentation(0) + self.setExpandsOnDoubleClick(False) + self.setSelectionMode( + QtWidgets.QAbstractItemView.ExtendedSelection + ) + self.viewport().setMouseTracking(True) + self._pressed_group_index = None + + def _expand_item(self, index, expand=None): + is_expanded = self.isExpanded(index) + if expand is None: + expand = not is_expanded + + if expand != is_expanded: + if expand: + self.expand(index) + else: + self.collapse(index) + + def get_selected_instance_ids(self): + """Ids of selected instances.""" + instance_ids = set() + for index in self.selectionModel().selectedIndexes(): + instance_id = index.data(INSTANCE_ID_ROLE) + if instance_id is not None: + instance_ids.add(instance_id) + return instance_ids + + def event(self, event): + if not event.type() == QtCore.QEvent.KeyPress: + pass + + elif event.key() == QtCore.Qt.Key_Space: + self.toggle_requested.emit(-1) + return True + + elif event.key() == QtCore.Qt.Key_Backspace: + self.toggle_requested.emit(0) + return True + + elif event.key() == QtCore.Qt.Key_Return: + self.toggle_requested.emit(1) + return True + + return super(InstanceTreeView, self).event(event) + + def _mouse_press(self, event): + """Store index of pressed group. + + This is to be able change state of group and process mouse + "double click" as 2x "single click". + """ + if event.button() != QtCore.Qt.LeftButton: + return + + pressed_group_index = None + pos_index = self.indexAt(event.pos()) + if pos_index.data(IS_GROUP_ROLE): + pressed_group_index = pos_index + + self._pressed_group_index = pressed_group_index + + def mousePressEvent(self, event): + self._mouse_press(event) + super(InstanceTreeView, self).mousePressEvent(event) + + def mouseDoubleClickEvent(self, event): + self._mouse_press(event) + super(InstanceTreeView, self).mouseDoubleClickEvent(event) + + def _mouse_release(self, event, pressed_index): + if event.button() != QtCore.Qt.LeftButton: + return False + + pos_index = self.indexAt(event.pos()) + if not pos_index.data(IS_GROUP_ROLE) or pressed_index != pos_index: + return False + + if self.state() == QtWidgets.QTreeView.State.DragSelectingState: + indexes = self.selectionModel().selectedIndexes() + if len(indexes) != 1 or indexes[0] != pos_index: + return False + + self._expand_item(pos_index) + return True + + def mouseReleaseEvent(self, event): + pressed_index = self._pressed_group_index + self._pressed_group_index = None + result = self._mouse_release(event, pressed_index) + if not result: + super(InstanceTreeView, self).mouseReleaseEvent(event) + + +class InstanceListView(AbstractInstanceView): + """Widget providing abstract methods of AbstractInstanceView for list view. + + This is public access to and from list view. + """ + def __init__(self, controller, parent): + super(InstanceListView, self).__init__(parent) + + self.controller = controller + + instance_view = InstanceTreeView(self) + instance_delegate = ListItemDelegate(instance_view) + instance_view.setItemDelegate(instance_delegate) + instance_model = QtGui.QStandardItemModel() + + proxy_model = QtCore.QSortFilterProxyModel() + proxy_model.setSourceModel(instance_model) + proxy_model.setFilterCaseSensitivity(QtCore.Qt.CaseInsensitive) + proxy_model.setSortRole(SORT_VALUE_ROLE) + proxy_model.setFilterKeyColumn(0) + proxy_model.setDynamicSortFilter(True) + + instance_view.setModel(proxy_model) + + layout = QtWidgets.QHBoxLayout(self) + layout.setContentsMargins(0, 0, 0, 0) + layout.addWidget(instance_view) + + instance_view.selectionModel().selectionChanged.connect( + self._on_selection_change + ) + instance_view.collapsed.connect(self._on_collapse) + instance_view.expanded.connect(self._on_expand) + instance_view.toggle_requested.connect(self._on_toggle_request) + + self._group_items = {} + self._group_widgets = {} + self._widgets_by_id = {} + self._group_by_instance_id = {} + self._context_item = None + self._context_widget = None + + self._instance_view = instance_view + self._instance_delegate = instance_delegate + self._instance_model = instance_model + self._proxy_model = proxy_model + + def _on_expand(self, index): + group_name = index.data(SORT_VALUE_ROLE) + group_widget = self._group_widgets.get(group_name) + if group_widget: + group_widget.set_expanded(True) + + def _on_collapse(self, index): + group_name = index.data(SORT_VALUE_ROLE) + group_widget = self._group_widgets.get(group_name) + if group_widget: + group_widget.set_expanded(False) + + def _on_toggle_request(self, toggle): + selected_instance_ids = self._instance_view.get_selected_instance_ids() + if toggle == -1: + active = None + elif toggle == 1: + active = True + else: + active = False + + for instance_id in selected_instance_ids: + widget = self._widgets_by_id.get(instance_id) + if widget is not None: + widget.set_active(active) + + def _update_group_checkstate(self, group_name): + widget = self._group_widgets.get(group_name) + if widget is None: + return + + activity = None + for instance_id, _group_name in self._group_by_instance_id.items(): + if _group_name != group_name: + continue + + instance_widget = self._widgets_by_id.get(instance_id) + if not instance_widget: + continue + + if activity is None: + activity = int(instance_widget.is_active()) + + elif activity != instance_widget.is_active(): + activity = -1 + break + + if activity is None: + return + + state = QtCore.Qt.PartiallyChecked + if activity == 0: + state = QtCore.Qt.Unchecked + elif activity == 1: + state = QtCore.Qt.Checked + widget.set_checkstate(state) + + def refresh(self): + """Refresh instances in the view.""" + # Prepare instances by their groups + instances_by_group_name = collections.defaultdict(list) + group_names = set() + for instance in self.controller.instances: + group_label = instance.creator_label + group_names.add(group_label) + instances_by_group_name[group_label].append(instance) + + # Sort view at the end of refresh + # - is turned off until any change in view happens + sort_at_the_end = False + + # Access to root item of main model + root_item = self._instance_model.invisibleRootItem() + + # Create or use already existing context item + # - context widget does not change so we don't have to update anything + context_item = None + if self._context_item is None: + sort_at_the_end = True + context_item = QtGui.QStandardItem() + context_item.setData(0, SORT_VALUE_ROLE) + context_item.setData(CONTEXT_ID, INSTANCE_ID_ROLE) + + root_item.appendRow(context_item) + + index = self._instance_model.index( + context_item.row(), context_item.column() + ) + proxy_index = self._proxy_model.mapFromSource(index) + widget = ListContextWidget(self._instance_view) + self._instance_view.setIndexWidget(proxy_index, widget) + + self._context_widget = widget + self._context_item = context_item + + # Create new groups based on prepared `instances_by_group_name` + new_group_items = [] + for group_name in group_names: + if group_name in self._group_items: + continue + + group_item = QtGui.QStandardItem() + group_item.setData(group_name, SORT_VALUE_ROLE) + group_item.setData(True, IS_GROUP_ROLE) + group_item.setFlags(QtCore.Qt.ItemIsEnabled) + self._group_items[group_name] = group_item + new_group_items.append(group_item) + + # Add new group items to root item if there are any + if new_group_items: + # Trigger sort at the end + sort_at_the_end = True + root_item.appendRows(new_group_items) + + # Create widget for each new group item and store it for future usage + for group_item in new_group_items: + index = self._instance_model.index( + group_item.row(), group_item.column() + ) + proxy_index = self._proxy_model.mapFromSource(index) + group_name = group_item.data(SORT_VALUE_ROLE) + widget = InstanceListGroupWidget(group_name, self._instance_view) + widget.expand_changed.connect(self._on_group_expand_request) + widget.toggle_requested.connect(self._on_group_toggle_request) + self._group_widgets[group_name] = widget + self._instance_view.setIndexWidget(proxy_index, widget) + + # Remove groups that are not available anymore + for group_name in tuple(self._group_items.keys()): + if group_name in group_names: + continue + + group_item = self._group_items.pop(group_name) + root_item.removeRow(group_item.row()) + widget = self._group_widgets.pop(group_name) + widget.deleteLater() + + # Store which groups should be expanded at the end + expand_groups = set() + # Process changes in each group item + # - create new instance, update existing and remove not existing + for group_name, group_item in self._group_items.items(): + # Instance items to remove + # - will contain all exising instance ids at the start + # - instance ids may be removed when existing instances are checked + to_remove = set() + # Mapping of existing instances under group item + existing_mapping = {} + + # Get group index to be able get children indexes + group_index = self._instance_model.index( + group_item.row(), group_item.column() + ) + + # Iterate over children indexes of group item + for idx in range(group_item.rowCount()): + index = self._instance_model.index(idx, 0, group_index) + instance_id = index.data(INSTANCE_ID_ROLE) + # Add all instance into `to_remove` set + to_remove.add(instance_id) + existing_mapping[instance_id] = idx + + # Collect all new instances that are not existing under group + # New items + new_items = [] + # Tuples of new instance and instance itself + new_items_with_instance = [] + # Group activity (should be {-1;0;1} at the end) + # - 0 when all instances are disabled + # - 1 when all instances are enabled + # - -1 when it's mixed + activity = None + for instance in instances_by_group_name[group_name]: + instance_id = instance.id + # Handle group activity + if activity is None: + activity = int(instance["active"]) + elif activity == -1: + pass + elif activity != instance["active"]: + activity = -1 + + self._group_by_instance_id[instance_id] = group_name + # Remove instance id from `to_remove` if already exists and + # trigger update of widget + if instance_id in to_remove: + to_remove.remove(instance_id) + widget = self._widgets_by_id[instance_id] + widget.update_instance(instance) + continue + + # Create new item and store it as new + item = QtGui.QStandardItem() + item.setData(instance["subset"], SORT_VALUE_ROLE) + item.setData(instance_id, INSTANCE_ID_ROLE) + new_items.append(item) + new_items_with_instance.append((item, instance)) + + # Set checkstate of group checkbox + state = QtCore.Qt.PartiallyChecked + if activity == 0: + state = QtCore.Qt.Unchecked + elif activity == 1: + state = QtCore.Qt.Checked + + widget = self._group_widgets[group_name] + widget.set_checkstate(state) + + # Remove items that were not found + idx_to_remove = [] + for instance_id in to_remove: + idx_to_remove.append(existing_mapping[instance_id]) + + # Remove them in reverse order to prevend row index changes + for idx in reversed(sorted(idx_to_remove)): + group_item.removeRows(idx, 1) + + # Cleanup instance related widgets + for instance_id in to_remove: + self._group_by_instance_id.pop(instance_id) + widget = self._widgets_by_id.pop(instance_id) + widget.deleteLater() + + # Process new instance items and add them to model and create + # their widgets + if new_items: + # Trigger sort at the end when new instances are available + sort_at_the_end = True + + # Add items under group item + group_item.appendRows(new_items) + + for item, instance in new_items_with_instance: + if not instance.has_valid_context: + expand_groups.add(group_name) + item_index = self._instance_model.index( + item.row(), + item.column(), + group_index + ) + proxy_index = self._proxy_model.mapFromSource(item_index) + widget = InstanceListItemWidget( + instance, self._instance_view + ) + widget.active_changed.connect(self._on_active_changed) + self._instance_view.setIndexWidget(proxy_index, widget) + self._widgets_by_id[instance.id] = widget + + # Trigger sort at the end of refresh + if sort_at_the_end: + self._proxy_model.sort(0) + + # Expand groups marked for expanding + for group_name in expand_groups: + group_item = self._group_items[group_name] + proxy_index = self._proxy_model.mapFromSource(group_item.index()) + + self._instance_view.expand(proxy_index) + + def refresh_instance_states(self): + """Trigger update of all instances.""" + for widget in self._widgets_by_id.values(): + widget.update_instance_values() + + def _on_active_changed(self, changed_instance_id, new_value): + selected_instances, _ = self.get_selected_items() + + selected_ids = set() + found = False + for instance in selected_instances: + selected_ids.add(instance.id) + if not found and instance.id == changed_instance_id: + found = True + + if not found: + selected_ids = set() + selected_ids.add(changed_instance_id) + + self._change_active_instances(selected_ids, new_value) + group_names = set() + for instance_id in selected_ids: + group_name = self._group_by_instance_id.get(instance_id) + if group_name is not None: + group_names.add(group_name) + + for group_name in group_names: + self._update_group_checkstate(group_name) + + def _change_active_instances(self, instance_ids, new_value): + if not instance_ids: + return + + changed_ids = set() + for instance_id in instance_ids: + widget = self._widgets_by_id.get(instance_id) + if widget: + changed_ids.add(instance_id) + widget.set_active(new_value) + + if changed_ids: + self.active_changed.emit() + + def get_selected_items(self): + """Get selected instance ids and context selection. + + Returns: + tuple: Selected instance ids and boolean if context + is selected. + """ + instances = [] + context_selected = False + instances_by_id = { + instance.id: instance + for instance in self.controller.instances + } + + for index in self._instance_view.selectionModel().selectedIndexes(): + instance_id = index.data(INSTANCE_ID_ROLE) + if not context_selected and instance_id == CONTEXT_ID: + context_selected = True + + elif instance_id is not None: + instance = instances_by_id.get(instance_id) + if instance: + instances.append(instance) + + return instances, context_selected + + def _on_selection_change(self, *_args): + self.selection_changed.emit() + + def _on_group_expand_request(self, group_name, expanded): + group_item = self._group_items.get(group_name) + if not group_item: + return + + group_index = self._instance_model.index( + group_item.row(), group_item.column() + ) + proxy_index = self.mapFromSource(group_index) + self._instance_view.setExpanded(proxy_index, expanded) + + def _on_group_toggle_request(self, group_name, state): + if state == QtCore.Qt.PartiallyChecked: + return + + if state == QtCore.Qt.Checked: + active = True + else: + active = False + + group_item = self._group_items.get(group_name) + if not group_item: + return + + instance_ids = set() + for row in range(group_item.rowCount()): + item = group_item.child(row) + instance_id = item.data(INSTANCE_ID_ROLE) + if instance_id is not None: + instance_ids.add(instance_id) + + self._change_active_instances(instance_ids, active) + + proxy_index = self.mapFromSource(group_item.index()) + if not self._instance_view.isExpanded(proxy_index): + self._instance_view.expand(proxy_index) diff --git a/openpype/tools/publisher/widgets/models.py b/openpype/tools/publisher/widgets/models.py new file mode 100644 index 0000000000..0cfd771ef1 --- /dev/null +++ b/openpype/tools/publisher/widgets/models.py @@ -0,0 +1,201 @@ +import re +import collections + +from Qt import QtCore, QtGui + + +class AssetsHierarchyModel(QtGui.QStandardItemModel): + """Assets hiearrchy model. + + For selecting asset for which should beinstance created. + + Uses controller to load asset hierarchy. All asset documents are stored by + their parents. + """ + def __init__(self, controller): + super(AssetsHierarchyModel, self).__init__() + self._controller = controller + + self._items_by_name = {} + + def reset(self): + self.clear() + + self._items_by_name = {} + assets_by_parent_id = self._controller.get_asset_hierarchy() + + items_by_name = {} + _queue = collections.deque() + _queue.append((self.invisibleRootItem(), None)) + while _queue: + parent_item, parent_id = _queue.popleft() + children = assets_by_parent_id.get(parent_id) + if not children: + continue + + children_by_name = { + child["name"]: child + for child in children + } + items = [] + for name in sorted(children_by_name.keys()): + child = children_by_name[name] + item = QtGui.QStandardItem(name) + items_by_name[name] = item + items.append(item) + _queue.append((item, child["_id"])) + + parent_item.appendRows(items) + + self._items_by_name = items_by_name + + def name_is_valid(self, item_name): + return item_name in self._items_by_name + + def get_index_by_name(self, item_name): + item = self._items_by_name.get(item_name) + if item: + return item.index() + return QtCore.QModelIndex() + + +class TasksModel(QtGui.QStandardItemModel): + """Tasks model. + + Task model must have set context of asset documents. + + Items in model are based on 0-infinite asset documents. Always contain + an interserction of context asset tasks. When no assets are in context + them model is empty if 2 or more are in context assets that don't have + tasks with same names then model is empty too. + + Args: + controller (PublisherController): Controller which handles creation and + publishing. + """ + def __init__(self, controller): + super(TasksModel, self).__init__() + self._controller = controller + self._items_by_name = {} + self._asset_names = [] + self._task_names_by_asset_name = {} + + def set_asset_names(self, asset_names): + """Set assets context.""" + self._asset_names = asset_names + self.reset() + + @staticmethod + def get_intersection_of_tasks(task_names_by_asset_name): + """Calculate intersection of task names from passed data. + + Example: + ``` + # Passed `task_names_by_asset_name` + { + "asset_1": ["compositing", "animation"], + "asset_2": ["compositing", "editorial"] + } + ``` + Result: + ``` + # Set + {"compositing"} + ``` + + Args: + task_names_by_asset_name (dict): Task names in iterable by parent. + """ + tasks = None + for task_names in task_names_by_asset_name.values(): + if tasks is None: + tasks = set(task_names) + else: + tasks &= set(task_names) + + if not tasks: + break + return tasks or set() + + def is_task_name_valid(self, asset_name, task_name): + """Is task name available for asset. + + Args: + asset_name (str): Name of asset where should look for task. + task_name (str): Name of task which should be available in asset's + tasks. + """ + task_names = self._task_names_by_asset_name.get(asset_name) + if task_names and task_name in task_names: + return True + return False + + def reset(self): + """Update model by current context.""" + if not self._asset_names: + self._items_by_name = {} + self._task_names_by_asset_name = {} + self.clear() + return + + task_names_by_asset_name = ( + self._controller.get_task_names_by_asset_names(self._asset_names) + ) + self._task_names_by_asset_name = task_names_by_asset_name + + new_task_names = self.get_intersection_of_tasks( + task_names_by_asset_name + ) + old_task_names = set(self._items_by_name.keys()) + if new_task_names == old_task_names: + return + + root_item = self.invisibleRootItem() + for task_name in old_task_names: + if task_name not in new_task_names: + item = self._items_by_name.pop(task_name) + root_item.removeRow(item.row()) + + new_items = [] + for task_name in new_task_names: + if task_name in self._items_by_name: + continue + + item = QtGui.QStandardItem(task_name) + self._items_by_name[task_name] = item + new_items.append(item) + root_item.appendRows(new_items) + + +class RecursiveSortFilterProxyModel(QtCore.QSortFilterProxyModel): + """Recursive proxy model. + + Item is not filtered if any children match the filter. + + Use case: Filtering by string - parent won't be filtered if does not match + the filter string but first checks if any children does. + """ + def filterAcceptsRow(self, row, parent_index): + regex = self.filterRegExp() + if not regex.isEmpty(): + model = self.sourceModel() + source_index = model.index( + row, self.filterKeyColumn(), parent_index + ) + if source_index.isValid(): + pattern = regex.pattern() + + # Check current index itself + value = model.data(source_index, self.filterRole()) + if re.search(pattern, value, re.IGNORECASE): + return True + + rows = model.rowCount(source_index) + for idx in range(rows): + if self.filterAcceptsRow(idx, source_index): + return True + return False + + return super(RecursiveSortFilterProxyModel, self).filterAcceptsRow( + row, parent_index + ) diff --git a/openpype/tools/publisher/widgets/publish_widget.py b/openpype/tools/publisher/widgets/publish_widget.py new file mode 100644 index 0000000000..e4f3579978 --- /dev/null +++ b/openpype/tools/publisher/widgets/publish_widget.py @@ -0,0 +1,521 @@ +import os +import json +import time + +from Qt import QtWidgets, QtCore, QtGui + +from openpype.pipeline import KnownPublishError + +from .validations_widget import ValidationsWidget +from ..publish_report_viewer import PublishReportViewerWidget +from .widgets import ( + StopBtn, + ResetBtn, + ValidateBtn, + PublishBtn, + CopyPublishReportBtn, + SavePublishReportBtn, + ShowPublishReportBtn +) + + +class ActionsButton(QtWidgets.QToolButton): + def __init__(self, parent=None): + super(ActionsButton, self).__init__(parent) + + self.setText("< No action >") + self.setPopupMode(self.MenuButtonPopup) + menu = QtWidgets.QMenu(self) + + self.setMenu(menu) + + self._menu = menu + self._actions = [] + self._current_action = None + + self.clicked.connect(self._on_click) + + def current_action(self): + return self._current_action + + def add_action(self, action): + self._actions.append(action) + action.triggered.connect(self._on_action_trigger) + self._menu.addAction(action) + if self._current_action is None: + self._set_action(action) + + def set_action(self, action): + if action not in self._actions: + self.add_action(action) + self._set_action(action) + + def _set_action(self, action): + if action is self._current_action: + return + self._current_action = action + self.setText(action.text()) + self.setIcon(action.icon()) + + def _on_click(self): + self._current_action.trigger() + + def _on_action_trigger(self): + action = self.sender() + if action not in self._actions: + return + + self._set_action(action) + + +class PublishFrame(QtWidgets.QFrame): + """Frame showed during publishing. + + Shows all information related to publishing. Contains validation error + widget which is showed if only validation error happens during validation. + + Processing layer is default layer. Validation error layer is shown if only + validation exception is raised during publishing. Report layer is available + only when publishing process is stopped and must be manually triggered to + change into that layer. + + +------------------------------------------------------------------------+ + | | + | | + | | + | < Validation error widget > | + | | + | | + | | + | | + +------------------------------------------------------------------------+ + | < Main label > | + | < Label top > | + | (#### 10% ) | + | | + | Report: